Skip to content

Plan Apply

Dan Riddell edited this page Sep 30, 2026 · 1 revision

Plan and 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.

What a plan is

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.

The plan file

-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.

What apply refuses

apply rebuilds. It does not trust the plan's digests — it reproduces them, and stops before any write if they disagree.

  1. The tag must still resolve to the plan's commit. A force-moved tag is a different release wearing the same name.
  2. Every target must be in its observed or planned state. observed means nothing has happened yet; planned means an earlier apply already did that part. Anything else is a stale plan, and the target is named.
  3. 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.

Apply with no file

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.

Yank plans

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.

Drift detection

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 }

Markdown for a job summary

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.

In the release

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.

Reading a plan from Go

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.

Terraform-shaped output

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 summary

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.

apply accepts only the native schema, never the export. Nothing outside core — no plugin, no companion — can change what apply does.

In GitHub Actions

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.

Clone this wiki locally