-
Notifications
You must be signed in to change notification settings - Fork 0
Plan Apply
letsgo plan [--diff [--exit-code]] [--format md] [-out file]
letsgo apply [file] [-auto-approve]
letsgo plan -yank <tag> [-out file]
letsgo release builds and publishes in one step. plan and apply split that
in two, so a person or an approval gate can sit between deciding what a release
would do and doing it.
The split is not a dry run in the usual sense. A dry run that takes its own code
path proves something about the dry run. apply performs the same publish
release does, from the same code, and release is defined as plan followed
by apply -auto-approve in one process.
letsgo plan on its own resolves config, version and gates without building.
--diff — implied by -out and by --format md — goes further: it builds to a
scratch directory, hashes the artifacts, discards the bytes, then reads the
forge read-only and compares.
letsgo plan you/gambit v1.3.0 @ 4f2a9c1
+ release v1.3.0 (latest, notes 2.1 KB)
+ asset gambit_1.3.0_linux_amd64.tar.gz sha256:ab12...
+ asset gambit_1.3.0_darwin_arm64.tar.gz sha256:77e0...
~ tap Formula/gambit.rb blob 9c1e... -> 3d7a...
+ image ghcr.io/you/gambit:1.3.0 sha256:e4f1...
~ image ghcr.io/you/gambit:latest sha256:0b9d... -> sha256:e4f1...
= image ghcr.io/you/gambit:1 (already e4f1...)
+ proxy warm example.com/gambit@v1.3.0
Plan: 7 to add, 2 to change, 0 to remove.
Saved to release.plan (sha256:5c0e...). Apply with: letsgo apply release.plan
Every line is <op> <kind> <target> <detail>. The operators are + add,
~ change, - remove and = no change; the kinds are release, asset,
tap, image, proxy and gomod. The footer counts everything but =.
Reading the forge is the part a build alone cannot tell you. A formula edited by hand, an image tag someone re-pointed, an asset uploaded from a laptop — those are differences between your repository and the world, and they only show up by looking.
Planning writes nothing, anywhere. It needs no more than read access.
-out writes the plan as JSON and prints its digest:
{
"schema": 1,
"letsgo_version": "v0.9.0",
"created_at": "2026-09-27T10:00:00Z",
"kind": "release",
"repo": "you/gambit", "tag": "v1.3.0", "commit": "4f2a9c1...",
"config_sha256": "...",
"manifest": { "the predicted letsgo.json": "..." },
"actions": [
{"op": "+", "kind": "asset", "target": "gambit_1.3.0_linux_amd64.tar.gz",
"observed": null, "planned": "sha256:ab12..."},
{"op": "~", "kind": "tap", "target": "Formula/gambit.rb",
"observed": "blob:9c1e...", "planned": "blob:3d7a..."}
]
}The plan holds digests, not bytes. It records what the artifacts will hash to, never the artifacts themselves, so it is small enough to read, to attach to a pull request, and to hand to a reviewer who is approving a release rather than downloading one.
observed and planned are per-target state fingerprints: an asset digest, a
tap blob sha, an image digest, or absent.
apply rebuilds. It does not trust the plan's digests — it reproduces them, and
stops before any write if they disagree.
- The tag must still resolve to the plan's commit. A force-moved tag is a different release wearing the same name.
-
Every target must be in its
observedorplannedstate.observedmeans nothing has happened yet;plannedmeans an earlier apply already did that part. Anything else is a stale plan, and the target is named. - Every rebuilt artifact digest must match the plan's manifest, and the fields that differ are listed.
Only then does it write, and it writes only what the plan's actions name. Targets already in their planned state are skipped, which is what makes a failed apply re-runnable: an apply that died after three of six assets uploads the remaining three and succeeds.
The comparison excludes the manifest's own plan field, since a plan cannot
contain its own digest.
$ letsgo apply release.plan
refusing: the rebuild is not the release this plan agreed
toolchain go1.27.1 -> go1.27.2
asset gambit_1.3.0_linux_amd64.tar.gz sha256:ab12... -> sha256:c40d...
nothing was published
A changed toolchain between plan and apply is caught here rather than shipped.
letsgo apply with no argument makes the plan itself, shows it, and asks:
Plan: 7 to add, 2 to change, 0 to remove.
Apply? [y/N]
It then publishes exactly what it showed. With no terminal to ask on it requires
-auto-approve, and otherwise stops before building anything — a release is not
something to start by accident in a pipe.
A retraction is planned the same way:
$ letsgo plan -yank v1.3.0 -out y.plan
~ gomod retract v1.3.0 (commit on trunk)
~ tap Formula/gambit.rb -> v1.2.8
~ tap Formula/gambit@next.rb -> v1.2.8
~ tap Casks/gambit.rb -> v1.2.8
~ release v1.3.0 mark yanked in notes
Plan: 0 to add, 5 to change, 0 to remove.
The plan's kind is "yank". There is no manifest to predict, so staleness is
checked against the forge targets and the go.mod blob only. letsgo apply y.plan does exactly those things, or stops if the forge or go.mod has moved.
See letsgo yank for what a retraction means and why the
follow-up tag matters.
letsgo plan --diff --exit-code exits 2 when a release would change anything,
which makes it a scheduled check rather than a release step:
on: { schedule: [{ cron: "0 6 * * *" }] }
jobs:
drift:
runs-on: ubuntu-latest
permissions: { contents: read }
steps:
- uses: actions/checkout@v7
with: { fetch-depth: 0 }
- uses: actions/setup-go@v7
with: { go-version-file: go.mod }
- uses: danielriddell21/letsgo-action@v1
with: { command: plan, args: --diff --exit-code }letsgo plan --format md writes the diff as Markdown: a heading, the changes in
a diff code block with +, - and ! in column 0 so GitHub colours them,
and a footer counting what is added, changed, removed and left alone. = lines
are omitted and counted.
Only the Markdown goes to standard output, so it needs no filtering:
letsgo plan --format md >> "$GITHUB_STEP_SUMMARY"The rest of the report goes to standard error.
The published manifest records plan: {sha256, created_at, letsgo_version}, and
the plan file itself is attached to the release as letsgo.plan.json.
letsgo verify prints that record and checks the attached
plan's digest against it — so "this release was agreed before it was made" is a
property anyone can check afterwards, not a claim in a changelog.
The plan file's types are the public github.com/danielriddell21/letsgo/plan
package, with a Read function. It is covered by letsgo's own apidiff gate, so
a tool that reads plans is not reading an undocumented shape that moves.
Core emits its own format and no other. The
letsgo-tfplan companion converts a plan file for
tools that already read Terraform plans:
letsgo-tfplan json release.plan # terraform show -json shape
letsgo-tfplan md release.plan # a markdown job summaryThe 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.
apply accepts only the native schema, never the export. Nothing outside core —
no plugin, no companion — can change what apply does.
plan uploads the plan as an artifact and writes the rendered plan to the job
summary; apply downloads that artifact. An environment with required reviewers
between the two jobs makes the reviewer approve the plan itself:
jobs:
plan:
runs-on: ubuntu-latest
permissions: { contents: read }
steps:
- uses: actions/checkout@v7
with: { fetch-depth: 0 }
- uses: actions/setup-go@v7
with: { go-version-file: go.mod }
- uses: danielriddell21/letsgo-action@v1
with: { command: plan }
apply:
needs: plan
environment: release
runs-on: ubuntu-latest
permissions: { contents: write, id-token: write, attestations: write }
steps:
- uses: actions/checkout@v7
with: { fetch-depth: 0 }
- uses: actions/setup-go@v7
with: { go-version-file: go.mod }
- uses: danielriddell21/letsgo-action@v1
with: { command: apply }The plan job needs only read access, so the approval gate is also a privilege
boundary. apply deliberately runs on a different runner — the point is that
the plan survives the move.
plan-file and plan-artifact name the file and the artifact; both default to
release.plan. Giving plan or apply any args runs them as written, with
no plan saved, uploaded or downloaded. See GitHub Action.
Start here
Releasing
Checking
Extending
Running it
About