Skip to main content

Preflight

The one preflight engine: environment, publisher credentials and one-way-door state, and the reconcile sweep, run before any release stage

Anodizer answers three questions about a tree before it releases anything, with zero mutations:

  1. Can this runner publish? Every enabled stage and publisher declares what it needs — CLI tools, env vars and secrets, endpoint reachability, the docker daemon, loadable key material — and all of it is evaluated in one collect-all pass.
  2. Will every publisher accept the target version? Each one-way-door publisher (cargo, chocolatey, winget, aur) reports the version's upstream state, each publisher probes its own credential, and the rollback scope each publisher would need is checked.
  3. Is the target version already upstream with these bytes? Each selected publisher runs the same reconcile() it runs at dispatch time.

One engine asks all three, in that order, and anodizer preflight and anodizer release both run it. There is nothing to configure: requirements are declared next to each stage and publisher implementation, so the check cannot drift from what the pipeline reads.

Inside anodizer release

The engine runs once, after the config and git context resolve and before the before: hooks, in anodizer release and anodizer release --publish-only (scoped to the stages that mode runs). Every failure is collected in one pass and the release aborts before any side effect:

       Error 4 of 24 preflight check(s) failed:
       Error   ✗ tool 'cosign' not found on PATH [needed by: stage:sign, stage:docker-sign]
       Error   ✗ env var(s) missing or empty: COSIGN_KEY [needed by: stage:sign, stage:docker-sign]
       Error   ✗ env var AUR_SSH_KEY does not hold a usable SSH private key: missing '-----END ... PRIVATE KEY-----' footer [needed by: publish:aur]
       Error   ✗ endpoint 'http://minio.svc:9003' unreachable: connection refused [needed by: stage:blob]
       Error preflight: 4 environment failure(s) across 24 check(s); fix the issues above before re-running

Secret values are never printed — only env-var names. Key material (SSH, PGP, cosign) is structurally parsed as well as checked for presence, so the classic "the CI secret pasted in truncated" failure is caught before a publisher half-runs. A key that merely lost its trailing newline is accepted: the key writer normalizes that before ssh reads it.

The release skips the engine in four cases:

InvocationWhy
release --skip=preflighta pre-tag CI job already ran anodizer preflight on this tree (see the CI pattern)
release --snapshotno upstream side effects to guard
release --dry-runsame
release --splitsplit legs are operator-orchestrated partial pipelines

--announce-only runs the environment half alone, scoped to the announce stage's requirements: announcers fire sequentially with real side effects, so a missing token aborts before the first post. The publisher half is also skipped whenever the publish stage is skipped, since neither run crosses a one-way door.

Publisher check

After the environment report, the engine asks each one-way-door publisher for the target version's upstream state, and every selected publisher probes its own credential and reports the rollback scope it would need:

   • Pre-flight publisher check
   • cargo mycrate@1.2.3       clean
   • chocolatey mycrate@1.2.3  in-moderation — package in moderation queue
   • winget mycrate@1.2.3      pr-pending — https://github.com/microsoft/winget-pkgs/pull/123
   • aur mycrate@1.2.3         unknown — AUR RPC returned 503
   • preflight found 1 publisher(s) clean

A row already live upstream renders under the success marker (✓ cargo mycrate@1.2.3 published); every other state is a plain • status line. None of the five states aborts the run:

StateMeaning
cleanVersion not present upstream; safe to publish
publishedVersion already published / approved; the publisher's reconcile() skips it (idempotent)
in-moderationSubmitted, awaiting review; reconcile() treats a still-pending submission as already-done work and skips
pr-pendingA manifest PR is already open for this version; reconcile() finds it and skips re-submitting
unknownThe state query itself failed (network error, unexpected response); reconcile() falls through and lets the publisher run

Each publisher's own reconcile() step makes the skip-vs-dispatch call from this same state at the moment it runs, so a re-run of an in-flight release converges on work that is already underway.

What can abort the run here is a blocker: a credential a publisher probed and found unusable with no other way to authenticate (see the npm page for how one publisher grades that), or a missing rollback scope under --strict. Every other finding is a warning that prints and lets the run continue — a rollback scope missing in default mode, a credential probe that could not reach a verdict, a publisher that is optional. The rollback scope preflight lists what is asked of each publisher. A blocker aborts the run with a preflight: N resilience blocker(s): … line naming each one.

Reconcile sweep

The third half calls the same reconcile() each publisher runs at dispatch time, over the same --publishers / --skip selection the publish loop applies, so a deselected publisher is never probed and never gates the exit code, and the standalone command and the release cannot answer differently:

   • Reconcile state
   ✓ cargo       complete — 1.2.3 live with matching cksum
   • npm         absent — will publish
   ✓ winget      complete — open PR https://github.com/microsoft/winget-pkgs/pull/123
   • aur         unknown — probe failed: AUR RPC returned 503
StateMeaningBlocks?
absentNot upstream yet; the publisher will publishno
completeThis exact version and content is already upstream (live, in moderation, or an open PR); the publisher skipsno
divergedThe version is upstream but the local artifact bytes differyes, if the publisher is required
unknownThe probe was inconclusive (network error, unparseable feed)no

The sweep asks the real hosts: the PR-mode publishers (nix, homebrew, homebrew-core, krew, scoop, winget) search the upstream index repository's pull requests on the GitHub API, cargo reads the crates.io sparse index, npm and PyPI their registries, chocolatey its feed; a call that fails reads as unknown on that row and never fails an otherwise clean report.

complete is deliberately not an error: it is the approval a resumed release wants. unknown is deliberately not an error either — an unreachable registry must not veto a release, and the registry's own conflict handling is the backstop. A diverged optional publisher is reported as a warning, because the release itself tolerates it too — the standalone command is never stricter than the pipeline it guards. A required diverged aborts the run and asks for a version bump: the version is already published with different content.

Standalone command

The same engine is exposed as a command — the pre-tag CI job, or a local "can this machine cut the release?" check:

$ anodizer preflight                    # the whole engine, full pipeline scope
$ anodizer preflight --publish-only     # only what `release --publish-only` runs
$ anodizer preflight --json             # machine-readable report
$ anodizer preflight --skip=docker,blob # same stage names as release --skip

Which version is probed

The publisher check and the reconcile sweep are only meaningful against the version this tree would release, so the command derives it the way anodizer tag does:

TreeVersion the probes use
ANODIZER_CURRENT_TAG (or its GORELEASER_CURRENT_TAG alias, or a tag-push GITHUB_REF_NAME) names a tagthat tag, wherever it sits relative to HEAD — the operator named the target
HEAD carries the configured tagthat tag — the resume / backfill / --publish-only case, where a required diverged must still gate
commits since the last tag carry a release signal (#major / #minor / #patch, a conventional feat: / fix:, …)the version anodizer tag would cut next
commits since the last tag carry no release signalthe current version; the reconcile sweep is skipped (below)

Under -v the derivation is printed:

$ anodizer preflight -v
   • HEAD is not tagged; publisher probes use the planned version 0.27.1 (v0.27.0 → v0.27.1)

The derivation needs the tag history, so a CI checkout that runs it passes fetch-depth: 0. A shallow checkout whose history ends before the last tag cannot plan: the plan would start from no tag and the probes would ask the registries about the version the LAST release published, which reads as diverged on every crate the tree has changed since. Such a checkout is refused before any probe runs. A shallow clone deep enough to still hold the last tag plans the same bump a full clone plans, and a shallow clone of a repository whose remote holds no tag of a family this tree releases in has no last release to reach and plans the first version. The remote asked is origin; a checkout without one is asked of every remote it has. When the remote's tags cannot be listed, or there is no remote to ask, the checkout is refused rather than guessed at:

$ anodizer preflight
Error preflight: this checkout is shallow and HEAD's history does not reach the last tag, so the version this tree would release cannot be planned; fetch the whole history (actions/checkout fetch-depth: 0, or git fetch --unshallow) and re-run

When the reconcile sweep is skipped

With no release signal since the last tag the resolved version is the last released one, and every probe would describe a version nobody is about to publish. anodizer locates that tag relative to HEAD with a local git query and skips the whole sweep:

   • Reconcile state
   •   skipped — v0.22.2 is already released and HEAD has advanced past it; this tree will cut a new version
Tag for the resolved versionBehaviour
declared by an override, at any positionprobe — the operator named the target version
does not existprobe — a fresh version (including the planned one), nothing can be upstream yet
exists, points at HEADprobe — the resume / backfill / --publish-only case
exists, behind HEADskip — HEAD has advanced past it and nothing plans a new version
exists, off HEAD's history (older checkout, divergent branch)skip — this tree will not publish that version

The skip is an inference about a tag anodizer picked for you, so it never applies to one you named — a backfill run from a tree checked out well past the version it is publishing probes exactly that version:

# Probes v0.20.0 even though HEAD is three releases ahead of it.
$ ANODIZER_CURRENT_TAG=v0.20.0 anodizer preflight --publish-only

Exit codes

ConditionExit
Everything present, no blocker, no divergence0
Any environment requirement missingnon-zero
A publisher blocker (unusable sole credential; missing rollback scope under --strict)non-zero
A required publisher divergednon-zero
Publisher warnings only; an optional publisher diverged0
Publishers complete / unknown only0

Contract change. anodizer preflight previously exited non-zero when a publisher was in moderation or had a manifest PR open. It no longer does: those are complete, the expected state of a resumed release, and treating them as failures is what wedged partially-failed releases. CI scripts that read "non-zero == do not publish" now only trip on a genuine content divergence, a blocker, or a missing credential. To act on the old signal, read the --json reconcile[].state field instead of the exit code.

JSON report

--json carries all three halves: the environment keys at the top level (with a kind per failure — missing_tool, missing_env, endpoint_unreachable, docker_unavailable, bad_key_material), a publishers object with the publisher check's entries, warnings and blockers (null when the publisher half was skipped), and a reconcile array with one object per publisher:

{
  "checks": 24,
  "failures": [],
  "publishers": {
    "entries": [
      { "publisher": "cargo", "package": "mycrate", "version": "1.2.3", "state": "clean" },
      { "publisher": "chocolatey", "package": "mycrate", "version": "1.2.3", "state": { "in-moderation": { "reason": "package in moderation queue" } } }
    ],
    "warnings": [],
    "blockers": []
  },
  "reconcile": [
    { "publisher": "cargo", "state": "complete", "detail": "1.2.3 live with matching cksum", "blocking": false },
    { "publisher": "npm", "state": "absent", "blocking": false }
  ]
}

A skipped sweep projects to one marker row, so "this question did not apply" can never be read as "no publisher is configured". Its publisher is the whole-set wildcard *:

{
  "reconcile": [
    {
      "publisher": "*",
      "state": "skipped",
      "detail": "v0.22.2 is already released and HEAD has advanced past it; this tree will cut a new version",
      "blocking": false
    }
  ]
}

The CI pattern

A pipeline that tags automatically runs the engine once, before the tag exists, as the root job every other job depends on, and every job that runs anodizer release afterwards passes --skip=preflight. A missing or truncated secret, an unreachable endpoint, or a version a registry already holds then aborts the run with nothing tagged and nothing published, and the release jobs never spend a second network round on a question that is already answered.

Run the job on the runner that will publish, so the endpoints it probes are the ones the publish reaches and ambient credentials (a self-hosted runner's cloud keys, for instance) are checked in the same pass. Where a stage's credential is genuinely absent on the preflight runner, --skip that stage there and let the publish job carry it:

jobs:
  preflight:
    runs-on: arc-anodizer          # the runner the release job will use
    permissions:
      contents: read
      id-token: write              # so the OIDC request vars are present to check
    steps:
      - uses: actions/checkout@v6
        with:
          fetch-depth: 0           # the planned version is derived from the tag history
      - uses: tj-smith47/anodizer-action@v1
        with:
          auto-install: true
          args: preflight
        env:
          GITHUB_TOKEN: ${{ secrets.GH_PAT }}
          COSIGN_KEY: ${{ secrets.COSIGN_KEY }}
          # …every secret the release job consumes, so the two env blocks match…

  tag:
    needs: [preflight]
    if: needs.preflight.result == 'success'
    # …auto-tag only once the gate passes…

  release:
    needs: [tag]
    runs-on: arc-anodizer
    steps:
      - uses: tj-smith47/anodizer-action@v1
        with:
          args: release --publish-only --skip=preflight,npm,pypi,cargo
          # the pre-tag job already ran the engine on this tree

The job runs on an untagged tree by design: the probes use the version the tag job is about to cut. anodizer's own pipeline is the worked example, in The Release Pipeline.

What gets derived

Stage or publisherDerived requirements
buildscargo
nfpms / srpmsnfpm / rpmbuild + signing key material from signature: blocks
snapcraftssnapcraft, unsquashfs; SNAPCRAFT_STORE_CREDENTIALS when publish: true
signs / binary_signs / docker_signsthe signing cmd, env refs in args/stdin, env://VAR cosign keys validated as key material
sboms / makeselfs / appimages / upxsyft (or custom cmd), makeself, linuxdeploy, upx
dockers_v2docker + reachable daemon
blobsrendered S3 endpoint reachability, static keypair for custom endpoints, KMS CLIs
verify_release.install_smokedocker + reachable daemon
msiswix (v4) or candle + light (v3) — same explicit-version: > .wxs-namespace-sniff > installed-tool-probe policy the build uses; only when a Windows target is configured
nsismakensis; only when a Windows target is configured
pkgspkgbuild; only when a macOS target is configured
dmgsany of hdiutil / genisoimage / mkisofs (the stage's own detection ladder); only when a macOS target is configured
flatpaksflatpak-builder + flatpak; only when a Linux target is configured
app_bundlesnothing — the stage assembles the .app layout with pure file operations
notarizercodesign + env refs in certificate: / password: / API-key fields (cross-platform), codesign + xcrun + env refs in identity: / keychain: / profile_name: (native)
announceper-announcer secrets exactly as the senders read them — e.g. SLACK_WEBHOOK (or env refs in a templated webhook_url:), TELEGRAM_TOKEN, DISCORD_WEBHOOK_ID+DISCORD_WEBHOOK_TOKEN, full Twitter/Reddit/Mastodon credential sets, and SMTP_HOST / SMTP_USERNAME / SMTP_PASSWORD for email (password only when encryption is enabled)
publishersper-publisher token ladders (e.g. HOMEBREW_TAP_TOKEN → GITHUB_TOKEN), per-entry secret env names, AUR SSH keys

Entries disabled via skip: / skip_upload: / a falsy if: contribute nothing, and in per-crate workspace mode the requirements are the union across every publishable crate — one preflight covers the whole run.

The per-platform bundler stages (msis, nsis, pkgs, dmgs, flatpaks) contribute requirements only when the configured build targets include their platform — mirroring each stage's own run gate, so a Linux-only matrix never demands WiX. Announce requirements are checked in both the full and --publish-only scopes (the publish-only pipeline runs announce), and --announce-only checks them alone — the only stage that mode runs.