Skip to content

Build-matched agent image, launch-time version check, and one gated, verifiable release flow - #129

Merged
magnificentlycursed merged 15 commits into
developfrom
feat/version-matched-agent-image
Oct 9, 2026
Merged

magnificentlycursed merged 15 commits into
developfrom
feat/version-matched-agent-image

Conversation

@magnificentlycursed

@magnificentlycursed magnificentlycursed commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

Closes #125, #122, #123, #124, #127 and #128. Delivers the image and release half of #126; its crates.io ordering waits on #121, and its repository settings are a separate decision.

Container agents now run the crosslink their CLI was built for, and a release publishes its image, binaries and GitHub release in one gated, verifiable order. This grew out of the publishing research and was revised through three rounds of adversarial review (Platform Engineer, Solution Architect, Software and Quality Engineer, Security). Round 1 raised 34 findings, round 2 two blockers (both fixed: a truncated changelog and an arm64 version mismatch), round 3 none; every round's findings were fixed or recorded as deferred (tracker #815) or as decisions (crates.io, repository rulesets).

Default image matches the CLI build (#125)

Container commands defaulted to crosslink-agent:latest. That tag does not exist under Corvidae, moved with every tag including prereleases, and was never refreshed once cached, so the crosslink in the container could differ from the CLI that launched it. Both write the same hub.

  • Release builds. A build defaults to :<version> only on positive evidence:
    • a published package (cargo install crosslink);
    • a checkout at its v<version> tag;
    • an explicit CROSSLINK_AGENT_IMAGE_TAG.
  • Everything else. Every other build defaults to :nightly.
  • Bad overrides fail the build. An invalid override, latest, or local fails the build instead of silently falling back.
  • Forks. CROSSLINK_AGENT_IMAGE_REPOSITORY lets a fork's builds default to its own registry.
  • Shared, tested rules. The rules live in crosslink/src/build_support/agent_image.rs, used by build.rs and table-tested.
  • Rebuild loop fixed. The build script watches only git paths that exist, which also fixes a rebuild-every-time loop in shallow checkouts and worktrees.
  • One resolver. Kickoff, container start, container auth, swarm and sentinel all resolve the image the same way, --image then CROSSLINK_CONTAINER_IMAGE then the build default, and report which one they used.
  • Floating tags refresh. :nightly and :latest are refreshed before every launch. If the registry cannot be reached or does not answer and a local copy exists, the launch warns and uses it; if the registry refuses the image (withdrawn, private), it stops.

Version check at launch (#127)

The old staleness check never fired. Images now carry org.opencontainers.image.version, the exact crosslink --version of their binary, set by CI and by container build. At launch, kickoff and container start compare it with the CLI's version:

  • Different version: a warning.
  • Same version, different commit: noted at info level, since that's normal for development builds.

One gated release flow (#126, #122, #123)

A v* tag runs container-image.yml end to end:

  1. Gate. The tag must match Cargo.toml and must not already be published. Versions are never republished. Manual runs publish only from develop, and can rehearse the promotion against :promotion-rehearsal.

  2. Image. Only :<version> is pushed, with version, revision and source labels and a build attestation.

  3. Smoke test. Runs without logging in. It checks that every pushed tag resolves to the built digest, pulls that digest on both architectures, and checks the version label against the binary.

  4. :latest. Moves to the verified digest only for the highest stable release, one promotion at a time, checked before and after.

  5. GitHub release. release-builds.yml, now a reusable workflow, builds:

    • Linux x86_64 and aarch64;
    • macOS aarch64 and x86_64;
    • Windows x86_64;
    • the Codex plugin archives.

    The release is created with these, plus SHA256SUMS and attestations, only after the image passed.

Only the publishing jobs hold write permissions, and their actions are pinned by commit. A daily canary checks anonymously:

  • :nightly;
  • every release's :<version>, and :latest;
  • that the frozen ghcr.io/forecast-bio/crosslink-agent:latest, which 0.9.0-beta.1 builds use, still names the same digest.

Reproducible image (#128)

The Dockerfile now pins:

  • the base image, by digest;
  • gh, Codex, Claude Code, uv and gosu, by version;
  • gosu, also by checksum.

Two builds of the same commit contain the same tools. Released images are never rebuilt; newer tools ship in a patch release.

Runbook (#124)

RELEASING.md covers:

  • what a tag publishes, and in which order;
  • what to check, and how to roll back;
  • the guarantee each image tag gives;
  • updating pinned tools;
  • the repository settings releases depend on;
  • a checklist for moving the repository or owner.

The git-flow knowledge page was corrected to match.

Verification

  • Lint. cargo fmt --check and cargo clippy -- -D warnings -W clippy::unwrap_used -W clippy::expect_used are clean, and actionlint (with shellcheck) is clean on all three workflows.
  • Tests. Targeted tests pass: the image rules table, the resolver, the floating-tag check, the offline refresh fallback, the version comparison and label check, and the launch hints.
  • Builds.
    • A development build defaults to :nightly, and an explicit override changes it.
    • The packaged crate, built outside git, defaults to its version.
    • An invalid override (latest) fails the build, and a fork repository override takes effect.
  • Live, against Docker Desktop and GHCR.
    • The pinned Dockerfile builds and installs the same tool versions as today's :nightly.
    • container auth status refreshes :nightly and honours CROSSLINK_CONTAINER_IMAGE.
    • The canary passes on the current references and fails on a changed digest.
  • Simulated events. The gate script was run against simulated pull-request, tag, develop, dispatch and rehearsal events:
    • a mismatched tag, a dispatch from a tag or a feature branch, and an already-published version are all refused;
    • the smoke test's digest comparison passes on the right digest and fails on a wrong one.
  • Not yet exercised: the tag path. Promotion, the GitHub release and attestations only run on a tag. A manual run on develop with rehearse_promotion exercises the gate, smoke test and promotion first; RELEASING.md describes it.

Not in this PR

🤖 Generated with Claude Code

…k build

Container commands defaulted to `crosslink-agent:latest`. Under Corvidae that
tag does not exist (no release has been tagged since the move), and where it
does exist it moves on every tag, prereleases included, while a cached copy is
never refreshed, so the crosslink inside the container could lag behind or run
ahead of the CLI that launched it. The two write to the same hub.

The build script now chooses the default tag: an explicit
CROSSLINK_AGENT_IMAGE_TAG at build time wins; a build from crosslink's own
checkout uses `<version>` when HEAD is exactly `v<version>` with a clean tree
and `nightly` otherwise; any other build, such as a crates.io source install,
uses `<version>`. One constant carries it to every call site: kickoff's
`--image` default, `container start`, the container auth fallback, swarm and
sentinel. `container start` gains `--image` and honours
CROSSLINK_CONTAINER_IMAGE like the auth commands. Floating tags (`nightly`,
`latest`) are launched with `--pull=always`; pinned tags and digests are not.
The pull-failure hint explains the build-matched tag when the default image
is missing.

The container guide describes the default and no longer calls registry tags
immutable; only a digest pin is.
…atest only for verified stable releases

A `v*` tag pushed both `:<version>` and `:latest` before the smoke test ran,
moved `:latest` for prereleases, and the smoke test logged in before
pulling, so neither a private package nor a missing tag could fail it.

container-image.yml now:
- checks the tag against crosslink/Cargo.toml before anything is pushed;
- pushes only `:<version>` on a tag push;
- runs the smoke test without logging in, first checking that every pushed
  tag resolves anonymously;
- promotes `:latest` afterwards in a separate job, by the verified digest,
  and only for non-prerelease versions (re-pointing it to an older digest is
  the rollback);
- refuses manual publishes from anything but develop, so a dispatch can no
  longer overwrite `:nightly` from a feature branch or rebuild an old tag.

Release builds of the binaries (release-builds.yml and the image's own
binaries) set CROSSLINK_AGENT_IMAGE_TAG to the tag's version, passed through
cross, so their default image is the release image even from a shallow
checkout without tags.

A new daily image-canary workflow checks, anonymously, that `:nightly`, the
newest release's `:<version>` (and `:latest` when it is stable), and the
frozen ghcr.io/forecast-bio/crosslink-agent:latest that 0.9.0-beta.1 builds
default to can still be pulled.

crates.io publishing is unchanged here; its ownership and credential are
tracked separately.
Cargo treats a rerun-if-changed path that does not exist as changed, so on
a checkout without packed-refs (the shallow checkouts CI uses) the build
script reran and the binary was rebuilt on every cargo invocation. On
Windows the readiness tests then failed: the second test group's rebuild
could not replace crosslink.exe while a daemon from the first group still
had it open ("Access is denied").
… fail on bad overrides, and keep offline launches working

From the review of this PR:

- A build counts as a release, and defaults to `:<version>`, only on
  positive evidence: a published package (`.cargo_vcs_info.json`), a
  checkout of crosslink at its `v<version>` tag, or an explicit
  CROSSLINK_AGENT_IMAGE_TAG. A build with no git information is no longer
  guessed to be a release; it defaults to `:nightly`. The clean-tree
  condition is dropped: watching the index to keep it current would rebuild
  on git's own index refreshes.
- An explicit CROSSLINK_AGENT_IMAGE_TAG must match the OCI tag grammar and
  must not be `latest` or `local`; otherwise the build fails instead of
  falling back silently. CROSSLINK_AGENT_IMAGE_REPOSITORY lets fork builds
  default to their own registry, validated the same way.
- The build script watches the HEAD, refs and packed-refs that git reports
  for the checkout (so linked worktrees work), and only paths that exist.
- The rules live in src/build_support/agent_image.rs, included by build.rs
  and table-tested from the kickoff tests.
- `--pull=always` made every launch of a floating tag fail without network
  access, even with the image cached. Floating tags are now refreshed with an
  explicit pull first; if that fails and a local copy exists, the launch
  warns and uses it, and otherwise the error carries the runtime's reason.
- The container guide states what development builds actually run, that
  `:nightly` follows develop, and that `cargo install --git` needs `--image`.
…iner command

CROSSLINK_CONTAINER_IMAGE was honoured only by `container start` and
`container auth`; kickoff took its image from a clap default, and swarm and
sentinel used the build default with no override at all, so the same
environment chose different images depending on the command.

One resolver now serves all of them: `--image`, then
CROSSLINK_CONTAINER_IMAGE, then this build's default. Blank values count as
unset and image names starting with `-` are refused. Kickoff (when it
launches a container) and `container start` print the image and where it
came from. Together with the build-time CROSSLINK_AGENT_IMAGE_REPOSITORY
override, a fork's builds can default to the fork's own registry.
… at launch

The only skew check, check_staleness, read a binary-hash label that only a
local `container build` set, hashed a host binary against the image's Linux
binary (never equal across platforms), and inspected the default image even
when another was chosen; it never fired for published images.

Images now carry org.opencontainers.image.version with the exact
`crosslink --version` of their binary (`container build` sets it from the
packaged binary; CI sets it in the release workflow). After refreshing the
image, kickoff and `container start` compare that label with this CLI's
version: a different version warns, because the container and the CLI write
the same hub; the same version from a different commit (a development build
against :nightly) is reported at info level; images without the label are
skipped.
…test promotion, pinned image

From the review of this PR, plus the release-binary and reproducibility
items folded into it:

- A gate job decides what a run may publish before anything is built: a v*
  tag must match crosslink/Cargo.toml (read with tomllib, like cargo) and
  must not already be published, since pinned tags are cached and a second
  image under one tag would split users; manual runs publish only from
  develop and can rehearse promotion against :promotion-rehearsal; the
  unreachable manual-<sha> branch is gone.
- Binaries bake the gate's agent image tag explicitly (the version on a tag,
  nightly otherwise) instead of inferring it.
- Only the publishing jobs hold packages/contents write, and their actions
  are pinned by commit. Images carry org.opencontainers.image.version (the
  binary's exact version), revision and source labels, and a GitHub build
  attestation.
- The smoke test pulls the built digest without logging in, checks that
  every pushed tag resolves anonymously to that digest, and checks the
  version label against the binary.
- promote-latest moves :latest only to the verified digest, only when the
  version is the highest stable v* tag (backports and reruns never move it
  backwards), one run at a time, verifying the source before and the tag
  after. Release runs are never cancelled part-way.
- release-builds.yml becomes a reusable workflow called on release tags (it
  keeps its pull-request check), adds Linux aarch64 and macOS aarch64
  builds, and the release job creates the GitHub release with the binaries,
  plugin archives, SHA256SUMS and build attestations only after the image
  passed its smoke test.
- The canary checks every release since the build-matched default with
  prereleases ordered below their release, and pins the frozen forecast-bio
  image to its digest so a silent repoint fails.
- The Dockerfile pins the base image by digest and gh, Codex, Claude Code,
  uv and gosu by version, and verifies gosu by checksum.

crates.io publishing (publish.yml) is unchanged and still independent of
this flow until its ownership and credential are settled.
…ository-move checklist

There was no release runbook (tracker #170 planned one and the 0.5.2
changelog says it exists; it did not), and each repository move dropped part
of publishing. RELEASING.md describes what a v* tag publishes and in which
order, what to check, how to roll back, the guarantees of each image tag,
updating pinned image tools, the repository settings releases depend on, and
what to carry over when the repository or owner moves.
@magnificentlycursed magnificentlycursed changed the title Default to the agent image matching the CLI build, and gate release image publishing Build-matched agent image, launch-time version check, and one gated, verifiable release flow Oct 8, 2026
Rewriting this PR's [Unreleased] release-flow entry in 4b9f988 kept only
the text before it, dropping every release section from 0.9.0-beta.1 back
(1,089 lines down to 43). The file is now the base file with this PR's
additions inserted under [Unreleased] > Added, plus an entry for the
launch-time version check that was missing.
…bel, and a network-only offline fallback

From the second review round of this PR:

- build.rs honours an explicit CROSSLINK_VERSION (it must start with the
  crate version), so CI can give every architecture the same version
  string; the arm64 binary built with cross had none, because the cross
  container cannot see the repository's git metadata.
- The version label is dev.crosslink.version. org.opencontainers.image.version
  is inherited from the Ubuntu base ("24.04"), so unlabelled images would
  have raised false version warnings.
- A failed pull falls back to a local copy only when the network failed and
  only for floating tags. A registry that answers "manifest unknown",
  "denied" or "unauthorized" has withdrawn or hidden the image, and a cached
  copy no longer outlives that.
- A pinned tag or digest missing locally is pulled explicitly before launch,
  so its failures carry the same hints and the version check runs on its
  first launch too.
- The image is resolved only for container launches; a local launch (and
  every swarm and sentinel launch) no longer reads the image settings.
- Tests: transport-versus-refusal classification, the full preparation
  matrix with a fake runtime, the exact inspect arguments of the version
  check; the namespace test that the always-set repository variable made
  tautological is removed.
…out the shared cache, and verify what binaries default to

From the second review round of this PR:

- The gate requires the tagged commit to be on main and proceeds only when
  the registry answers "not found" for :<version>; a missing token or any
  other answer stops the release instead of republishing.
- The gate sets one version string (crate version + commit) that every
  binary is built with, passed through cross, and the image's
  dev.crosslink.version label comes from it; the arm64 binary built with
  cross previously reported no commit, which would have failed its smoke
  leg after the release image was already pushed.
- Release images build with no cache; the registry cache is written by
  develop pushes and is no longer trusted for releases.
- The smoke test checks, on both architectures, that the binary defaults to
  this image and that the bundled tools (Claude Code, Codex, gh, uv) run;
  release-builds checks each binary's default image and version.
- Builds also set the image repository from the publishing owner, so a
  fork's binaries default to the fork's image.
- Rehearsal dispatches get their own concurrency group, so a develop push
  no longer cancels them.
- The Dockerfile builds with pipefail, so a failed installer download fails
  the build, and says which inputs still float.
- `just build-image` labels its image with the version it builds.
- CI runs actionlint (with shellcheck) on every workflow.
- RELEASING.md: the gate's new conditions, deleting a failed stray tag,
  re-running only failed jobs, what "never republished" covers, and that the
  intended rulesets are not configured yet.
A job name built from needs outputs is shown unexpanded when the job is
skipped, as it is on every pull request.
On Linux a test thread forking a process while another test's fake runtime
script is open for writing keeps a write handle in that child until it
execs, and executing the script meanwhile fails with ETXTBSY ("Failed to
run …/c pull" on CI). The fake runtimes now probe until the script can be
spawned before a test uses them.
…build version check

From the third review round of this PR:

- Pull failures are classified by whether the registry refused the image
  (manifest unknown, denied, unauthorized, not found), matched with the
  image reference removed. Only a refusal stops a launch that has a local
  copy of a floating tag. Network errors, registry outages (5xx), rate
  limits and unrecognised failures fall back to the local copy with a
  warning, and the advice names which case it is. Round 2's list of
  network phrases missed "context deadline exceeded", "no route to host"
  and server errors, so cached :nightly launches failed with wrong advice.
- An explicit CROSSLINK_VERSION must be the crate version optionally
  followed by +<build>; a mere prefix match accepted 0.9.01 for 0.9.0.
- `--image` without a container now warns that it is ignored.
- Tests: refusal classification including words inside the image
  reference, the resolver's precedence, sources and refusals, and that a
  local launch never reads the image settings.
…d before the push, rehearsals leave :nightly alone

From the third review round of this PR:

- A re-run of the publish job (Re-run failed jobs, the runbook's recovery)
  no longer rebuilds and overwrites :<version>: when the version is already
  published on a re-run attempt, the job reuses its digest for the smoke
  test, promotion and attestation; on a first attempt an existing version
  stops the job.
- Before anything is pushed, both the amd64 and the arm64 binaries are
  checked for the gate's version string and for defaulting to this image,
  so an arm64 mismatch can no longer burn a release version after the push.
- A rehearsal dispatch pushes only :nightly-<sha> and writes no cache, so it
  cannot race a develop push or move :nightly backwards.
- Release runs skip the cargo cache as well as the image cache.
- The tool check runs under pipefail, so a missing gh fails it.
- release-builds passes its inputs to the check through env.
- RELEASING.md says plainly that, until rulesets exist, the release checks
  guard against accidents only (they run from the tagged commit's own
  workflow), and describes the re-run behaviour and artifact retention.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant