-
Notifications
You must be signed in to change notification settings - Fork 0
Plugins
letsgo does the things a Go release always needs. Plugins do the things some repositories need and most do not, and they live outside letsgo so that its own configuration stays a closed set.
They are published from letsgo-plugins.
| plugin | hook | what it does |
|---|---|---|
letsgo-multi |
archive-layout |
ships every command in one archive per target, and one formula that installs them all |
letsgo-env |
ldflags |
compiles values from the environment into the binary |
letsgo-cask |
tap-files |
writes a Homebrew cask for a macOS build, alongside the formula |
letsgo-tfplan |
none — reads a saved plan file | writes a plan as terraform show -json, or as a markdown job summary |
A plugin may not change the released bytes unless its output is recorded.
letsgo runs a plugin with the hook's name as its only argument, writes one JSON
object to stdin and reads one from stdout. Whatever comes back is written into
letsgo.json, so letsgo verify replays the recorded answer and never runs a
plugin — a release stays reproducible on a machine that has none of these
installed.
A plugin that answers a hook is pinned by digest, because a program that decides what gets built is a build input exactly as the compiler is. A plugin that only reads a finished release is not, because there is nothing left for it to decide.
letsgo plugin install # every pin in letsgo.mod
letsgo plugin install letsgo-multi # the latest release
letsgo plugin install letsgo-multi@v0.2.0 # one exact versionThe plugin is installed into a content-addressed store keyed by its own digest,
so different repositories can pin different versions on one machine without
overwriting each other — see Plugin store. letsgo finds it there
itself; --link also puts it on PATH ($GOBIN, or $GOPATH/bin, unless -o
names another) for running it by hand. --repo installs from somewhere other
than letsgo-plugins.
The download is checked twice against the release's own manifest: once as an
archive, and once as the executable inside it. That is the difference between
this and the curl | tar recipe it replaces, which fetched an archive over TLS
and trusted whatever came back.
Afterwards it prints the line letsgo.mod wants:
installed letsgo-multi v0.2.0
/home/you/go/bin/letsgo-multi
archive cf69535d0972
binary 4934015d3de6
pin it in letsgo.mod:
plugin archive-layout letsgo-multi v0.2.0 sha256:4934015d3de6…
The hook comes from letsgo.mod when the plugin is already declared there —
the upgrade case, where only the digest changes. On a first install letsgo
leaves it as <hook> rather than guessing: a plugin documents the hook it
answers, and a letsgo that carried a table of plugin names would be a letsgo
that knows about particular plugins.
letsgo plugin list then answers the question that follows every pin:
$ letsgo plugin list
archive-layout letsgo-multi v0.2.0 ok /home/you/go/bin/letsgo-multi
ldflags letsgo-env v0.2.0 not installed
letsgo.mod pins a plugin by the digest of the executable:
plugin archive-layout letsgo-multi v0.2.0 sha256:<binary_sha256 from the release>
plugin ldflags letsgo-env v0.2.0 sha256:<binary_sha256 from the release>
letsgo plugin install prints that digest, and it is also in the letsgo.json
published with every release: find the artifact for the platform that runs your
release and take its binary_sha256 — the digest of the executable inside the
archive, not of the archive. letsgo hashes the executable before running it and
refuses to continue if it is not the one pinned.
letsgo-cask is not pinned, because it answers no hook. It runs after the
release is over and reads what was published, so it cannot change a byte of it.
Install it and run it; there is nothing to declare in letsgo.mod.
The obvious recipe cannot satisfy a pin, and failing mysteriously later would be worse than saying so here:
go install github.com/danielriddell21/letsgo-plugins/cmd/letsgo-env@v0.2.0 # not for a pinned plugingo install does not pass -trimpath, so the build directory is compiled into
the binary. Two machines with different GOPATH values produce different bytes
from the same source at the same version, so there is no one digest to pin:
$ GOPATH=/tmp/a go install …/letsgo-env@latest && sha256sum /tmp/a/bin/letsgo-env
f0877414…
$ GOPATH=/tmp/b go install …/letsgo-env@latest && sha256sum /tmp/b/bin/letsgo-env
e3c3c34a…
The released binaries are built by letsgo with -trimpath -buildvcs=false, so
their digests are a function of the source and the Go version and nothing else.
That is what makes them pinnable, and it is the same property the plugins exist
to protect. go install is fine for letsgo-cask, which nothing pins.
A plugin line carries a single digest, and a plugin binary differs per
platform, so a pin matches the platform that publishes the release — normally
linux/amd64 on CI. That is enough for releasing and for verifying: letsgo verify replays the answer recorded in letsgo.json and never runs a plugin,
so a release can be checked on a machine that has none of these installed.
Running letsgo release by hand on another platform needs that platform's pin.
A repository whose product is a collection of small tools ships the collection. Without this, eleven commands become fifty-five downloads and eleven Homebrew formulas — and if any tool shares a name with a Homebrew core package, its formula shadows the core one.
No configuration: every command goes into one archive named after the project.
letsgo's ldflags are literal on purpose, so that a release is a function of
its commit. This injects values from the environment instead, and records them.
Configure it in letsgo-env.mod, beside letsgo.mod:
inject internal/telemetry.otelEndpoint OTEL_ENDPOINT
inject internal/telemetry.otelAuthToken OTEL_AUTH_TOKEN
Every named variable must be set, or the release fails — injecting an empty string silently is how a release ships a binary that cannot phone home and does not say why.
Nothing injected this way is a secret. A value passed to -X is compiled
into the binary and recoverable from a published artifact with strings, and
letsgo records it in letsgo.json so that the build can be reproduced. It was
already public the moment it shipped. letsgo says so at plan time:
! plugins letsgo-env compiled 2 value(s) into the binary: …
they are recoverable with `strings` and recorded in letsgo.json,
so they are not secrets
A value that must stay secret belongs in the environment the program runs in, not in the program.
letsgo writes formulas, not casks. A formula is the right shape for a command-line program; a repository shipping a windowed build alongside its CLI wants both.
This is not a hook. It reads the letsgo.json a release already published and
writes a cask from it, so there is nothing to pin and nothing it can do to the
bytes — by the time it runs, the release is over.
letsgo-cask dist/letsgo.json --repo you/gambit \
--desc "Watch two chess agents play in a native macOS window" \
--license MIT \
--caveats "The board opens a window and is macOS-only."--variant names the variant whose archives the cask installs, as spelled in
letsgo.mod; without it the release's own archives are used. The cask goes to
stdout, or to the file named by -o. Committing it to a tap is git's job.
--desc, --license and --caveats describe the program rather than the
artifacts, so the release cannot supply them and they are passed in. Each is
omitted from the cask when empty rather than guessed at. --caveats renders as
a heredoc, so it can run to several lines.
It emits binary, not app: letsgo publishes an executable, and claiming an
.app would name something the archive does not contain.
Core emits one plan format and no other — see Plan and apply. This companion converts a saved plan for tools that already read Terraform plans, for teams with existing tooling built on that shape:
letsgo-tfplan json release.plan # terraform show -json shape
letsgo-tfplan md release.plan # a markdown job summaryIt answers no hook and is never pinned: it reads a plan file after letsgo plan
has written one, and cannot affect what apply does. The JSON maps + ~ - = to
create, update, delete and no-op, gives before-and-after state as
attribute maps, and marks digests unknown at plan time in after_unknown.
- uses: danielriddell21/letsgo-action@v1
with:
command: "" # install letsgo, run nothing yet
- run: letsgo plugin install letsgo-multi@v0.2.0
- run: letsgo releaseStart here
Releasing
Checking
Extending
Running it
About