Skip to content

Releases: jphastings/gosd

gosd 0.8.4 (2026-09-08)

Choose a tag to compare

@gosd-knope gosd-knope released this 08 Sep 13:26
e527306

Features

New ready package: hold the status LED until your app is ready

An app that needs WiFi, an external API, or a sensor initialized before
it's genuinely "all okay" can now hold gosd-init's status LED on
"booting" past /app's process start, and only let it show "running" once
the app says so. Importing github.com/jphastings/gosd/ready is the
declaration — gosd build inspects the app's own dependency graph, no flag
needed — and calling ready.Signal() is what flips the LED. Every app that
doesn't import the package is unaffected: the LED still moves to "running"
the instant /app starts, exactly as before.

New wifi package

wifi.Join(ctx, wifi.Credentials{SSID, Passphrase}, wifi.Options{Persist: bool}) lets an app join a WiFi network at runtime, using credentials it
obtained by its own means — an NFC tag, a provisioning screen, anything
other than what the config tree already had at boot. Join blocks until
gosd-init reports the attempt joined or failed. Off a device (any binary
not built by gosd build), Join returns an immediate, actionable error
rather than reaching for a filesystem that isn't there.

wifi.Join now works end to end

gosd-init's WiFi bring-up now watches for a runtime wifi.Join request and
reconciles it: tears down the current association, attempts the requested
network, and reports back joined or failed with the failure reason as
precisely as nl80211 gives it. This runs on any WiFi-capable board even if
no WiFi credentials were configured at boot, and answers honestly with "no
WiFi interface" on a board with no WiFi hardware at all — an app's Join
call never just hangs.

wifi.Options{Persist: true} now does something: on a successful join,
gosd-init writes the network into the card's config tree so the device
rejoins it on every future boot too. A failed join is never persisted.

A successful runtime join also restarts the cloudflared or tailscale-funnel
ingress tunnel (whichever the image was built with), so it comes back on
the new network without a reboot — including a join back onto the same
network, which still counts as a fresh restart.

Fixes

Board images are now built from artifacts v0.10.4

gosd build downloads the board kernels and bootloaders published as
v0.10.4, which brings:

  • Turing RK1 kernel and U-Boot are now published in artifacts releases

gosd 0.8.3 (2026-08-31)

Choose a tag to compare

@gosd-knope gosd-knope released this 31 Aug 10:28

Features

New gosd init command

gosd init scaffolds a gosd-build.toml in the current directory, prefilling it with auto-detected values: the app's main package (discovered by scanning for package main), a git-tagged version when available, and a derived label prefix. The rest of the configuration remains for you to fill in by hand — partition sizes, ingress tunnels, and other app-specific build options — so every new project can skip the manual template boilerplate and start with a working configuration file.

artifacts 0.10.4 (2026-08-31)

Choose a tag to compare

@gosd-knope gosd-knope released this 31 Aug 10:28

Features

Turing RK1 kernel and U-Boot are now published in artifacts releases

The Turing RK1's compiled kernel and mainline U-Boot (idbloader + FIT with
BL31, rkbin DDR-init blobs) are now attached to artifacts releases,
alongside every other board's. The board itself isn't buildable via gosd build yet — that's a separate, later step — this just gets its compiled
output into a real release for the first time.

gosd 0.8.2 (2026-08-25)

Choose a tag to compare

@gosd-knope gosd-knope released this 28 Aug 08:15
7f76171

Features

gosd build accepts go build's own compile flags

gosd build (and gosd-build.toml) now accepts five flags that mirror go build itself, applied to your app's own compile only (never gosd-init):

  • --ldflags — e.g. --ldflags="-X main.version=1.4.2" to stamp a version
    into the compiled binary, closing a real gap: until now nothing gosd
    built ever put a version into the app binary itself (--app-version
    bakes into config.json/crash reports only). --ldflags also
    understands one template token, {{.AppVersion}}, substituted with
    --app-version's fully resolved value (including a git: resolution) —
    so --app-version=git:v*.*.* --ldflags="-X main.version={{.AppVersion}}"
    resolves the version once and carries it into both config.json and the
    binary, with nothing to resolve twice.
  • --tags — extra Go build tags for your app compile, comma- or
    space-separated. These merge with gosd's own mandatory
    gosd/gosd_<board> tags rather than replacing them; a gosd or
    gosd_-prefixed value is refused, since gosd always adds those itself.
  • --trimpath, --gcflags, --asmflags — passed straight through to go build.

None of the five are mirrored on gosd run, matching its existing pattern
of not exposing every build flag. See the build-config docs and
docs/board-build-tags.md for details.

One consequence: gosd build no longer errors on the compile flags tools
like goreleaser's go builder pass by default.
That closes the flag-acceptance gap, but not the rest of the mismatch —
goreleaser's target axis is a {goos}_{goarch} matrix, not gosd's
--board, and -o still means "a bootable disk image," never "one
binary" — so driving a real build still takes some assembly (a builds:
entry per board with filler goos/goarch, or a config-file-read gosd build from a hooks: pre: step instead of tool: gosd).

gosd 0.8.1 (2026-08-24)

Choose a tag to compare

@gosd-knope gosd-knope released this 24 Aug 06:55
e5b0cbe

Features

App versions can resolve from git tags: --app-version git:v*.*.*

A version recorded in a checked-in gosd-build.toml couldn't change per
release — so now it doesn't have to. An [app] version (or
--app-version) value starting git: resolves at build time from your
app repository's tags, in pure Go with no git binary required: the
wildcard pattern after git: picks which tags count, the matching tag
nearest the commit being built wins (describe semantics, so maintenance
branches never steal a newer tag from another branch), and the pattern's
literal prefix is stripped from the result — git:v*.*.* turns tag
v1.4.2 into 1.4.2. Between releases the version reads
1.4.2-5-g<hash>, and an unclean worktree appends -dirty rather than
failing the build.

Shallow CI checkouts have no tags to search, so the error for that case
names the efficient fix directly: a treeless fetch — fetch-depth: 0
with filter: tree:0 for actions/checkout, or
git fetch --unshallow --tags --filter=tree:0 — which downloads the
commit graph and tags (all resolution needs) while skipping historical
file contents. Details and examples live on the build-config
documentation page.

One edge: a literal version that genuinely began with git: now means
something else; no known app does this.

gosd 0.8.0 (2026-08-23)

Choose a tag to compare

@gosd-knope gosd-knope released this 24 Aug 00:07
ee5298a

Breaking Changes

Build options can live in a checked-in gosd-build.toml (and three flags are renamed)

A repository can now record its canonical build in a gosd-build.toml next
to its code: every gosd build flag has a key of the same name, an [app] main key stands in for the package-path argument, and so a bare gosd build in a fresh checkout reproduces the repo's intended image(s). Flags
passed on the command line always win, per option, so nothing recorded in
the file is ever locked in. Relative paths in the file resolve against the
file's own directory, unknown keys are errors naming the key, and gosd run honours the subset of keys whose flags it mirrors. The details, and a
full example file, are in the new build-config documentation page.

The file's keys map onto flags structurally — --boot-size is size under
[boot] — and to make the real groups fit, three flags are renamed
(breaking, with no alias):

  • --support-url is now --app-support-url
  • --config-dir is now --boot-config-dir (on gosd build and gosd run)
  • --catalog is now --publish-catalog

Invocations using the old spellings fail with cobra's unknown-flag error;
update scripts and Makefiles to the new names — or move the values into a
gosd-build.toml and drop the flags altogether.

gosd 0.7.2 (2026-08-21)

Choose a tag to compare

@gosd-knope gosd-knope released this 23 Aug 15:56
335b1aa

Features

gosd build --kernel-param adds your own kernel command-line parameters

Some things can only be turned on from the kernel command line, and until now
a GoSD app had no way to put anything there. gosd build --kernel-param snd_bcm2835.enable_hdmi=1 (repeatable) now bakes a parameter into the image.

You write it once and GoSD delivers it wherever the board's family actually
reads its command line from — cmdline.txt on the Raspberry Pi boards, the
append line of extlinux.conf on the Rockchip and Allwinner boards — so a
bare gosd build, which builds every board, carries your parameters onto all
of them. A parameter one board's kernel doesn't recognise is inert there, just
as an unrecognised kernel parameter always is, so cross-board builds don't
need per-board flags.

Values are checked for shape and never for vocabulary: whitespace, newlines
and other characters that would corrupt the boot config are refused with an
error naming the offending value, but GoSD keeps no list of "known" kernel
parameters to fall foul of — which matters, since a gosd build-kernel kernel
can introduce parameters no such list would ever have had. Parameters render
in the order you pass them, after GoSD's own, so builds stay byte-identical.

gosd run mirrors the flag, extending qemu's kernel command line, so a
parameter can be tried under qemu before a card is ever flashed.

A USB mass-storage gadget can no longer be pointed at the boot partition

gadget.MassStorage now refuses to expose the partition your device booted
from, or the whole card holding it, to a USB host — whether or not it
happens to be mounted at the time. That partition carries the kernel the
board starts from and the config tree it was provisioned with, so a computer
given write access to it has code execution on the next boot.

Until now the only thing standing in the way was a check that the backing
path wasn't currently mounted, which worked purely because gosd-init keeps
/boot mounted for the life of the device. Nothing on a GoSD board could
identify the boot medium independently: the image boots from an initramfs,
so the kernel command line names no root block device. gosd-init now
publishes the devices it reserves as it boots, and the gadget package
refuses against that instead.

The refused error wraps a new gadget.ErrReservedDevice, so an app that can
do something else when a volume isn't available — offer a different one, or
run without the drive — can match it with errors.Is and degrade
gracefully, as gadget.ErrNoController already allows.

The data partition is not refused: it is your app's own storage, and
sharing it stays your call (examples/usbwebsite still offers it behind its
documented opt-in). Everything the runtime documentation says about what
lives on that partition, and why publishing it discloses this device's WiFi
passphrase and ingress tokens, still applies.

Apps built against this release keep working on images produced by an older
gosd, which publish no such list; there, MassStorage behaves exactly as
it did before.

Fixes

A crash report no longer carries the WiFi passphrase

LAST_FATAL_ERROR.md is a file whose own text asks its reader to forward the
whole thing, so gosd-init scrubs the secrets it knows about out of it first.
The WiFi passphrase was not one of them: every app environment value and both
ingress credentials were swept, and the one credential most likely to be
reused on another account was not.

It is now registered the same way and at the same moment as the tunnel
credentials — from both places one can come from, the card's wifi/passphrase
setting and the passphrase baked into the image — and appears in a report as
{wifi: passphrase}. The network's SSID is deliberately left alone: it is
broadcast to anyone in radio range, and removing it would cost a WiFi failure
the one detail that makes it diagnosable.

No gosd-init code path printed the passphrase before this, so no released
image is known to have leaked one. The point of the redaction rule set is that
nobody has to audit each new log line, or each upstream library's, to keep
that true.

Notes

Updates are by reflashing, permanently — and the boot-time claim is now the measured one

Two product decisions, both of which change what the documentation promises.

Over-the-network updates are dropped. GoSD will not gain an OTA update
mechanism: reflashing the card is the update path, permanently. Plain
Raspberry Pi Imager reflash was already the documented baseline, and it keeps
what a device has — a --data-size=expand image re-adopts its own data
partition on first boot, and the config store puts the operator's hostname,
WiFi credentials and hand-edited settings back onto the newly flashed card. So
an upgrade costs one Imager run and loses neither data nor settings. The cost,
stated plainly: fixing a fielded device needs physical access to its card.
Consequence worth knowing if you audit what an image listens on — mDNS is now
the only network listener in gosd-init, with no sanctioned exception
pending. The design that was declined is kept in the repository as a record of
a road not taken.

The boot-time claim was wrong in both directions and is now measured. The
README promised "under 5 seconds, WiFi included". On real hardware your app is
running about 10 seconds after power-on, and a wired board answers on
hostname.local in about the same (ROCK 4SE: ~9.2s power-to-HTTP). Over WiFi,
expect ~25s — association, DHCP and mDNS announcement all happen after your
app is already serving. "The app is running" and "the app is reachable" are
different numbers, and the README now says so.

Also corrected: the feature list said USB gadget mode could present the board
as a USB Ethernet device. It cannot — CDC-ACM serial and mass storage are what
the gadget package builds today, as the compatibility matrix has always
said.

npm/gosd 0.3.2 (2026-08-20)

Choose a tag to compare

@gosd-knope gosd-knope released this 20 Aug 19:45
7b33736

Fixes

The injection manifest is read with a size cap, and warns when it is unpinned over plain HTTP

fetchManifest now gives up on a manifest past 1 MiB rather than buffering the
whole response before looking at it: it refuses an oversized Content-Length
outright, and counts bytes as they arrive regardless, since a host willing to
send an endless body is not one whose headers mean anything. manifestSha256
makes a tampered manifest detectable, but only once its bytes are already in
memory, so on its own it was no defence against that same untrusted host
exhausting the tab before there was anything to hash. A real manifest is a few
KiB of JSON, so a response past the cap is a wrong URL or a hostile one either
way. The image fetch was already bounded this way by its manifest's declared
size.

Fetching an unpinned manifest over plain http:// now logs a warning
(loopback excepted, so a local dev server stays quiet). The image itself is
perfectly safe over http — every byte is hash-verified whatever the transport —
but the manifest is where those hashes come from, so it is the one fetch whose
integrity nothing downstream can re-derive.

The README now also says outright that escaping the content you pass in is
yours to do: placeholder and setting values are written to the card verbatim,
so a quickstart-style renderConfigYaml(userInput) is where user input has to
be escaped for the format it is going into.

gosd 0.7.1 (2026-08-20)

Choose a tag to compare

@gosd-knope gosd-knope released this 20 Aug 21:19
3af23e4

Fixes

Board images are now built from artifacts v0.10.3

gosd build downloads the board kernels and bootloaders published as
v0.10.3, which brings:

  • The status LED's fatal signal now survives kernel shutdown
  • SPI now works on the Raspberry Pi Zero W

gosd 0.7.0 (2026-08-20)

Choose a tag to compare

@gosd-knope gosd-knope released this 20 Aug 19:45
7b33736

Breaking Changes

gosd build and gosd run refuse a package path that is really a build flag

A package path starting with - is no longer passed to the Go toolchain, and
gosd's own go invocations now pass Go's -- terminator before it, so one can
never be read as a build flag again.

This closes a way of getting arbitrary code to run on the machine doing the
build. gosd build -- -toolexec=/tmp/payload reached go build with
-toolexec intact, and -toolexec runs a program of the caller's choosing in
place of the compiler — before the app was even compiled, and so with control
over every image that build produced. Reaching it needs influence over gosd's
arguments, which is exactly what a wrapper forwarding a value it does not fully
control gives away: a CI job templating a branch-derived path, a Makefile's
gosd build $(PKG), a script taking a package argument.

An ambient GOFLAGS is no longer inherited by gosd's go subprocesses either.
It can carry -toolexec just as well, and needs no control over gosd's
arguments at all — a .envrc picked up on entering a cloned repository, a
modified shell profile, or an inherited CI variable was enough. GOPROXY,
GOPRIVATE and the other module-fetch variables are still honoured.

Anything that isn't recognisably a package path — a relative path, an absolute
path, or an import path — is now refused with an error naming what was rejected
and what a valid argument looks like. Every documented invocation, gosd build . included, is unaffected.

Features

More actionable build errors, and a new gadget sentinel

gosd build --data-size now rejects a size that rounds down to less than
one sector (512 bytes) as soon as the flag is parsed, instead of running a
full cross-compile and artifact fetch for every board before failing deep
inside the image writer — the same "fail fast, before any image bytes
exist" contract the 256GiB ceiling check already gave.

A board package's own invariant-violation panic (for example, a u-boot.itb
too big for its locked offset) is now caught and turned into the same
single-line, actionable CLI error every other build failure produces,
instead of reaching the terminal as a raw Go stack trace.

gadget now exports ErrNoController, wrapped into the error Apply
returns when a board has no USB peripheral controller to bind to. This
matches the errors.Is-able sentinel convention sound.ErrNoDevice,
emmc.ErrNoEMMC, and disk.ErrNoDisk already give apps that want to
detect a missing device and degrade gracefully instead of failing outright.

disk and emmc no longer adopt a FAT32/exFAT volume on the strength of its label

Formatting a disk or emmc volume as FAT32 or exFAT wrote the filesystem and
mounted it, with nothing in between forcing those writes to the medium — and
every later boot decided the volume was "already provisioned" by reading its
label back. Neither half is safe. Until a flush happens, an arbitrary subset of
a format's writes may have reached the card, in no particular order; and the
volume label is written near the end of one, so a card that lost power
mid-format could come back with a label that says "ready" over FAT tables that
were never finished. Adopting it handed the app torn cluster chains that
corrupt on first write. The other way round — a label that did not land —
left storage that was refused on every boot, forever, despite never having held
anything.

Both are now fixed the way ext4 already worked. A format is followed by a flush
to the medium, and only once that and the mount have succeeded does GoSD write
a reserved, empty .gosd-established file into the volume's root. A later boot
adopts the volume only if that marker is there; a volume carrying the app's
label but no marker and no files is crash debris, and is repaired
(reformatted) without needing destructive, because nothing was ever written
to it.

Cards already in the field keep their data. A FAT32 or exFAT volume
formatted by an earlier release carries no marker, so it is adopted on the
evidence of the files already in it — GoSD's formatters never create a file, so
anything in the root can only have been written by an app that was handed the
mountpoint, which only happens after a format completed. The marker is written
in passing, so the upgrade happens once. The one volume this can reformat is
one with no files in its root at all, which by definition has nothing to
lose.

Two smaller behaviour changes come with it, both matching what ext4 has done
since it became the default:

  • A FAT32/exFAT volume that matches the app's label and filesystem but cannot
    be mounted is now refused with an error matching both ErrRefusedFormat and
    ErrUnmountable, rather than a bare mount error — and, with
    destructive: true, is reformatted rather than reported.
  • .gosd-established is reserved. Apps must not delete it; doing so does not
    destroy data, but it costs the volume its proof of a finished format.

Separately, emmc can no longer select an eMMC's boot0/boot1/rpmb/gp0-3
hardware partitions as a format target. disk has excluded them by name since
the day the risk was found; emmc merely never encountered one, because the
kernel happens not to label those devices the way its selection looks for. The
exclusion now lives in the code both packages share, so neither can pick one —
formatting an eMMC's boot area leaves a board that no longer boots and cannot
be recovered from its SD card.

--usb-gadget works on the Radxa Cubie A5E again

Building with --usb-gadget for this board now ships a device tree that
disables the USB-C port's host controllers, so the port stays with the
peripheral controller and can present itself as a USB device. Without it the
host side takes the port during boot and the board can never enumerate,
whatever the device tree's dr_mode says — which is why the flag has been
refused for this board since that was found on hardware.

The two roles are mutually exclusive on this hardware, which has no circuitry
to detect which is wanted: an image built with --usb-gadget cannot use its
USB-C port as a USB host. The USB 3.0 Type-A port is unaffected.

disk can now wait for a USB drive that is still enumerating

disk.FormatAndMount discovers once. That is right for the storage GoSD was
first built around — an NVMe SSD or an onboard eMMC is on an on-SoC bus and is
already enumerated by the time an app's main runs — but USB mass storage is
not like that. A stick or an enclosure needs its hub port powered, then a probe,
then a scan, then a medium-ready report: commonly a second or two after the host
controller comes up, and longer through a hub or for a disk that spins up. An app
that reached FormatAndMount before all that finished got ErrNoDisk for a
drive that was physically plugged in.

The new disk.Options.Wait is how long to keep looking:

res := <-disk.FormatAndMountWith("APPDATA", "/storage", disk.Options{
	Wait: 10 * time.Second,
})

Its zero value is what shipped before it existed, so no app changes behaviour by
upgrading. There is deliberately no default window: one would stall every app
that treats ErrNoDisk as "carry on without a disk", and every board with
nothing attached would pay it on each boot. A long Wait is also the honest way
to ask for "use a drive whenever someone plugs one in". ErrNoDisk now names the
option when an app never asked to wait, and reports how long it waited when it
did.

gosd cache inspects and clears the CLI's on-disk caches

gosd build/run/build-kernel/build-external already auto-prune their
pinned-download caches to the current pin after every successful run, and the
durable build-kernel/build-external state directory now keeps only its 8
most recently used entries — everyday growth was already bounded. gosd cache adds manual visibility and control on top of that:

  • gosd cache dir prints the path of every cache location.
  • gosd cache size reports how much disk space each one is using, and a
    total.
  • gosd cache clean deletes the pinned-download caches (board artifacts, the
    CA bundle, ingress binaries, kernel firmware) — always safe, since every
    one is a sha256-verified download the next build/run simply re-fetches.

gosd cache clean deliberately leaves the build-kernel/build-external
state alone by default: each of its entries costs 20-75 minutes of container
build time to reproduce. Pass --builds to also clear it.

Nothing executes from /data any more, and three build inputs are validated like their neighbours

The writable data partition — and any volume the emmc or disk packages
mount — is now mounted noexec, alongside the nosuid/nodev it already
carried. Nothing a GoSD image ships runs from there (/app and every
gosd-shipped helper live in the initramfs rootfs), and /data is the one
filesystem on the device an operator can rewrite from a laptop, so the kernel
now refuses outright rather than the property resting on there happening to be
nothing to run.

Three build-time inputs are now checked the way their neighbours already were.
--publish-base-url must be an absolute http(s) URL with a host, matching
--support-url — it is what every download link in a generated os_list.json
is built from, and those land in an end user's Raspberry Pi Imager. A
gosd-kernel.toml [[firmware]] entry's url must be https, matching every
board manifest gosd ships — a loopback host may still use http, since there
is no network path to sit on and that is how a local fixture server is pointed
at. And sound.Options.Format's Rate and Channels
are rejected when negative, naming the field, instead of arriving at the kernel
as an enormous unsigned value and coming back as a bare EINVAL
indistinguishable from hardware that simply cannot do what was asked.

gadget.Close no longer reports a gadget as torn down when the teardown
f...

Read more