diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4cc26bf57..05e02a4a5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -24,6 +24,14 @@ jobs:
+ workflow-lint:
+ name: Workflow lint
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: actionlint (includes shellcheck of run steps)
+ run: docker run --rm -v "$PWD":/repo -w /repo rhysd/actionlint:1.7.12@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 -color
+
lint:
name: Lint
runs-on: ubuntu-latest
diff --git a/.github/workflows/container-image.yml b/.github/workflows/container-image.yml
index 2e827759d..dd3bebf41 100644
--- a/.github/workflows/container-image.yml
+++ b/.github/workflows/container-image.yml
@@ -1,21 +1,17 @@
name: Container Image
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+# Builds the agent image and, for releases, the whole release:
+#
+# gate -> build-binary -> publish-image -> smoke-verify -> promote-latest
+# \-> publish-release
+# \-> release-binaries (release tags) -------------/
+#
+# A v* tag must match crosslink/Cargo.toml and must not already be published.
+# It pushes only :; the anonymous smoke test runs that exact digest;
+# :latest then moves to the verified digest, serialized across runs and only
+# for the highest stable release; the GitHub release with binaries is created
+# only after the image passed. Pushes to develop publish :nightly. crates.io
+# publishing (publish.yml) is not part of this flow yet.
on:
push:
@@ -25,51 +21,169 @@ on:
paths:
- 'crosslink/resources/container/**'
- '.github/workflows/container-image.yml'
+ - '.github/workflows/release-builds.yml'
- 'crosslink/Cargo.toml'
- 'crosslink/Cargo.lock'
-
-
-
+ - 'crosslink/Cross.toml'
+ - 'crosslink/build.rs'
workflow_dispatch:
+ inputs:
+ rehearse_promotion:
+ description: >-
+ After the smoke test, promote this develop build to :promotion-rehearsal
+ (exercises the promote-latest path without touching :latest)
+ type: boolean
+ default: false
permissions:
contents: read
- packages: write
concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
+ group: ${{ github.workflow }}-${{ github.ref }}${{ github.event.inputs.rehearse_promotion == 'true' && '-rehearsal' || '' }}
+ # Never cancel a release run part-way through publishing.
+ cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
env:
REGISTRY: ghcr.io
-
-
IMAGE_NAME: crosslink-agent
jobs:
-
-
+ gate:
+ name: Release gate
+ runs-on: ubuntu-latest
+ outputs:
+ push: ${{ steps.plan.outputs.push }}
+ tags: ${{ steps.plan.outputs.tags }}
+ primary_tag: ${{ steps.plan.outputs.primary_tag }}
+ base: ${{ steps.plan.outputs.base }}
+ version: ${{ steps.plan.outputs.version }}
+ release: ${{ steps.plan.outputs.release }}
+ prerelease: ${{ steps.plan.outputs.prerelease }}
+ rehearsal: ${{ steps.plan.outputs.rehearsal }}
+ agent_image_tag: ${{ steps.plan.outputs.agent_image_tag }}
+ crosslink_version: ${{ steps.plan.outputs.crosslink_version }}
+ cache_ref: ${{ steps.plan.outputs.cache_ref }}
+ write_cache: ${{ steps.plan.outputs.write_cache }}
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ with:
+ fetch-depth: 0
+ - name: Decide what this run may publish
+ id: plan
+ env:
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ REHEARSE: ${{ github.event.inputs.rehearse_promotion }}
+ run: |
+ set -euo pipefail
+ OWNER="$(printf '%s' "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')"
+ BASE="${REGISTRY}/${OWNER}/${IMAGE_NAME}"
+ REPO_PATH="${OWNER}/${IMAGE_NAME}"
+ SHORT_SHA="${GITHUB_SHA::7}"
+ CARGO_VERSION="$(python3 -c 'import tomllib; print(tomllib.load(open("crosslink/Cargo.toml", "rb"))["package"]["version"])')"
+ PUSH=false RELEASE=false PRERELEASE=false REHEARSAL=false WRITE_CACHE=false
+ AGENT_IMAGE_TAG=nightly
+ case "${GITHUB_EVENT_NAME}:${GITHUB_REF}" in
+ pull_request:*)
+ VERSION="pr-${PR_NUMBER}"
+ TAGS=("${BASE}:pr-${PR_NUMBER}")
+ ;;
+ push:refs/tags/v*)
+ VERSION="${GITHUB_REF#refs/tags/v}"
+ if [ "$VERSION" != "$CARGO_VERSION" ]; then
+ echo "::error::Tag v${VERSION} does not match crosslink/Cargo.toml version ${CARGO_VERSION}; nothing was published"
+ exit 1
+ fi
+ # Releases come from main: a tag on any other commit publishes nothing.
+ if ! git merge-base --is-ancestor "${GITHUB_SHA}" origin/main; then
+ echo "::error::v${VERSION} points at ${GITHUB_SHA}, which is not on main; releases are tagged on main"
+ exit 1
+ fi
+ # A published version is never replaced: pinned tags are pulled
+ # once and cached, so a second image under the same tag would
+ # split users across two builds. Only an explicit "not found"
+ # lets the release proceed; anything else fails closed.
+ token="$(curl -sS "https://${REGISTRY}/token?scope=repository:${REPO_PATH}:pull" | jq -r '.token // empty' || true)"
+ if [ -z "$token" ]; then
+ echo "::error::cannot confirm ${BASE}:${VERSION} is unpublished: no anonymous registry token (package private, missing, or registry unavailable)"
+ exit 1
+ fi
+ code="$(curl -sS -o /dev/null -w '%{http_code}' -I \
+ -H "Authorization: Bearer ${token}" \
+ -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json' \
+ "https://${REGISTRY}/v2/${REPO_PATH}/manifests/${VERSION}" || true)"
+ case "$code" in
+ 404) ;;
+ 200)
+ echo "::error::${BASE}:${VERSION} is already published; versions are never republished. Release a new version instead."
+ exit 1
+ ;;
+ *)
+ echo "::error::cannot confirm ${BASE}:${VERSION} is unpublished (registry answered HTTP ${code:-none}); retry when the registry is healthy"
+ exit 1
+ ;;
+ esac
+ TAGS=("${BASE}:${VERSION}")
+ PUSH=true RELEASE=true AGENT_IMAGE_TAG="${VERSION}"
+ case "$VERSION" in *-*) PRERELEASE=true ;; esac
+ ;;
+ push:refs/heads/develop|workflow_dispatch:refs/heads/develop)
+ VERSION="nightly-${SHORT_SHA}"
+ PUSH=true
+ if [ "${GITHUB_EVENT_NAME}" = workflow_dispatch ] && [ "${REHEARSE:-false}" = true ]; then
+ # A rehearsal never moves :nightly or writes the shared cache,
+ # so it cannot race a develop push.
+ REHEARSAL=true
+ TAGS=("${BASE}:nightly-${SHORT_SHA}")
+ else
+ WRITE_CACHE=true
+ TAGS=("${BASE}:nightly" "${BASE}:nightly-${SHORT_SHA}")
+ fi
+ ;;
+ workflow_dispatch:*)
+ echo "::error::Manual image publishes run only from develop (got ${GITHUB_REF}); releases publish from their v* tag push"
+ exit 1
+ ;;
+ *)
+ echo "::error::Unexpected event/ref: ${GITHUB_EVENT_NAME} ${GITHUB_REF}"
+ exit 1
+ ;;
+ esac
+ {
+ echo "push=${PUSH}"
+ echo "tags<> "$GITHUB_OUTPUT"
+ echo "Plan: push=${PUSH} release=${RELEASE} prerelease=${PRERELEASE} rehearsal=${REHEARSAL} tags=${TAGS[*]}"
build-binary:
name: Build binary (${{ matrix.arch }})
+ needs: gate
runs-on: ubuntu-latest
+ env:
+ # The default agent image the binaries in this image will use: the
+ # release version on a tag, nightly otherwise. Set explicitly, never inferred.
+ CROSSLINK_AGENT_IMAGE_TAG: ${{ needs.gate.outputs.agent_image_tag }}
+ CROSSLINK_AGENT_IMAGE_REPOSITORY: ${{ needs.gate.outputs.base }}
+ # One version string for every architecture (cross cannot see .git).
+ CROSSLINK_VERSION: ${{ needs.gate.outputs.crosslink_version }}
strategy:
fail-fast: true
matrix:
include:
-
-
- arch: amd64
target: x86_64-unknown-linux-musl
use_cross: false
-
-
-
-
-
-
-
-
- arch: arm64
target: aarch64-unknown-linux-musl
use_cross: true
@@ -97,6 +211,7 @@ jobs:
tool: cross
- name: Cache cargo
+ if: needs.gate.outputs.release != 'true'
uses: actions/cache@v4
with:
path: |
@@ -123,28 +238,29 @@ jobs:
if-no-files-found: error
retention-days: 1
-
-
-
publish-image:
name: Build & publish image
- needs: build-binary
+ needs: [gate, build-binary]
runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+ id-token: write
+ attestations: write
outputs:
- primary_tag: ${{ steps.tags.outputs.primary_tag }}
- version: ${{ steps.tags.outputs.version }}
- pushed: ${{ steps.tags.outputs.pushed }}
+ digest: ${{ steps.build.outputs.digest || steps.existing.outputs.digest }}
+ crosslink_version: ${{ steps.label.outputs.crosslink_version }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Download amd64 binary
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: crosslink-amd64
path: artifacts/amd64
- name: Download arm64 binary
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: crosslink-arm64
path: artifacts/arm64
@@ -155,92 +271,109 @@ jobs:
cp artifacts/arm64/crosslink crosslink/resources/container/crosslink-arm64
chmod +x crosslink/resources/container/crosslink-amd64
chmod +x crosslink/resources/container/crosslink-arm64
- ls -lah crosslink/resources/container/
- - name: Compute tags
- id: tags
+ - name: Check both binaries before anything is pushed
+ id: label
+ env:
+ EXPECTED: ${{ needs.gate.outputs.crosslink_version }}
+ DEFAULT_IMAGE: ${{ needs.gate.outputs.base }}:${{ needs.gate.outputs.agent_image_tag }}
run: |
set -euo pipefail
- REF="${GITHUB_REF}"
- EVENT="${GITHUB_EVENT_NAME}"
- SHORT_SHA="$(echo "${GITHUB_SHA}" | cut -c1-7)"
- OWNER="$(printf '%s' "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')"
- BASE="${REGISTRY}/${OWNER}/${IMAGE_NAME}"
- TAGS=()
- PUSH=false
- PRIMARY=""
- VERSION=""
-
- if [ "$EVENT" = "pull_request" ]; then
-
- TAGS=("${BASE}:pr-${{ github.event.pull_request.number }}")
- PRIMARY="${BASE}:pr-${{ github.event.pull_request.number }}"
- VERSION="pr-${{ github.event.pull_request.number }}"
- elif [[ "$REF" == refs/tags/v* ]]; then
- VERSION="${REF#refs/tags/v}"
- TAGS=("${BASE}:${VERSION}" "${BASE}:latest")
- PRIMARY="${BASE}:${VERSION}"
- PUSH=true
- elif [ "$REF" = "refs/heads/develop" ]; then
- VERSION="nightly-${SHORT_SHA}"
- TAGS=("${BASE}:nightly" "${BASE}:nightly-${SHORT_SHA}")
- PRIMARY="${BASE}:nightly"
- PUSH=true
- elif [ "$EVENT" = "workflow_dispatch" ]; then
- VERSION="manual-${SHORT_SHA}"
- TAGS=("${BASE}:nightly" "${BASE}:manual-${SHORT_SHA}")
- PRIMARY="${BASE}:nightly"
- PUSH=true
- else
- echo "::error::Unexpected ref/event combination: ref=${REF} event=${EVENT}"
+ out="$(crosslink/resources/container/crosslink-amd64 --version)"
+ if [ "$out" != "crosslink ${EXPECTED}" ]; then
+ echo "::error::amd64 binary reports '${out}', expected 'crosslink ${EXPECTED}'"
exit 1
fi
-
- {
- echo "tags<> "$GITHUB_OUTPUT"
+ for arch in amd64 arm64; do
+ bin="crosslink/resources/container/crosslink-${arch}"
+ grep -aqF "$EXPECTED" "$bin" || { echo "::error::${arch} binary does not carry version ${EXPECTED}"; exit 1; }
+ grep -aqF "$DEFAULT_IMAGE" "$bin" || { echo "::error::${arch} binary does not default to ${DEFAULT_IMAGE}"; exit 1; }
+ done
+ echo "crosslink_version=${EXPECTED}" >> "$GITHUB_OUTPUT"
+
+ # A re-run of this job (Re-run failed jobs) after the push already
+ # happened must not rebuild and overwrite :: reuse the
+ # published digest instead. On a first attempt an existing version means
+ # something else published it since the gate, so stop.
+ - name: Reuse an already published version on a re-run
+ id: existing
+ if: needs.gate.outputs.release == 'true'
+ env:
+ BASE: ${{ needs.gate.outputs.base }}
+ VERSION: ${{ needs.gate.outputs.version }}
+ ATTEMPT: ${{ github.run_attempt }}
+ run: |
+ set -euo pipefail
+ repo="${BASE#"${REGISTRY}"/}"
+ token="$(curl -sS "https://${REGISTRY}/token?scope=repository:${repo}:pull" | jq -r '.token // empty' || true)"
+ test -n "$token" || { echo "::error::no anonymous registry token for ${BASE}"; exit 1; }
+ headers="$(curl -sS -o /dev/null -D - -I -H "Authorization: Bearer ${token}" \
+ -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json' \
+ "https://${REGISTRY}/v2/${repo}/manifests/${VERSION}" || true)"
+ status="$(printf '%s' "$headers" | head -n 1 | awk '{print $2}')"
+ case "$status" in
+ 404) echo "reuse=false" >> "$GITHUB_OUTPUT" ;;
+ 200)
+ if [ "$ATTEMPT" = 1 ]; then
+ echo "::error::${BASE}:${VERSION} appeared after the gate checked it; not overwriting it"
+ exit 1
+ fi
+ digest="$(printf '%s' "$headers" | tr -d '\r' | awk -F': ' 'tolower($1) == "docker-content-digest" {print $2}')"
+ echo "Re-run: ${BASE}:${VERSION} is already published as ${digest}; reusing it"
+ { echo "reuse=true"; echo "digest=${digest}"; } >> "$GITHUB_OUTPUT"
+ ;;
+ *) echo "::error::registry answered HTTP ${status:-none} for ${BASE}:${VERSION}"; exit 1 ;;
+ esac
- name: Set up QEMU
- uses: docker/setup-qemu-action@v3
+ uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
with:
platforms: linux/amd64,linux/arm64
- name: Set up Buildx
- uses: docker/setup-buildx-action@v3
+ uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Log in to GHCR
- if: steps.tags.outputs.push == 'true'
- uses: docker/login-action@v3
+ if: needs.gate.outputs.push == 'true'
+ uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
- uses: docker/build-push-action@v6
+ id: build
+ if: steps.existing.outputs.reuse != 'true'
+ uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: crosslink/resources/container
file: crosslink/resources/container/Dockerfile
platforms: linux/amd64,linux/arm64
- push: ${{ steps.tags.outputs.push == 'true' }}
- tags: ${{ steps.tags.outputs.tags }}
+ push: ${{ needs.gate.outputs.push == 'true' }}
+ tags: ${{ needs.gate.outputs.tags }}
+ labels: |
+ dev.crosslink.version=${{ steps.label.outputs.crosslink_version }}
+ org.opencontainers.image.version=${{ steps.label.outputs.crosslink_version }}
+ org.opencontainers.image.revision=${{ github.sha }}
+ org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
provenance: false
-
- cache-from: type=registry,ref=${{ steps.tags.outputs.cache_ref }}
- cache-to: ${{ steps.tags.outputs.push == 'true' && format('type=registry,ref={0},mode=max', steps.tags.outputs.cache_ref) || '' }}
-
-
+ # Releases build without the registry cache that develop pushes
+ # write, so a release image follows only from its tagged source.
+ no-cache: ${{ needs.gate.outputs.release == 'true' }}
+ cache-from: ${{ needs.gate.outputs.release != 'true' && format('type=registry,ref={0}', needs.gate.outputs.cache_ref) || '' }}
+ cache-to: ${{ needs.gate.outputs.write_cache == 'true' && format('type=registry,ref={0},mode=max', needs.gate.outputs.cache_ref) || '' }}
+
+ - name: Attest the image build
+ if: needs.gate.outputs.push == 'true'
+ uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2
+ with:
+ subject-name: ${{ needs.gate.outputs.base }}
+ subject-digest: ${{ steps.build.outputs.digest || steps.existing.outputs.digest }}
+ push-to-registry: true
container-pr-smoke:
name: Provider smoke (PR)
- needs: build-binary
+ needs: [gate, build-binary]
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
@@ -306,74 +439,243 @@ jobs:
fi
done
-
-
-
-
smoke-verify:
name: Smoke verify (${{ matrix.platform }})
- needs: publish-image
- if: needs.publish-image.outputs.pushed == 'true'
+ needs: [gate, publish-image]
+ if: needs.gate.outputs.push == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
platform: [linux/amd64, linux/arm64]
+ env:
+ BASE: ${{ needs.gate.outputs.base }}
+ DIGEST: ${{ needs.publish-image.outputs.digest }}
steps:
- name: Set up QEMU (for non-native arch)
uses: docker/setup-qemu-action@v3
with:
platforms: linux/amd64,linux/arm64
+ # No login: the smoke test pulls exactly as a user does, so a private
+ # package or a tag that did not land fails here instead of in the field.
+ - name: Every pushed tag resolves anonymously to the built digest
+ if: matrix.platform == 'linux/amd64'
+ env:
+ TAGS: ${{ needs.gate.outputs.tags }}
+ run: |
+ set -euo pipefail
+ test -n "$DIGEST"
+ repo="${BASE#"${REGISTRY}"/}"
+ token="$(curl -sS "https://${REGISTRY}/token?scope=repository:${repo}:pull" | jq -r '.token // empty' || true)"
+ if [ -z "$token" ]; then
+ echo "::error::${BASE} refuses anonymous pulls; make the package public in its package settings"
+ exit 1
+ fi
+ for ref in $TAGS; do
+ tag="${ref##*:}"
+ headers="$(curl -sS -o /dev/null -D - -I \
+ -H "Authorization: Bearer ${token}" \
+ -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json' \
+ "https://${REGISTRY}/v2/${repo}/manifests/${tag}")"
+ status="$(printf '%s' "$headers" | head -n 1 | awk '{print $2}')"
+ got="$(printf '%s' "$headers" | tr -d '\r' | awk -F': ' 'tolower($1) == "docker-content-digest" {print $2}')"
+ if [ "$status" != 200 ]; then
+ echo "::error::${ref} is not pullable anonymously (HTTP ${status})"
+ exit 1
+ fi
+ if [ "$got" != "$DIGEST" ]; then
+ echo "::error::${ref} resolves to ${got}, not the built digest ${DIGEST}"
+ exit 1
+ fi
+ echo "anonymous pull OK: ${ref} -> ${got}"
+ done
+
+ - name: Pull the built digest
+ run: docker pull --platform ${{ matrix.platform }} "${BASE}@${DIGEST}"
+ - name: The image's tools run on this platform
+ run: |
+ docker run --rm --platform ${{ matrix.platform }} "${BASE}@${DIGEST}" \
+ bash -c 'set -eo pipefail; claude --version; codex --version; gh --version | head -n 1; uv --version'
+ - name: Manifest covers both architectures (once)
+ if: matrix.platform == 'linux/amd64'
+ run: |
+ docker manifest inspect "${BASE}@${DIGEST}" \
+ | jq -e '[.manifests[].platform | select(.os != "unknown") | "\(.os)/\(.architecture)"] | sort | . == ["linux/amd64","linux/arm64"]'
+
+ - name: Run crosslink --version and check the image's version label
+ env:
+ LABEL_VERSION: ${{ needs.publish-image.outputs.crosslink_version }}
+ AGENT_IMAGE_TAG: ${{ needs.gate.outputs.agent_image_tag }}
+ RELEASE: ${{ needs.gate.outputs.release }}
+ VERSION: ${{ needs.gate.outputs.version }}
+ run: |
+ set -euo pipefail
+ out="$(docker run --rm --platform ${{ matrix.platform }} --entrypoint /usr/local/bin/crosslink "${BASE}@${DIGEST}" --version)"
+ echo "version output: $out"
+ echo "$out" | grep -E '^crosslink [0-9]+\.[0-9]+\.[0-9]+'
+ label="$(docker image inspect --format '{{ index .Config.Labels "dev.crosslink.version" }}' "${BASE}@${DIGEST}")"
+ if [ "crosslink ${label}" != "$out" ] || [ "$label" != "$LABEL_VERSION" ]; then
+ echo "::error::image label '${label}' does not match its binary '${out}'"
+ exit 1
+ fi
+ # The binary must default to this image: the build-matched contract.
+ expected_default="${BASE}:${AGENT_IMAGE_TAG}"
+ if ! docker run --rm --platform ${{ matrix.platform }} --entrypoint grep "${BASE}@${DIGEST}" -aqF "$expected_default" /usr/local/bin/crosslink; then
+ echo "::error::the ${{ matrix.platform }} binary does not default to ${expected_default}"
+ exit 1
+ fi
+ if [ "$RELEASE" = true ]; then
+ actual="$(echo "$out" | awk '{print $2}' | sed 's/+.*//')"
+ if [ "$VERSION" != "$actual" ]; then
+ echo "::error::Tag version ($VERSION) does not match binary version ($actual)"
+ exit 1
+ fi
+ fi
+ # :latest is the most recent stable release. It moves only to the digest the
+ # smoke test ran, only when that version is the highest stable v* tag (a
+ # backport or a re-run of an older release never moves it backwards), and one
+ # promotion at a time across runs. Re-pointing it to an older version's
+ # digest by hand is the rollback. A develop dispatch with
+ # rehearse_promotion exercises this job against :promotion-rehearsal.
+ promote-latest:
+ name: Promote verified image (:latest or rehearsal)
+ needs: [gate, publish-image, smoke-verify]
+ if: >-
+ (needs.gate.outputs.release == 'true' && needs.gate.outputs.prerelease == 'false') ||
+ needs.gate.outputs.rehearsal == 'true'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+ concurrency:
+ group: promote-latest-${{ github.repository }}
+ cancel-in-progress: false
+ env:
+ BASE: ${{ needs.gate.outputs.base }}
+ DIGEST: ${{ needs.publish-image.outputs.digest }}
+ VERSION: ${{ needs.gate.outputs.version }}
+ REHEARSAL: ${{ needs.gate.outputs.rehearsal }}
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ with:
+ fetch-depth: 0
+ - name: Decide the target tag
+ id: target
+ run: |
+ set -euo pipefail
+ if [ "$REHEARSAL" = true ]; then
+ echo "tag=promotion-rehearsal" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ highest="$(git -c versionsort.suffix=- tag --list 'v*' --sort=-version:refname | grep -v -- '-' | head -n 1)"
+ if [ "$highest" != "v${VERSION}" ]; then
+ echo "::notice::v${VERSION} is not the highest stable release (${highest}); :latest stays where it is"
+ echo "tag=" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ echo "tag=latest" >> "$GITHUB_OUTPUT"
+ - name: Set up Buildx
+ if: steps.target.outputs.tag != ''
+ uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Log in to GHCR
- uses: docker/login-action@v3
+ if: steps.target.outputs.tag != ''
+ uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- - name: Pull image
- run: docker pull --platform ${{ matrix.platform }} ${{ needs.publish-image.outputs.primary_tag }}
-
- - name: Inspect manifest covers both arches (once per matrix is fine)
- if: matrix.platform == 'linux/amd64'
+ - name: Point the tag at the verified digest
+ if: steps.target.outputs.tag != ''
+ env:
+ TARGET: ${{ steps.target.outputs.tag }}
run: |
- docker manifest inspect ${{ needs.publish-image.outputs.primary_tag }} \
- | jq -e '[.manifests[].platform | "\(.os)/\(.architecture)"] | sort | . == ["linux/amd64","linux/arm64"]'
+ set -euo pipefail
+ test -n "$DIGEST"
+ # The source must exist before anything moves.
+ docker buildx imagetools inspect "${BASE}@${DIGEST}" > /dev/null
+ docker buildx imagetools create --tag "${BASE}:${TARGET}" "${BASE}@${DIGEST}"
+ repo="${BASE#"${REGISTRY}"/}"
+ token="$(curl -sS "https://${REGISTRY}/token?scope=repository:${repo}:pull" | jq -r '.token // empty' || true)"
+ got="$(curl -sS -o /dev/null -D - -I -H "Authorization: Bearer ${token}" \
+ -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' \
+ "https://${REGISTRY}/v2/${repo}/manifests/${TARGET}" | tr -d '\r' | awk -F': ' 'tolower($1) == "docker-content-digest" {print $2}')"
+ if [ "$got" != "$DIGEST" ]; then
+ echo "::error::${BASE}:${TARGET} resolves to '${got}', not ${DIGEST}; re-point it by hand"
+ exit 1
+ fi
+ echo "${BASE}:${TARGET} -> ${DIGEST}"
+
+ release-binaries:
+ name: Release binaries
+ needs: gate
+ if: needs.gate.outputs.release == 'true'
+ uses: ./.github/workflows/release-builds.yml
+ with:
+ agent_image_tag: ${{ needs.gate.outputs.version }}
+ agent_image_repository: ${{ needs.gate.outputs.base }}
+ crosslink_version: ${{ needs.gate.outputs.crosslink_version }}
+
+ # The GitHub release is created only after the image for this version passed
+ # its anonymous smoke test, because every attached binary defaults to it.
+ publish-release:
+ name: Publish GitHub release
+ needs: [gate, smoke-verify, release-binaries]
+ if: needs.gate.outputs.release == 'true'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ id-token: write
+ attestations: write
+ env:
+ VERSION: ${{ needs.gate.outputs.version }}
+ PRERELEASE: ${{ needs.gate.outputs.prerelease }}
+ steps:
+ - name: Download release artifacts
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
+ with:
+ path: dist
+ pattern: release-*
- - name: Run crosslink --version
- id: version
+ - name: Collect assets and checksums
run: |
set -euo pipefail
+ mkdir -p release
+ for dir in dist/release-*/; do
+ name="$(basename "$dir")"
+ name="${name#release-}"
+ for file in "$dir"*; do
+ case "$file" in
+ *.tar.gz) cp "$file" release/ ;;
+ *) cp "$file" "release/${name}" ;;
+ esac
+ done
+ done
+ (cd release && sha256sum -- * > SHA256SUMS)
+ cat release/SHA256SUMS
+ - name: Attest the release binaries
+ uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2
+ with:
+ subject-path: release/*
- OUT=$(docker run --rm --platform ${{ matrix.platform }} \
- --entrypoint /usr/local/bin/crosslink \
- ${{ needs.publish-image.outputs.primary_tag }} --version)
- echo "version output: $OUT"
- echo "out=$OUT" >> "$GITHUB_OUTPUT"
-
- echo "$OUT" | grep -E '^crosslink [0-9]+\.[0-9]+\.[0-9]+'
-
- - name: Verify version matches tag (release tags only)
- if: startsWith(github.ref, 'refs/tags/v')
+ - name: Create the release
+ env:
+ GH_TOKEN: ${{ github.token }}
run: |
- EXPECTED="${{ needs.publish-image.outputs.version }}"
-
-
-
-
-
-
- ACTUAL=$(echo "${{ steps.version.outputs.out }}" | awk '{print $2}' | sed 's/+.*//')
- if [ "$EXPECTED" != "$ACTUAL" ]; then
- echo "::error::Tag version ($EXPECTED) does not match binary version ($ACTUAL)"
- exit 1
- fi
- echo "Version match: $EXPECTED"
+ set -euo pipefail
+ flags=()
+ if [ "$PRERELEASE" = true ]; then flags+=(--prerelease); fi
+ gh release create "v${VERSION}" release/* \
+ --repo "${GITHUB_REPOSITORY}" \
+ --verify-tag \
+ --title "v${VERSION}" \
+ --generate-notes \
+ "${flags[@]}"
diff --git a/.github/workflows/image-canary.yml b/.github/workflows/image-canary.yml
new file mode 100644
index 000000000..8c446c155
--- /dev/null
+++ b/.github/workflows/image-canary.yml
@@ -0,0 +1,88 @@
+name: Agent Image Canary
+
+# Checks, as an anonymous user would, that the agent images released crosslink
+# builds default to can still be pulled. A private package, a missing tag or a
+# deleted old-owner image fails here instead of in users' kickoffs.
+
+on:
+ schedule:
+ - cron: '17 6 * * *'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ canary:
+ name: Default images are pullable
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Collect the image references released builds use
+ id: refs
+ run: |
+ set -euo pipefail
+ owner="$(printf '%s' "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')"
+ base="ghcr.io/${owner}/crosslink-agent"
+ # ref[@expected digest]: a digest means the tag must still name exactly
+ # that image (it is outside this repository's control).
+ refs="${base}:nightly"
+ # Releases up to v0.9.0-beta.1 default to the frozen forecast-bio
+ # image; it must stay pullable and unchanged.
+ refs="${refs} ghcr.io/forecast-bio/crosslink-agent:latest@sha256:5f93d5509aecbc5d05c88e9836b62014de7acc754e93605bfe67319e388ccb60"
+ # Every later release defaults to its own version tag. versionsort.suffix
+ # ranks a prerelease below its release (v0.10.0-rc.1 < v0.10.0).
+ stable=""
+ after_scheme=false
+ for tag in $(git -c versionsort.suffix=- tag --list 'v*' --sort=version:refname); do
+ if [ "$after_scheme" = true ]; then
+ refs="${refs} ${base}:${tag#v}"
+ case "$tag" in *-*) ;; *) stable="$tag" ;; esac
+ fi
+ if [ "$tag" = v0.9.0-beta.1 ]; then after_scheme=true; fi
+ done
+ if [ -n "$stable" ]; then
+ refs="${refs} ${base}:latest"
+ fi
+ echo "refs=${refs}" >> "$GITHUB_OUTPUT"
+ echo "checking: ${refs}"
+
+ - name: Pull each reference anonymously
+ env:
+ REFS: ${{ steps.refs.outputs.refs }}
+ run: |
+ set -euo pipefail
+ failed=0
+ for entry in $REFS; do
+ ref="${entry%@*}"
+ expected=""
+ if [ "$entry" != "$ref" ]; then expected="${entry#*@}"; fi
+ image="${ref#ghcr.io/}"
+ repo="${image%:*}"
+ tag="${image##*:}"
+ token="$(curl -sS "https://ghcr.io/token?scope=repository:${repo}:pull" | jq -r '.token // empty' || true)"
+ if [ -z "$token" ]; then
+ echo "::error::${ref}: anonymous pull refused (package private or missing)"
+ failed=1
+ continue
+ fi
+ headers="$(curl -sS -o /dev/null -D - -I \
+ -H "Authorization: Bearer ${token}" \
+ -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json' \
+ "https://ghcr.io/v2/${repo}/manifests/${tag}")"
+ status="$(printf '%s' "$headers" | head -n 1 | awk '{print $2}')"
+ got="$(printf '%s' "$headers" | tr -d '\r' | awk -F': ' 'tolower($1) == "docker-content-digest" {print $2}')"
+ if [ "$status" != 200 ]; then
+ echo "::error::${ref}: manifest HTTP ${status}"
+ failed=1
+ elif [ -n "$expected" ] && [ "$got" != "$expected" ]; then
+ echo "::error::${ref} now resolves to ${got}, not ${expected}: the image installed releases pull has changed"
+ failed=1
+ else
+ echo "OK ${ref} ${got}"
+ fi
+ done
+ exit "$failed"
diff --git a/.github/workflows/release-builds.yml b/.github/workflows/release-builds.yml
index dc2ff311b..709336d6c 100644
--- a/.github/workflows/release-builds.yml
+++ b/.github/workflows/release-builds.yml
@@ -3,22 +3,41 @@ name: Release Builds
+# Builds the release binaries and Codex plugin archives. Pull requests to main
+# build them as a check; releases run this as a reusable workflow from
+# container-image.yml, which attaches the results to the GitHub release only
+# after the release's agent image has passed its smoke test.
on:
- push:
- tags:
- - 'v*'
pull_request:
branches: [main]
+ workflow_call:
+ inputs:
+ agent_image_tag:
+ description: The default agent image tag the binaries bake in (the release version)
+ type: string
+ required: true
+ agent_image_repository:
+ description: The agent image repository the binaries default to
+ type: string
+ required: true
+ crosslink_version:
+ description: The version string every binary reports
+ type: string
+ required: true
permissions:
contents: read
concurrency:
- group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
- cancel-in-progress: true
+ group: release-builds-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
CARGO_TERM_COLOR: always
+ # Release runs pass the version; pull-request check builds are development builds.
+ CROSSLINK_AGENT_IMAGE_TAG: ${{ inputs.agent_image_tag || 'nightly' }}
+ CROSSLINK_AGENT_IMAGE_REPOSITORY: ${{ inputs.agent_image_repository }}
+ CROSSLINK_VERSION: ${{ inputs.crosslink_version }}
jobs:
package-codex-plugin:
@@ -66,7 +85,7 @@ jobs:
- name: Upload plugin release artifact
uses: actions/upload-artifact@v4
with:
- name: crosslink-codex-plugin
+ name: release-crosslink-codex-plugin
path: |
crosslink/crosslink-codex-plugin-*.tar.gz
crosslink/crosslink-codex-marketplace-*.tar.gz
@@ -83,18 +102,29 @@ jobs:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
- artifact: crosslink-linux
+ artifact: release-crosslink-linux-x86_64
+ use_cross: false
+ - os: ubuntu-latest
+ target: aarch64-unknown-linux-musl
+ artifact: release-crosslink-linux-aarch64
+ use_cross: true
+ - os: macos-latest
+ target: aarch64-apple-darwin
+ artifact: release-crosslink-macos-aarch64
+ use_cross: false
- os: macos-latest
target: x86_64-apple-darwin
- artifact: crosslink-macos
+ artifact: release-crosslink-macos-x86_64
+ use_cross: false
- os: windows-latest
target: x86_64-pc-windows-msvc
- artifact: crosslink-win.exe
+ artifact: release-crosslink-windows-x86_64.exe
+ use_cross: false
steps:
- uses: actions/checkout@v4
- - name: Install musl-tools (Linux only)
- if: matrix.os == 'ubuntu-latest'
+ - name: Install musl-tools (Linux native only)
+ if: matrix.os == 'ubuntu-latest' && matrix.use_cross == false
run: sudo apt-get update && sudo apt-get install -y musl-tools
- name: Install Rust toolchain
@@ -102,9 +132,32 @@ jobs:
with:
targets: ${{ matrix.target }}
- - name: Build release
+ - name: Install cross (Linux aarch64 only)
+ if: matrix.use_cross == true
+ uses: taiki-e/install-action@v2
+ with:
+ tool: cross
+
+ - name: Build release (native)
+ if: matrix.use_cross == false
run: cargo build --locked --release --target ${{ matrix.target }}
+ - name: Build release (cross)
+ if: matrix.use_cross == true
+ run: cross build --locked --release --target ${{ matrix.target }}
+
+ - name: Check the binary defaults to the release image (release runs)
+ if: inputs.agent_image_tag != ''
+ shell: bash
+ env:
+ BIN: target/${{ matrix.target }}/release/crosslink${{ matrix.os == 'windows-latest' && '.exe' || '' }}
+ EXPECTED_IMAGE: ${{ inputs.agent_image_repository }}:${{ inputs.agent_image_tag }}
+ EXPECTED_VERSION: ${{ inputs.crosslink_version }}
+ run: |
+ set -euo pipefail
+ grep -aqF "$EXPECTED_IMAGE" "$BIN" || { echo "::error::${BIN} does not default to ${EXPECTED_IMAGE}"; exit 1; }
+ grep -aqF "$EXPECTED_VERSION" "$BIN" || { echo "::error::${BIN} does not carry version ${EXPECTED_VERSION}"; exit 1; }
+
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 73cd86f76..b2213bb50 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,51 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Added
+- Container commands default to the agent image that matches the crosslink
+ build: released versions use `ghcr.io/corvidae-coding-projects/crosslink-agent:`
+ and development builds use `:nightly`, replacing the `:latest` default that
+ could lag behind or run ahead of the CLI (gh#125). A build counts as a
+ release only on positive evidence: a published package, a checkout at its
+ `v` tag, or an explicit `CROSSLINK_AGENT_IMAGE_TAG`, which must be a
+ valid tag or the build fails. `CROSSLINK_AGENT_IMAGE_REPOSITORY` lets fork
+ builds default to their own registry. `:nightly` and `:latest` are refreshed
+ before every launch, falling back to a local copy with a warning when the
+ registry cannot be reached (but not when the registry refuses the image).
+ Every container command (kickoff, `container
+ start`, `container auth`, swarm and sentinel) resolves its image the same
+ way: `--image`, then `CROSSLINK_CONTAINER_IMAGE`, then the build default, and
+ reports where the image came from.
+- Releases are one gated flow (gh#126, gh#122, gh#123). A `v*` tag must match
+ `crosslink/Cargo.toml`, must be on `main`, and the registry must confirm the
+ version is unpublished (versions are never republished; anything but an
+ explicit "not found" stops the release). Release images build without the
+ registry cache that `develop` pushes write, every binary reports one
+ version string set by the gate, and the smoke test also checks that the
+ binaries default to `:` and that the bundled tools run on both
+ architectures. It pushes only `:`; the smoke test pulls that exact
+ digest anonymously and checks that every pushed tag resolves to it, so a
+ private package or a missing tag fails CI; `:latest` then moves to the
+ verified digest, one promotion at a time, and only when the version is the
+ highest stable release. The GitHub release is created only after the image
+ passed, with Linux (x86_64, aarch64), macOS (aarch64, x86_64) and Windows
+ binaries, the Codex plugin archives, `SHA256SUMS`, and build attestations.
+ Images carry their crosslink version as a label and a build attestation.
+ Write permissions are granted only to the publishing jobs, whose actions are
+ pinned by commit. Manual publishes run only from `develop`, and a manual run
+ can rehearse the promotion against `:promotion-rehearsal`. A daily canary
+ checks, anonymously, `:nightly`, every release's `:` and
+ `:latest`, and that the frozen `ghcr.io/forecast-bio/crosslink-agent:latest`
+ 0.9.0-beta.1 uses still names the same image.
+- `RELEASING.md`: the release runbook (what a tag publishes and in which order, rollback, image guarantees, required repository settings) and a checklist for moving the repository or owner (gh#124).
+- The agent image pins its base image by digest and its agent tools by version
+ (GitHub CLI, Codex, Claude Code, uv, gosu), verifies gosu by checksum, and
+ fails the build when a piped installer download fails (gh#128). Ubuntu's apt
+ packages and the installer scripts still float.
+- Kickoff and `container start` compare the crosslink version an agent image
+ carries (its `dev.crosslink.version` label, set by CI, `container build` and
+ `just build-image`) with the CLI's, warning when they differ, since both write
+ the same hub (gh#127). The old binary-hash staleness check, which never fired
+ for published images, is removed.
- `crosslink container auth login|status|refresh|logout --provider github`
keeps a GitHub CLI login in a per-user docker volume so container agents
can publish hub refs over HTTPS (gh#109). Kickoff and `container start`
diff --git a/RELEASING.md b/RELEASING.md
new file mode 100644
index 000000000..77dc1fdbb
--- /dev/null
+++ b/RELEASING.md
@@ -0,0 +1,158 @@
+# Releasing crosslink
+
+This is the runbook for cutting a crosslink release and for moving the project
+to a new repository or owner. It describes what the workflows do today; when a
+workflow changes, change this file in the same pull request.
+
+## What a release publishes
+
+A `v` tag pushed to `main` starts the release flow in
+`.github/workflows/container-image.yml`:
+
+| Stage | Job | What happens | Fails when |
+|---|---|---|---|
+| Gate | `gate` | Decides what the run may publish and the version string every binary reports. | The tag does not match `crosslink/Cargo.toml`, the tagged commit is not on `main`, or the registry does not confirm `:` is unpublished (only an explicit "not found" proceeds). |
+| Image | `build-binary`, `publish-image` | Builds Linux amd64/arm64 binaries that default to `:`, pushes `ghcr.io//crosslink-agent:` built without the registry cache (which `develop` pushes write), with `dev.crosslink.version`, revision and source labels, and attests the build. | The build fails. |
+| Smoke test | `smoke-verify` | Without logging in, checks that every pushed tag resolves to the built digest, pulls that digest on both architectures, runs `crosslink --version` and the bundled tools, and checks the version label and that the binary defaults to `:`. | The package is private, a tag did not land, a tool is missing, or the binary is not the tagged version. |
+| `:latest` | `promote-latest` | Points `:latest` at the verified digest. One promotion runs at a time. | Skipped for prereleases and for any version that is not the highest stable `v*` tag. |
+| Binaries | `release-binaries` (`release-builds.yml`) | Builds Linux x86_64/aarch64, macOS aarch64/x86_64 and Windows x86_64 binaries plus the Codex plugin archives, and checks each binary defaults to `:` and reports the gate's version. | A build or a check fails. |
+| GitHub release | `publish-release` | Creates the release with the binaries, plugin archives, `SHA256SUMS` and build attestations. Prereleases are marked as such. | The smoke test or a build failed. |
+
+The tag push also runs `publish.yml` (crates.io). That workflow is not part of
+the gated flow yet and needs the crate's ownership and credential settled first;
+until then it fails on tag pushes from this repository.
+
+Every released build defaults to the agent image of its own version, so a
+release is only usable once `:` is published and public. The order
+above guarantees that the GitHub release never points at an unverified image.
+
+## Before you tag
+
+1. Release from a `release/v` branch cut from `develop`, opened as a
+ pull request to `main`.
+2. Bump `version` in `crosslink/Cargo.toml` (and `Cargo.lock`). A version that
+ was ever published anywhere, including crates.io, cannot be reused.
+3. Move the `[Unreleased]` entries in `CHANGELOG.md` under the new version.
+4. Make sure CI on the release pull request is green, then merge it.
+5. Optional, before the first release from a new repository or after changing
+ the workflow: run **Container Image** manually on `develop` with
+ *rehearse_promotion*. It exercises the gate, smoke test and promotion
+ against `:promotion-rehearsal` without touching `:latest`.
+
+## Tag and watch
+
+Create an annotated tag `v` on the merged `main` commit and push it to
+the repository.
+
+Watch the **Container Image** run for the tag. When it is green:
+
+- `ghcr.io//crosslink-agent:` exists and pulls anonymously;
+- for a stable release, `:latest` names the same digest;
+- the GitHub release has the binaries and `SHA256SUMS`.
+
+Then merge `main` back into `develop` so the next release does not conflict
+(skipping this back-merge is what made the v0.9.0-beta.1 cut painful).
+
+## If something fails
+
+- **Gate fails:** nothing was published. Fix the version or the commit and tag
+ again. Delete the failed tag (an admin can, even once tags are protected):
+ promotion takes "highest stable" from git tags, so a stray higher tag would
+ stop `:latest` from moving and turn the canary red.
+- **A later stage fails for a transient reason:** use **Re-run failed jobs**.
+ A re-run of the image job reuses the digest that was already pushed instead
+ of rebuilding it. Re-running all jobs starts at the gate again, which
+ refuses because `:` now exists. Build artifacts are kept for one
+ day, so re-run within a day or release the next patch version.
+- **Smoke test fails:** `:` exists but `:latest` did not move and no
+ GitHub release was created. Find the cause, fix it, and release the next
+ patch version. Do not republish the same version: the gate refuses, on
+ purpose, because pinned tags are cached by users.
+- **Bad release after promotion (rollback):** point `:latest` at the previous
+ release's digest by hand:
+
+ ```
+ docker buildx imagetools create --tag ghcr.io//crosslink-agent:latest \
+ ghcr.io//crosslink-agent@
+ ```
+
+ Then mark the GitHub release as a prerelease or delete its assets, and ship a
+ fixed patch release.
+
+## Images and their guarantees
+
+| Tag | Moves? | Who uses it |
+|---|---|---|
+| `:` | Not by the release flow (the gate refuses to republish and re-runs reuse the pushed digest); any repository writer can still push one from another workflow until rulesets restrict that | Released builds of that version |
+| `:latest` | To each stable release, after its smoke test | Anyone who asks for it with `--image` |
+| `:nightly` | On every push to `develop` | Development builds; refreshed before each launch |
+| `:nightly-` | Not by design, but registry tags can be moved | Pinning a development build |
+
+Only a digest pin is byte-for-byte reproducible. Images and binaries carry
+build attestations (`gh attestation verify --owner `).
+
+Because development builds follow `:nightly`, anything merged to `develop` runs
+in every development build's next container, which mounts the user's provider
+and GitHub logins. Protect `develop` accordingly.
+
+Known limit: `cargo install --git … --tag v` cannot see its release tag
+and defaults to `:nightly`; such users should pass `--image` with their version.
+
+The daily **Agent Image Canary** checks, anonymously, that `:nightly`, every
+release's `:` and `:latest`, and the frozen
+`ghcr.io/forecast-bio/crosslink-agent:latest` (which 0.9.0-beta.1 and older
+builds default to, pinned to its digest) can still be pulled. Never delete or
+make private an image that a released version defaults to.
+
+## Updating pinned tools in the image
+
+`crosslink/resources/container/Dockerfile` pins the base image by digest and gh,
+Codex, Claude Code, uv and gosu by version (gosu by checksum too). Still
+floating: Ubuntu's apt packages, the gh apt keyring, Codex's npm dependencies,
+and the Claude Code and uv installer scripts. To update a pin, change its `ARG`
+(and checksum) in a pull request; the new tools reach users in the next
+`:nightly` and the next release. Released `:` images are not rebuilt
+with newer tools: ship a patch release instead.
+
+## Required repository settings
+
+- **Packages:** `crosslink-agent` is public and its Actions access lets this
+ repository write. A package created by a workflow starts private; make it
+ public before the first release from a new owner.
+- **Permissions:** workflows default to read-only tokens; the publishing jobs
+ request `packages: write`, `contents: write`, `id-token: write` and
+ `attestations: write` themselves.
+- **Environments:** `github-pages` for the docs; `cargo` (tag-only deployment
+ policy) once crates.io publishing is wired up.
+- **Rulesets (intended; not configured as of 2026-10-08):** `v*` tags may be
+ created only by maintainers and never moved (deletion by an admin only, for
+ tags whose release failed at the gate); `main` and `develop` require pull
+ requests and passing CI and refuse force pushes and deletion. Until they
+ exist, the release checks guard against accidents only: the gate runs from
+ the tagged commit's own workflow, so anyone with write access can tag any
+ commit, edit the checks out, and publish a release, and can push to
+ `develop`, which every development build's `:nightly` follows. The rulesets
+ are the only control against that.
+
+## Moving the repository or owner
+
+Each past move (dollspace-gay/chainlink → forecast-bio/crosslink →
+dollspace-gay/crosslink → Corvidae-Coding-Projects/crosslink) dropped part of
+publishing. Repository redirects do not cover GHCR, crates.io, Pages or
+settings. When moving:
+
+1. Update `repository`/`homepage` in `crosslink/Cargo.toml`, README links, the
+ VS Code extension metadata, `docs_src/_quarto.yml`, and the default image
+ repository in `crosslink/src/build_support/agent_image.rs`. Leave historical
+ references (CHANGELOG entries, old issue links) pointing where they pointed.
+2. Make the new owner's `crosslink-agent` package public and link it to the
+ repository after its first publish; check with an anonymous pull.
+3. Add the new owner to the crates.io crate (or set up trusted publishing for
+ the new repository) and recreate the `cargo` environment and credential.
+4. Recreate Actions secrets, environments (including `github-pages`) and the
+ rulesets above, and enable Pages with the workflow source.
+5. Keep the old owner's images public and frozen (released builds still pull
+ them); mark them deprecated in the package description and add them to the
+ canary with their digests.
+6. Cut a patch release from the new repository so crates.io metadata, the
+ release assets and `:` all exist under the new owner.
diff --git a/crosslink/Cross.toml b/crosslink/Cross.toml
index 90d54ec6d..5c7bbda2b 100644
--- a/crosslink/Cross.toml
+++ b/crosslink/Cross.toml
@@ -1,3 +1,11 @@
+[build.env]
+# The agent image defaults and version string CI bakes in (see build.rs).
+passthrough = [
+ "CROSSLINK_AGENT_IMAGE_TAG",
+ "CROSSLINK_AGENT_IMAGE_REPOSITORY",
+ "CROSSLINK_VERSION",
+]
+
diff --git a/crosslink/build.rs b/crosslink/build.rs
index 0344de89a..0d03460fc 100644
--- a/crosslink/build.rs
+++ b/crosslink/build.rs
@@ -3,9 +3,29 @@ use std::io::Write;
use std::path::Path;
fn main() {
- println!("cargo:rerun-if-changed=../.git/HEAD");
- println!("cargo:rerun-if-changed=../.git/refs/");
- if let Ok(output) = std::process::Command::new("git")
+ emit_agent_image();
+
+ // CI passes the version string explicitly so every architecture reports
+ // the same one; cross-compile containers may not see the repository's git
+ // metadata. Otherwise derive it from git.
+ println!("cargo:rerun-if-env-changed=CROSSLINK_VERSION");
+ let explicit_version = std::env::var("CROSSLINK_VERSION")
+ .ok()
+ .map(|value| value.trim().to_string())
+ .filter(|value| !value.is_empty());
+ if let Some(version) = explicit_version {
+ let rest = version.strip_prefix(env!("CARGO_PKG_VERSION"));
+ let valid = rest.is_some_and(|rest| rest.is_empty() || rest.starts_with('+'))
+ && version
+ .chars()
+ .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+'));
+ assert!(
+ valid,
+ "CROSSLINK_VERSION={version:?} must be the crate version {} optionally followed by +, using only [A-Za-z0-9.+-]",
+ env!("CARGO_PKG_VERSION")
+ );
+ println!("cargo:rustc-env=CROSSLINK_VERSION={version}");
+ } else if let Ok(output) = std::process::Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
{
@@ -82,6 +102,118 @@ the design; GH #429 tracks the broader feature.
}
}
+include!("src/build_support/agent_image.rs");
+
+/// Build-time overrides, and the compile-time variables that carry the chosen
+/// repository and tag into the binary.
+const AGENT_IMAGE_TAG_ENV: &str = "CROSSLINK_AGENT_IMAGE_TAG";
+const AGENT_IMAGE_REPOSITORY_ENV: &str = "CROSSLINK_AGENT_IMAGE_REPOSITORY";
+
+/// Choose the default agent image (see `src/build_support/agent_image.rs`),
+/// pass it to the compiler, and tell cargo when to choose again. An invalid
+/// explicit override fails the build rather than falling back silently.
+fn emit_agent_image() {
+ println!("cargo:rerun-if-env-changed={AGENT_IMAGE_TAG_ENV}");
+ println!("cargo:rerun-if-env-changed={AGENT_IMAGE_REPOSITORY_ENV}");
+ let env_override = |name: &str| {
+ std::env::var(name)
+ .ok()
+ .map(|value| value.trim().to_string())
+ .filter(|value| !value.is_empty())
+ };
+ let version = env!("CARGO_PKG_VERSION");
+ let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
+ let packaged = Path::new(&manifest_dir)
+ .join(".cargo_vcs_info.json")
+ .is_file();
+ let git_dirs = own_checkout_git_dirs(&manifest_dir);
+ if let Some((git_dir, common_dir)) = &git_dirs {
+ // Watch only paths that exist: cargo treats a missing watched path
+ // as changed and would rebuild on every invocation (shallow CI
+ // checkouts have no packed-refs; a worktree's HEAD lives in its own
+ // git dir).
+ for path in [
+ git_dir.join("HEAD"),
+ common_dir.join("refs"),
+ common_dir.join("packed-refs"),
+ ] {
+ if path.exists() {
+ println!("cargo:rerun-if-changed={}", path.display());
+ }
+ }
+ }
+ let release_tag = format!("v{version}");
+ let release_tag_at_head = git_dirs.is_some()
+ && git_output(&manifest_dir, &["tag", "--points-at", "HEAD"])
+ .is_some_and(|tags| tags.lines().any(|tag| tag.trim() == release_tag));
+ let tag_override = env_override(AGENT_IMAGE_TAG_ENV);
+ let tag = choose_agent_image_tag(&AgentImageBuild {
+ override_tag: tag_override.as_deref(),
+ packaged,
+ own_checkout: git_dirs.is_some(),
+ release_tag_at_head,
+ version,
+ })
+ .unwrap_or_else(|error| panic!("{error}"));
+ let repository =
+ choose_agent_image_repository(env_override(AGENT_IMAGE_REPOSITORY_ENV).as_deref())
+ .unwrap_or_else(|error| panic!("{error}"));
+ println!("cargo:rustc-env={AGENT_IMAGE_TAG_ENV}={tag}");
+ println!("cargo:rustc-env={AGENT_IMAGE_REPOSITORY_ENV}={repository}");
+}
+
+fn git_output(dir: &str, args: &[&str]) -> Option {
+ std::process::Command::new("git")
+ .current_dir(dir)
+ .args(args)
+ .output()
+ .ok()
+ .filter(|output| output.status.success())
+ .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
+}
+
+/// The git dir and common git dir of crosslink's own checkout, when the
+/// build runs inside one (not merely inside some other repository, as a
+/// vendored or packaged copy might be). Works in linked worktrees, where
+/// `.git` is a file.
+fn own_checkout_git_dirs(manifest_dir: &str) -> Option<(std::path::PathBuf, std::path::PathBuf)> {
+ let toplevel = git_output(manifest_dir, &["rev-parse", "--show-toplevel"])?;
+ let own = fs::canonicalize(Path::new(&toplevel).join("crosslink")).ok()?
+ == fs::canonicalize(manifest_dir).ok()?;
+ if !own {
+ return None;
+ }
+ // `--path-format=absolute` needs git 2.31; older gits (as in some cross
+ // images) print paths relative to the working directory instead.
+ let dirs = git_output(
+ manifest_dir,
+ &[
+ "rev-parse",
+ "--path-format=absolute",
+ "--git-dir",
+ "--git-common-dir",
+ ],
+ )
+ .or_else(|| {
+ git_output(
+ manifest_dir,
+ &["rev-parse", "--git-dir", "--git-common-dir"],
+ )
+ })?;
+ let mut lines = dirs.lines();
+ let resolve = |line: &str| {
+ let path = Path::new(line.trim());
+ if path.is_absolute() {
+ path.to_path_buf()
+ } else {
+ Path::new(manifest_dir).join(path)
+ }
+ };
+ let git_dir = resolve(lines.next()?);
+ let common_dir = resolve(lines.next()?);
+ Some((git_dir, common_dir))
+}
+
fn generate_commands_file(commands_dir: &Path) -> Result<(), Box> {
let mut cmd_entries: Vec<(String, String)> = Vec::new();
diff --git a/crosslink/resources/container/Dockerfile b/crosslink/resources/container/Dockerfile
index 4d3ff40f8..191d35df2 100644
--- a/crosslink/resources/container/Dockerfile
+++ b/crosslink/resources/container/Dockerfile
@@ -6,12 +6,23 @@
-FROM ubuntu:24.04
-
+# The base image is pinned by digest and the agent tools by version (gosu also
+# by checksum); bump them deliberately. Still floating: Ubuntu's apt packages,
+# the gh apt keyring, Codex's npm dependencies, and the Claude Code and uv
+# installer scripts (the installers fetch the pinned versions).
+FROM ubuntu:24.04@sha256:534baea6a22c03a63003dbc8dbe78fe34bc0d7e595d9a9dc9834884ff530eb55
+# A failed download in a piped install must fail the build.
+SHELL ["/bin/bash", "-o", "pipefail", "-c"]
ARG TARGETARCH=amd64
+ARG GH_VERSION=2.102.0
+ARG CODEX_VERSION=0.160.1
+ARG CLAUDE_CODE_VERSION=2.1.292
+ARG UV_VERSION=0.12.23
ARG GOSU_VERSION=1.17
+ARG GOSU_SHA256_AMD64=bbc4136d03ab138b1ad66fa4fc051bafc6cc7ffae632b069a53657279a450de3
+ARG GOSU_SHA256_ARM64=c3805a85d17f4454c23d7059bcb97e1ec1af272b90126e79ed002342de08389b
@@ -26,12 +37,12 @@ RUN mkdir -p -m 755 /etc/apt/keyrings \
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \
&& chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list \
- && apt-get update && apt-get install -y --no-install-recommends gh \
+ && apt-get update && apt-get install -y --no-install-recommends "gh=${GH_VERSION}" \
&& rm -rf /var/lib/apt/lists/*
-RUN npm install -g @openai/codex
+RUN npm install -g "@openai/codex@${CODEX_VERSION}"
RUN userdel -r ubuntu 2>/dev/null || true
@@ -42,19 +53,26 @@ USER agent
WORKDIR /home/agent
-SHELL ["/bin/bash", "-c"]
+SHELL ["/bin/bash", "-o", "pipefail", "-c"]
-RUN curl -fsSL https://claude.ai/install.sh | bash
+# The installer verifies the checksum of the Claude Code binary it downloads.
+RUN curl -fsSL https://claude.ai/install.sh | bash -s "${CLAUDE_CODE_VERSION}"
-RUN curl -LsSf https://astral.sh/uv/install.sh | sh
+RUN curl -LsSf "https://astral.sh/uv/${UV_VERSION}/install.sh" | sh
USER root
RUN curl -fsSL "https://github.com/tianon/gosu/releases/download/${GOSU_VERSION}/gosu-${TARGETARCH}" -o /usr/local/bin/gosu \
+ && case "${TARGETARCH}" in \
+ amd64) expected="${GOSU_SHA256_AMD64}" ;; \
+ arm64) expected="${GOSU_SHA256_ARM64}" ;; \
+ *) echo "no gosu checksum for ${TARGETARCH}" >&2; exit 1 ;; \
+ esac \
+ && echo "${expected} /usr/local/bin/gosu" | sha256sum -c - \
&& chmod +x /usr/local/bin/gosu
diff --git a/crosslink/src/build_support/agent_image.rs b/crosslink/src/build_support/agent_image.rs
new file mode 100644
index 000000000..c4ac8ceb5
--- /dev/null
+++ b/crosslink/src/build_support/agent_image.rs
@@ -0,0 +1,99 @@
+// How a build chooses its default agent image. Pure functions, no I/O:
+// `build.rs` includes this file to make the choice, and the kickoff tests
+// include it to table-test the rules. It is not a module of the crate.
+
+/// The published agent image repository, unless a build overrides it.
+const DEFAULT_AGENT_IMAGE_REPOSITORY: &str = "ghcr.io/corvidae-coding-projects/crosslink-agent";
+
+/// Tags a build default may never be: `latest` floats across releases, and
+/// `local` is what `crosslink container build` writes.
+const RESERVED_AGENT_IMAGE_TAGS: [&str; 2] = ["latest", "local"];
+
+/// An image tag per the OCI grammar `[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}`.
+fn validate_agent_image_tag(tag: &str) -> Result<(), String> {
+ let mut chars = tag.chars();
+ let first_ok = chars
+ .next()
+ .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_');
+ let rest_ok = chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'));
+ if !first_ok || !rest_ok || tag.len() > 128 {
+ return Err(format!(
+ "{tag:?} is not a valid image tag ([A-Za-z0-9_][A-Za-z0-9_.-]{{0,127}})"
+ ));
+ }
+ if RESERVED_AGENT_IMAGE_TAGS.contains(&tag) {
+ return Err(format!("{tag:?} cannot be a build's default image tag"));
+ }
+ Ok(())
+}
+
+/// An image repository: lowercase registry path components, an optional
+/// registry port, no tag or digest.
+fn validate_agent_image_repository(repository: &str) -> Result<(), String> {
+ let (host, path) = repository
+ .split_once('/')
+ .ok_or_else(|| format!("{repository:?} has no registry path"))?;
+ let host_ok = !host.is_empty()
+ && host
+ .chars()
+ .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '-' | ':'));
+ let path_ok = !path.is_empty()
+ && path.split('/').all(|part| {
+ !part.is_empty()
+ && part.chars().all(|c| {
+ c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-')
+ })
+ });
+ if host_ok && path_ok {
+ Ok(())
+ } else {
+ Err(format!(
+ "{repository:?} is not a valid image repository (lowercase registry/path, no tag)"
+ ))
+ }
+}
+
+/// What the build knows about where it is being built.
+struct AgentImageBuild<'a> {
+ /// `CROSSLINK_AGENT_IMAGE_TAG` at build time, if set and non-empty.
+ override_tag: Option<&'a str>,
+ /// The source is a published package (`cargo package` wrote
+ /// `.cargo_vcs_info.json`), as for `cargo install crosslink`.
+ packaged: bool,
+ /// The build runs inside crosslink's own git checkout.
+ own_checkout: bool,
+ /// The tag `v` points at that checkout's HEAD.
+ release_tag_at_head: bool,
+ version: &'a str,
+}
+
+/// The default agent image tag: an explicit override (which must be valid),
+/// then the version for published packages and for checkouts at their
+/// release tag, and `nightly` (the image built from `develop`) for every
+/// other build. A guess never yields a version: only positive evidence does.
+fn choose_agent_image_tag(build: &AgentImageBuild<'_>) -> Result {
+ if let Some(tag) = build.override_tag {
+ validate_agent_image_tag(tag)
+ .map_err(|error| format!("CROSSLINK_AGENT_IMAGE_TAG: {error}"))?;
+ return Ok(tag.to_string());
+ }
+ if build.packaged || (build.own_checkout && build.release_tag_at_head) {
+ validate_agent_image_tag(build.version)
+ .map_err(|error| format!("crate version as image tag: {error}"))?;
+ return Ok(build.version.to_string());
+ }
+ Ok("nightly".to_string())
+}
+
+/// The default agent image repository: an explicit, valid override (for
+/// forks that publish their own image) or the published one.
+fn choose_agent_image_repository(override_repository: Option<&str>) -> Result {
+ match override_repository {
+ Some(repository) => {
+ validate_agent_image_repository(repository)
+ .map_err(|error| format!("CROSSLINK_AGENT_IMAGE_REPOSITORY: {error}"))?;
+ Ok(repository.to_string())
+ }
+ None => Ok(DEFAULT_AGENT_IMAGE_REPOSITORY.to_string()),
+ }
+}
diff --git a/crosslink/src/commands/container.rs b/crosslink/src/commands/container.rs
index afd5043fd..259fa07f2 100644
--- a/crosslink/src/commands/container.rs
+++ b/crosslink/src/commands/container.rs
@@ -24,6 +24,7 @@ pub fn run(command: ContainerCommands) -> Result<()> {
prompt,
issue,
memory,
+ image,
} => {
let path = PathBuf::from(&worktree);
start(
@@ -32,6 +33,7 @@ pub fn run(command: ContainerCommands) -> Result<()> {
prompt.as_deref(),
issue,
memory.as_deref(),
+ image.as_deref(),
)
}
ContainerCommands::Ps => ps(),
@@ -379,34 +381,91 @@ pub(crate) fn github_login_preflight(
}
}
-/// Environment override for the image that hosts container account login.
-/// Forks publish the agent image under their own registry path; the login volume
-/// is image-independent, so any image carrying the provider CLI will do.
-const AUTH_IMAGE_ENV: &str = "CROSSLINK_CONTAINER_IMAGE";
+/// Whether a failed pull is the registry refusing the image (it answered that
+/// the image does not exist or may not be pulled). Only a refusal stops a
+/// launch that has a local copy of a floating tag: a withdrawn or hidden image
+/// must not outlive its withdrawal in caches. Network failures and registry
+/// outages (5xx, rate limits) and anything unrecognised are not refusals.
+/// The image reference is removed before matching so that words in it cannot
+/// match.
+pub(crate) fn is_registry_refusal(stderr: &str, image: &str) -> bool {
+ let lower = stderr.replace(image, "").to_ascii_lowercase();
+ [
+ "manifest unknown",
+ "manifest_unknown",
+ "name unknown",
+ "pull access denied",
+ "denied:",
+ "access denied",
+ "unauthorized",
+ "forbidden",
+ "repository does not exist",
+ ": not found",
+ ]
+ .iter()
+ .any(|needle| lower.contains(needle))
+}
-/// Pick the login image: an explicit `--image` wins, then the environment
-/// value, then the published default. Blank values count as unset.
-fn resolve_auth_image(explicit: Option<&str>, env_value: Option<&str>) -> String {
- let non_empty = |value: Option<&str>| {
- value
- .map(str::trim)
- .filter(|value| !value.is_empty())
- .map(String::from)
+fn image_present_locally(runtime: &str, image: &str) -> bool {
+ Command::new(runtime)
+ .args(["image", "inspect", image])
+ .stdout(std::process::Stdio::null())
+ .stderr(std::process::Stdio::null())
+ .status()
+ .is_ok_and(|status| status.success())
+}
+
+/// Make the image ready to launch. A floating tag (`:nightly`, `:latest`) is
+/// refreshed every time; a pinned tag or digest is pulled once, when it is not
+/// present locally. When the registry cannot be reached and a local copy of a
+/// floating tag exists, the launch warns and uses it; any other failure stops
+/// the launch with the runtime's reason.
+pub(crate) fn refresh_floating_image(runtime: &str, image: &str) -> Result<()> {
+ let floating = crate::commands::kickoff::is_floating_image(image);
+ if !floating && image_present_locally(runtime, image) {
+ return Ok(());
+ }
+ let pull = Command::new(runtime)
+ .args(["pull", "--quiet", image])
+ .output()
+ .with_context(|| format!("Failed to run {runtime} pull"))?;
+ if pull.status.success() {
+ return Ok(());
+ }
+ let stderr = String::from_utf8_lossy(&pull.stderr);
+ let reason = stderr
+ .lines()
+ .map(str::trim)
+ .find(|line| !line.is_empty())
+ .unwrap_or("no error output")
+ .to_string();
+ let refused = is_registry_refusal(&stderr, image);
+ if floating && !refused && image_present_locally(runtime, image) {
+ eprintln!(
+ "warning: could not refresh {image} ({reason}); using the local copy, which may be out of date"
+ );
+ return Ok(());
+ }
+ let advice = if refused {
+ "The registry refused the image (it may have been withdrawn, made private, or never published). Pass --image with another image, or build one locally (`just build-image` tags :local)."
+ } else {
+ "The registry could not be reached or did not answer (network, outage or rate limit). Retry, or pass --image with an image you have locally (`just build-image` tags :local)."
};
- non_empty(explicit)
- .or_else(|| non_empty(env_value))
- .unwrap_or_else(|| format!("{IMAGE_NAME}:{IMAGE_TAG}"))
+ bail!("could not pull {image}: {reason}\n{advice}")
}
-/// `resolve_auth_image` over the live `CROSSLINK_CONTAINER_IMAGE` value.
+/// Pick the image: an explicit `--image` wins, then `CROSSLINK_CONTAINER_IMAGE`,
+/// then this build's default (the image matching the CLI's own version).
+/// Blank values count as unset. See `kickoff::resolve_agent_image_from`.
+#[cfg(test)]
+fn resolve_auth_image(explicit: Option<&str>, env_value: Option<&str>) -> String {
+ crate::commands::kickoff::resolve_agent_image_from(explicit, env_value)
+ .map_or_else(|_| DEFAULT_IMAGE.to_string(), |(image, _)| image)
+}
+
+/// The image a container command uses, and where it came from.
fn auth_image(explicit: Option<&str>) -> Result {
- let from_env = std::env::var(AUTH_IMAGE_ENV).ok();
- let image = resolve_auth_image(explicit, from_env.as_deref());
- anyhow::ensure!(
- !image.starts_with('-'),
- "container image name cannot start with '-': {image}"
- );
- Ok(image)
+ crate::commands::kickoff::resolve_agent_image(explicit).map(|(image, _)| image)
}
fn run_auth_container(provider: &str, status: bool, image: Option<&str>) -> Result<()> {
@@ -416,7 +475,7 @@ fn run_auth_container(provider: &str, status: bool, image: Option<&str>) -> Resu
let parsed_provider = provider.parse::()?;
let volume = parsed_provider.volume();
let image = auth_image(image)?;
- let image_hint = if image == format!("{IMAGE_NAME}:{IMAGE_TAG}") {
+ let image_hint = if image == DEFAULT_IMAGE {
String::new()
} else {
format!(" --image {image}")
@@ -440,6 +499,7 @@ fn run_auth_container(provider: &str, status: bool, image: Option<&str>) -> Resu
if let Some(agent_provider) = parsed_provider.agent_provider_name() {
command.args(["-e", &format!("CROSSLINK_AGENT_PROVIDER={agent_provider}")]);
}
+ refresh_floating_image("docker", &image)?;
command.arg(&image);
command.args(parsed_provider.command(status));
if status {
@@ -529,9 +589,11 @@ pub(crate) fn github_login_args(
])
}
-const IMAGE_NAME: &str = "ghcr.io/corvidae-coding-projects/crosslink-agent";
+const IMAGE_NAME: &str = crate::commands::kickoff::AGENT_IMAGE_REPOSITORY;
-const IMAGE_TAG: &str = "latest";
+/// This build's default image: the published image matching the CLI's own
+/// version (`:` for releases, `:nightly` for development builds).
+const DEFAULT_IMAGE: &str = crate::commands::kickoff::DEFAULT_AGENT_IMAGE;
const BUILD_DEFAULT_TAG: &str = "local";
const CONTAINER_PREFIX: &str = "crosslink-task-";
@@ -553,22 +615,6 @@ fn find_crosslink_binary() -> Result {
std::env::current_exe().context("Could not determine crosslink binary path")
}
-fn file_hash(path: &Path) -> Result {
- use std::io::Read;
- let mut file = std::fs::File::open(path)?;
-
- let mut buf = vec![0u8; 65536];
- let n = file.read(&mut buf)?;
- buf.truncate(n);
-
- let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
- for &byte in &buf {
- hash ^= u64::from(byte);
- hash = hash.wrapping_mul(0x0100_0000_01b3);
- }
- Ok(format!("{hash:016x}"))
-}
-
fn resolve_repo_root() -> Result {
let output = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
@@ -645,37 +691,85 @@ fn compute_memory_limit(config_override: Option<&str>) -> String {
)
}
-fn get_image_hash() -> Option {
- let output = Command::new("docker")
+/// The image label that records which crosslink an agent image contains: the
+/// exact `crosslink --version` of its binary. CI and `container build` set it.
+/// Crosslink-specific so that labels inherited from a base image (Ubuntu sets
+/// `org.opencontainers.image.version`) are never mistaken for it.
+pub(crate) const IMAGE_VERSION_LABEL: &str = "dev.crosslink.version";
+
+/// This CLI's version as `crosslink --version` reports it (`+` for git
+/// builds).
+pub(crate) fn cli_version() -> &'static str {
+ option_env!("CROSSLINK_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"))
+}
+
+/// How the crosslink inside an image relates to this CLI's.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum ImageVersionMatch {
+ /// Same version and build.
+ Same,
+ /// Same release version, built from a different commit: the normal case
+ /// for a development build against `:nightly`.
+ SameReleaseDifferentBuild,
+ /// A different version: the container and the CLI write the same hub, and
+ /// their formats may disagree.
+ Different,
+}
+
+/// Compare two `crosslink --version` strings (`[+]`).
+pub(crate) fn compare_crosslink_versions(cli: &str, image: &str) -> ImageVersionMatch {
+ let core = |version: &str| {
+ version
+ .split_once('+')
+ .map_or(version, |(core, _)| core)
+ .to_string()
+ };
+ if cli == image {
+ ImageVersionMatch::Same
+ } else if core(cli) == core(image) {
+ ImageVersionMatch::SameReleaseDifferentBuild
+ } else {
+ ImageVersionMatch::Different
+ }
+}
+
+/// Compare the crosslink an image contains with this CLI's, using the image's
+/// version label, and say so when they differ. Nothing is printed when the
+/// image is not available locally yet or carries no label (images built before
+/// the label existed).
+pub(crate) fn check_image_version(runtime: &str, image: &str) -> Option {
+ let output = Command::new(runtime)
.args([
+ "image",
"inspect",
"--format",
- "{{index .Config.Labels \"crosslink-binary-hash\"}}",
- &format!("{IMAGE_NAME}:{IMAGE_TAG}"),
+ &format!("{{{{ index .Config.Labels \"{IMAGE_VERSION_LABEL}\" }}}}"),
+ image,
])
.output()
- .ok()?;
- if output.status.success() {
- let hash = String::from_utf8(output.stdout).ok()?.trim().to_string();
- if !hash.is_empty() && hash != "" {
- return Some(hash);
- }
- }
- None
-}
-
-fn check_staleness() {
- let Ok(binary_hash) = find_crosslink_binary().and_then(|p| file_hash(&p)) else {
- return;
- };
- if let Some(image_hash) = get_image_hash() {
- if image_hash != binary_hash {
- tracing::warn!(
- "container image {IMAGE_NAME}:{IMAGE_TAG} is stale relative to your installed crosslink binary. \
- Pull the latest published image (`docker pull {IMAGE_NAME}:{IMAGE_TAG}`) or rebuild locally (`just build-image` or `crosslink container build`)."
- );
- }
+ .ok()
+ .filter(|output| output.status.success())?;
+ let image_version = String::from_utf8_lossy(&output.stdout).trim().to_string();
+ if image_version.is_empty() || image_version == "" {
+ tracing::debug!(
+ "{image} carries no {IMAGE_VERSION_LABEL} label; skipping the version check"
+ );
+ return None;
+ }
+ let verdict = compare_crosslink_versions(cli_version(), &image_version);
+ match verdict {
+ ImageVersionMatch::Same => {}
+ ImageVersionMatch::SameReleaseDifferentBuild => tracing::info!(
+ "{image} contains crosslink {image_version}; this CLI is {}",
+ cli_version()
+ ),
+ ImageVersionMatch::Different => eprintln!(
+ "warning: {image} contains crosslink {image_version}, but this CLI is {}. Both write the same hub; \
+ use the image matching this build, or pass --image with one built from your code (`just build-image`).",
+ cli_version()
+ ),
}
+ Some(verdict)
}
struct BuildDirCleanup(PathBuf);
@@ -732,8 +826,6 @@ pub fn build(force: bool, tag: Option<&str>, dockerfile: Option<&str>) -> Result
std::fs::copy(&binary, build_path.join(&staged_binary))
.context("Failed to copy crosslink binary to build context")?;
- let binary_hash = file_hash(&binary).unwrap_or_else(|_| "unknown".to_string());
-
println!("Building container image: {image}");
let mut cmd = Command::new("docker");
@@ -741,7 +833,10 @@ pub fn build(force: bool, tag: Option<&str>, dockerfile: Option<&str>) -> Result
cmd.args(["--build-arg", &format!("TARGETARCH={docker_arch}")]);
cmd.args(["--label", LABEL_AGENT]);
- cmd.args(["--label", &format!("crosslink-binary-hash={binary_hash}")]);
+ cmd.args([
+ "--label",
+ &format!("{IMAGE_VERSION_LABEL}={}", cli_version()),
+ ]);
if force {
cmd.arg("--no-cache");
}
@@ -754,7 +849,7 @@ pub fn build(force: bool, tag: Option<&str>, dockerfile: Option<&str>) -> Result
}
println!("Image built successfully: {image}");
- println!("Binary hash: {binary_hash}");
+ println!("crosslink version: {}", cli_version());
Ok(())
}
@@ -764,12 +859,12 @@ pub fn start(
prompt_file: Option<&str>,
issue_id: Option,
memory: Option<&str>,
+ image: Option<&str>,
) -> Result<()> {
if !docker_available() {
bail!("Docker is not available. Install Docker and ensure the daemon is running.");
}
-
- check_staleness();
+ let (image, image_source) = crate::commands::kickoff::resolve_agent_image(image)?;
let worktree_abs = std::fs::canonicalize(worktree_path)
.with_context(|| format!("Worktree not found: {}", worktree_path.display()))?;
@@ -845,13 +940,15 @@ pub fn start(
let agent_id = format!("container--{worktree_slug}");
- let image = format!("{IMAGE_NAME}:{IMAGE_TAG}");
+ refresh_floating_image("docker", &image)?;
+ check_image_version("docker", &image);
println!("Starting task container: {container_name}");
println!(" Worktree: {}", worktree_abs.display());
println!(" Memory: {memory_limit}");
println!(" Agent: {agent_id}");
println!(" Provider: {}", resolved.provider);
+ println!(" Image: {image} (from {image_source})");
let mut cmd = Command::new("docker");
cmd.args(["run", "-d"]);
@@ -1164,7 +1261,7 @@ mod tests {
#[test]
fn auth_image_resolution_prefers_flag_then_env_then_default() {
- let default = format!("{IMAGE_NAME}:{IMAGE_TAG}");
+ let default = DEFAULT_IMAGE.to_string();
assert_eq!(resolve_auth_image(None, None), default);
assert_eq!(resolve_auth_image(Some(" "), Some("")), default);
assert_eq!(
@@ -1180,29 +1277,12 @@ mod tests {
);
}
- #[test]
- fn image_name_is_ghcr_namespaced() {
- assert_eq!(
- IMAGE_NAME,
- "ghcr.io/corvidae-coding-projects/crosslink-agent"
- );
- assert_eq!(
- IMAGE_NAME,
- crate::commands::kickoff::DEFAULT_AGENT_IMAGE
- .rsplit_once(':')
- .map_or(IMAGE_NAME, |(name, _)| name),
- "container.rs IMAGE_NAME diverged from kickoff DEFAULT_AGENT_IMAGE — \
- re-opens the GH#576 compose-failure between `crosslink container build` \
- and `crosslink kickoff run --container …`"
- );
- }
-
#[test]
fn build_default_tag_is_distinct_from_lookup_tag() {
assert_eq!(BUILD_DEFAULT_TAG, "local");
assert_ne!(
- BUILD_DEFAULT_TAG, IMAGE_TAG,
- "BUILD_DEFAULT_TAG and IMAGE_TAG must differ — otherwise `crosslink container build` \
+ BUILD_DEFAULT_TAG, crate::commands::kickoff::AGENT_IMAGE_TAG,
+ "BUILD_DEFAULT_TAG and crate::commands::kickoff::AGENT_IMAGE_TAG must differ — otherwise `crosslink container build` \
clobbers the published `:latest` users pulled from GHCR"
);
}
@@ -1346,6 +1426,221 @@ mod tests {
assert!(podman.contains("create `vol` with podman"), "{podman}");
}
+ #[test]
+ fn image_versions_compare_on_release_and_build() {
+ use ImageVersionMatch::{Different, Same, SameReleaseDifferentBuild};
+ let cases = [
+ ("0.10.0", "0.10.0", Same),
+ ("0.10.0+abc1234", "0.10.0+abc1234", Same),
+ (
+ "0.10.0+abc1234",
+ "0.10.0+def5678",
+ SameReleaseDifferentBuild,
+ ),
+ (
+ "0.10.0+abc1234-dirty",
+ "0.10.0+abc1234",
+ SameReleaseDifferentBuild,
+ ),
+ ("0.10.0", "0.10.0+def5678", SameReleaseDifferentBuild),
+ ("0.10.0", "0.9.0", Different),
+ ("0.10.0-beta.1+abc", "0.10.0+abc", Different),
+ ];
+ for (cli, image, expected) in cases {
+ assert_eq!(
+ compare_crosslink_versions(cli, image),
+ expected,
+ "{cli} vs {image}"
+ );
+ }
+ }
+
+ /// A stand-in runtime whose `image inspect` prints the given label value.
+ /// Wait until a freshly written fake runtime script can be executed. On
+ /// Linux, another test thread forking a process while the script was open
+ /// for writing keeps a write handle alive in that child until it execs,
+ /// and executing the script meanwhile fails with ETXTBSY.
+ #[cfg(unix)]
+ fn wait_until_spawnable(path: &Path) {
+ for _ in 0..500 {
+ match Command::new(path)
+ .arg("--crosslink-test-probe")
+ .stdout(std::process::Stdio::null())
+ .stderr(std::process::Stdio::null())
+ .status()
+ {
+ Err(error) if error.raw_os_error() == Some(26) => {
+ std::thread::sleep(std::time::Duration::from_millis(10));
+ }
+ _ => return,
+ }
+ }
+ }
+
+ #[cfg(unix)]
+ fn fake_inspect_runtime(dir: &Path, label: Option<&str>) -> String {
+ use std::os::unix::fs::PermissionsExt;
+ let path = dir.join(format!(
+ "inspect-{}",
+ label.unwrap_or("missing").replace(['+', '.'], "_")
+ ));
+ let body = label.map_or_else(
+ || "exit 1".to_string(),
+ |value| format!("printf '%s\\n' '{value}'"),
+ );
+ let log = dir.join("inspect.log");
+ std::fs::write(
+ &path,
+ format!(
+ "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\n{body}\n",
+ log.display()
+ ),
+ )
+ .unwrap();
+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
+ wait_until_spawnable(&path);
+ path.to_string_lossy().into_owned()
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn launch_reads_the_images_version_label() {
+ let dir = tempfile::tempdir().unwrap();
+ let image = format!("{IMAGE_NAME}:nightly");
+ assert_eq!(
+ check_image_version(
+ &fake_inspect_runtime(dir.path(), Some(cli_version())),
+ &image
+ ),
+ Some(ImageVersionMatch::Same)
+ );
+ assert_eq!(
+ check_image_version(&fake_inspect_runtime(dir.path(), Some("0.0.1")), &image),
+ Some(ImageVersionMatch::Different)
+ );
+ // No label (older images) or no local image: nothing to compare.
+ assert_eq!(
+ check_image_version(
+ &fake_inspect_runtime(dir.path(), Some("")),
+ &image
+ ),
+ None
+ );
+ assert_eq!(
+ check_image_version(&fake_inspect_runtime(dir.path(), None), &image),
+ None
+ );
+ let calls = std::fs::read_to_string(dir.path().join("inspect.log")).unwrap();
+ let expected = format!(
+ "image inspect --format {{{{ index .Config.Labels \"{IMAGE_VERSION_LABEL}\" }}}} {image}"
+ );
+ assert!(calls.contains(&expected), "{calls}");
+ assert_eq!(IMAGE_VERSION_LABEL, "dev.crosslink.version");
+ }
+
+ #[test]
+ fn only_registry_refusals_stop_a_cached_launch() {
+ let image = "ghcr.io/geoff/crosslink-agent:nightly";
+ for refused in [
+ "Error response from daemon: manifest unknown",
+ "Error response from daemon: pull access denied for ghcr.io/x/y, repository does not exist",
+ "Error: unauthorized: authentication required",
+ "Error response from daemon: denied: requested access to the resource is denied",
+ "Error response from daemon: ghcr.io/x/y:nightly: not found",
+ ] {
+ assert!(is_registry_refusal(refused, image), "{refused}");
+ }
+ for not_refused in [
+ "Error response from daemon: Get \"https://ghcr.io/v2/\": dial tcp: lookup ghcr.io: no such host",
+ "Error response from daemon: Get \"https://ghcr.io/v2/\": context deadline exceeded",
+ "dial tcp 140.82.112.34:443: connect: no route to host",
+ "lookup ghcr.io on 127.0.0.53:53: server misbehaving",
+ "received unexpected HTTP status: 503 Service Unavailable",
+ "toomanyrequests: retry later",
+ "something nobody has seen before",
+ // Words inside the image reference itself must not match.
+ "Error: failed to pull ghcr.io/geoff/crosslink-agent:nightly: EOF",
+ ] {
+ assert!(!is_registry_refusal(not_refused, image), "{not_refused}");
+ }
+ }
+
+ /// A stand-in runtime for image preparation: `pull` prints `pull_stderr`
+ /// and exits `pull_rc`, `image inspect` exits `inspect_rc`, and every call
+ /// is appended to `calls.log`.
+ #[cfg(unix)]
+ fn fake_pull_runtime(
+ dir: &Path,
+ name: &str,
+ pull_rc: i32,
+ pull_stderr: &str,
+ inspect_rc: i32,
+ ) -> String {
+ use std::os::unix::fs::PermissionsExt;
+ let path = dir.join(name);
+ let log = dir.join("calls.log");
+ std::fs::write(
+ &path,
+ format!(
+ "#!/bin/sh\necho \"$*\" >> '{}'\ncase \"$1\" in\n pull) echo '{pull_stderr}' >&2; exit {pull_rc} ;;\n image) exit {inspect_rc} ;;\nesac\nexit 1\n",
+ log.display()
+ ),
+ )
+ .unwrap();
+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
+ wait_until_spawnable(&path);
+ path.to_string_lossy().into_owned()
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn images_are_prepared_with_a_network_only_fallback() {
+ let dir = tempfile::tempdir().unwrap();
+ let nightly = format!("{IMAGE_NAME}:nightly");
+ let pinned = format!("{IMAGE_NAME}:0.10.0");
+ let offline = "dial tcp: lookup ghcr.io: no such host";
+ let withdrawn = "Error response from daemon: manifest unknown";
+ let prepare = |name: &str, pull_rc, stderr: &str, inspect_rc, image: &str| {
+ refresh_floating_image(
+ &fake_pull_runtime(dir.path(), name, pull_rc, stderr, inspect_rc),
+ image,
+ )
+ };
+ // Floating tag, registry reachable: refreshed.
+ prepare("a", 0, "", 1, &nightly).unwrap();
+ // Floating tag, network down, local copy present: the launch continues.
+ prepare("b", 1, offline, 0, &nightly).unwrap();
+ // Floating tag withdrawn by the registry: a cached copy must not be used.
+ let error = prepare("c", 1, withdrawn, 0, &nightly)
+ .unwrap_err()
+ .to_string();
+ assert!(
+ error.contains("manifest unknown") && error.contains("refused"),
+ "{error}"
+ );
+ // Network down and nothing local: the error carries the reason.
+ let error = prepare("d", 1, offline, 1, &nightly)
+ .unwrap_err()
+ .to_string();
+ assert!(
+ error.contains("no such host") && error.contains("network"),
+ "{error}"
+ );
+ // Pinned tag present locally: no pull at all.
+ let log = dir.path().join("calls.log");
+ let _ = std::fs::remove_file(&log);
+ prepare("e", 1, withdrawn, 0, &pinned).unwrap();
+ let calls = std::fs::read_to_string(&log).unwrap();
+ assert!(!calls.contains("pull"), "{calls}");
+ // Pinned tag missing locally: pulled once, and a refusal is reported.
+ let error = prepare("f", 1, withdrawn, 1, &pinned)
+ .unwrap_err()
+ .to_string();
+ assert!(error.contains("refused"), "{error}");
+ let calls = std::fs::read_to_string(&log).unwrap();
+ assert!(calls.contains(&format!("pull --quiet {pinned}")), "{calls}");
+ }
+
/// A stand-in container runtime: `info`, `volume inspect` and `run` exit
/// with the given codes, so each preflight branch can be reached without
/// a real daemon.
@@ -1361,6 +1656,7 @@ mod tests {
)
.unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
+ wait_until_spawnable(&path);
path.to_string_lossy().into_owned()
}
diff --git a/crosslink/src/commands/kickoff/launch.rs b/crosslink/src/commands/kickoff/launch.rs
index 1f7e188fc..41dac16eb 100644
--- a/crosslink/src/commands/kickoff/launch.rs
+++ b/crosslink/src/commands/kickoff/launch.rs
@@ -968,6 +968,8 @@ pub(super) fn launch_container(
Path::new("/workspaces/repo"),
policy,
)?;
+ crate::commands::container::refresh_floating_image(runtime_cmd, image)?;
+ crate::commands::container::check_image_version(runtime_cmd, image);
args.push(image.to_string());
args.push("bash".to_string());
args.push("-c".to_string());
@@ -1092,12 +1094,22 @@ fn format_container_launch_error(runtime_cmd: &str, image: &str, stderr: &str) -
.any(|needle| lowered.contains(needle));
if pull_failure {
+ let default_note = if image == super::types::DEFAULT_AGENT_IMAGE {
+ format!(
+ "\n This is the image matching this crosslink build (tag `{}`). A release's \
+ image is published when its `v*` tag is pushed; development builds use `:nightly`.",
+ super::types::AGENT_IMAGE_TAG
+ )
+ } else {
+ String::new()
+ };
format!(
"{runtime_cmd} container launch failed: {trimmed}\n\n\
- Hint: the image `{image}` could not be pulled. Either:\n \
+ Hint: the image `{image}` could not be pulled.{default_note} Either:\n \
* Build it locally: just build-image (tags as :local)\n \
* Or pick a published tag from {AGENT_IMAGE_PACKAGE_URL}\n \
- and pass it via `--image ghcr.io/corvidae-coding-projects/crosslink-agent:`."
+ and pass it via `--image {}:`.",
+ super::types::AGENT_IMAGE_REPOSITORY
)
} else {
format!("{runtime_cmd} container launch failed: {trimmed}")
@@ -1197,6 +1209,29 @@ mod tests {
assert!(msg.contains("just build-image"));
}
+ #[test]
+ fn pull_failure_on_the_default_image_explains_the_build_matched_tag() {
+ let msg = format_container_launch_error(
+ "docker",
+ crate::commands::kickoff::DEFAULT_AGENT_IMAGE,
+ "Error response from daemon: manifest unknown",
+ );
+ assert!(
+ msg.contains(crate::commands::kickoff::DEFAULT_AGENT_IMAGE),
+ "{msg}"
+ );
+ assert!(msg.contains("matching this crosslink build"), "{msg}");
+ let custom = format_container_launch_error(
+ "docker",
+ "example/agent:1",
+ "Error response from daemon: manifest unknown",
+ );
+ assert!(
+ !custom.contains("matching this crosslink build"),
+ "{custom}"
+ );
+ }
+
#[test]
fn pull_failure_no_such_image_yields_hint() {
let stderr =
diff --git a/crosslink/src/commands/kickoff/mod.rs b/crosslink/src/commands/kickoff/mod.rs
index 82d5c04d7..ea076a3df 100644
--- a/crosslink/src/commands/kickoff/mod.rs
+++ b/crosslink/src/commands/kickoff/mod.rs
@@ -14,11 +14,14 @@ mod wizard;
mod tests;
pub use types::{
- ContainerMode, KickoffOpts, KickoffReport, PlanOpts, ReportFormat, VerifyLevel,
+ agent_image_for, is_floating_image, resolve_agent_image, ContainerMode, KickoffOpts,
+ KickoffReport, PlanOpts, ReportFormat, VerifyLevel, AGENT_IMAGE_REPOSITORY,
DEFAULT_AGENT_IMAGE, EFFORT_LEVELS,
};
pub use types::{parse_container_mode, parse_duration, parse_verify_level};
+#[cfg(test)]
+pub use types::{resolve_agent_image_from, AGENT_IMAGE_TAG};
pub use cleanup::cleanup;
pub use graph::graph;
@@ -76,6 +79,10 @@ pub fn dispatch(
None
};
let parsed_container = parse_container_mode(&container)?;
+ let (image, image_source) = agent_image_for(&parsed_container, image.as_deref())?;
+ if parsed_container != ContainerMode::None && !quiet {
+ println!("Agent image: {image} (from {image_source})");
+ }
let parsed_timeout = parse_duration(&timeout)?;
let agent = crate::agents::resolve_agent(crosslink_dir)?;
let policy = resolve_execution_policy(
@@ -309,13 +316,14 @@ fn dispatch_launch(
(parsed_container != ContainerMode::None)
.then_some(crate::agents::SandboxPosture::ExternalIsolation),
)?;
+ let (agent_image, _) = agent_image_for(&parsed_container, None)?;
let opts = KickoffOpts {
description: &description,
issue,
container: parsed_container,
verify: parse_verify_level(verify)?,
model,
- image: types::DEFAULT_AGENT_IMAGE,
+ image: &agent_image,
timeout: parsed_timeout,
dry_run,
branch: None,
@@ -414,13 +422,14 @@ fn dispatch_launch(
(parsed_container != ContainerMode::None)
.then_some(crate::agents::SandboxPosture::ExternalIsolation),
)?;
+ let (agent_image, _) = agent_image_for(&parsed_container, None)?;
let opts = KickoffOpts {
description: &description,
issue: config.issue,
container: parsed_container,
verify: parse_verify_level(&config.verify)?,
model: &config.model,
- image: types::DEFAULT_AGENT_IMAGE,
+ image: &agent_image,
timeout: parsed_timeout,
dry_run: false,
branch: None,
diff --git a/crosslink/src/commands/kickoff/tests.rs b/crosslink/src/commands/kickoff/tests.rs
index edab7180f..9239fc93e 100644
--- a/crosslink/src/commands/kickoff/tests.rs
+++ b/crosslink/src/commands/kickoff/tests.rs
@@ -3392,3 +3392,177 @@ fn test_resolve_kickoff_template_none_without_flag_or_config() {
None
);
}
+
+/// The build script's image rules (`src/build_support/agent_image.rs`),
+/// compiled here so they can be tested directly.
+#[allow(dead_code)]
+mod agent_image_rules {
+ include!("../../build_support/agent_image.rs");
+
+ #[test]
+ fn this_builds_default_image_follows_the_rules() {
+ use super::{AGENT_IMAGE_REPOSITORY, AGENT_IMAGE_TAG, DEFAULT_AGENT_IMAGE};
+ assert_eq!(
+ DEFAULT_AGENT_IMAGE,
+ format!("{AGENT_IMAGE_REPOSITORY}:{AGENT_IMAGE_TAG}")
+ );
+ validate_agent_image_tag(AGENT_IMAGE_TAG).unwrap();
+ validate_agent_image_repository(AGENT_IMAGE_REPOSITORY).unwrap();
+ }
+
+ fn build(
+ override_tag: Option<&'static str>,
+ packaged: bool,
+ own_checkout: bool,
+ release_tag_at_head: bool,
+ ) -> Result {
+ choose_agent_image_tag(&AgentImageBuild {
+ override_tag,
+ packaged,
+ own_checkout,
+ release_tag_at_head,
+ version: "0.10.0",
+ })
+ }
+
+ #[test]
+ fn only_positive_evidence_yields_the_release_tag() {
+ // (override, packaged, own checkout, v at HEAD) -> tag
+ let cases: [(Option<&'static str>, bool, bool, bool, &str); 8] = [
+ (None, true, false, false, "0.10.0"), // cargo install crosslink
+ (None, false, true, true, "0.10.0"), // checkout at the release tag
+ (None, false, true, false, "nightly"), // develop or a feature branch
+ (None, false, false, false, "nightly"), // no git, not a package: a guess, not evidence
+ (None, false, false, true, "nightly"), // a tag in some other repository
+ (None, true, true, false, "0.10.0"), // a package is a release wherever it sits
+ (Some("0.10.1"), false, true, false, "0.10.1"), // release CI override
+ (Some("nightly"), true, false, false, "nightly"), // override beats evidence
+ ];
+ for (override_tag, packaged, own, at_tag, expected) in cases {
+ assert_eq!(
+ build(override_tag, packaged, own, at_tag).unwrap(),
+ expected,
+ "override={override_tag:?} packaged={packaged} own={own} at_tag={at_tag}"
+ );
+ }
+ }
+
+ #[test]
+ fn an_invalid_override_is_an_error_not_a_fallback() {
+ for bad in [
+ "latest",
+ "local",
+ ".hidden",
+ "-x",
+ "1.0.0+build",
+ "has space",
+ "",
+ ] {
+ let error = build(Some(bad), false, true, false).unwrap_err();
+ assert!(
+ error.contains("CROSSLINK_AGENT_IMAGE_TAG"),
+ "{bad:?}: {error}"
+ );
+ }
+ let too_long = "a".repeat(129);
+ assert!(validate_agent_image_tag(&too_long).is_err());
+ for good in [
+ "0.10.0",
+ "0.10.0-beta.1",
+ "nightly",
+ "_x",
+ "a".repeat(128).as_str(),
+ ] {
+ validate_agent_image_tag(good).unwrap();
+ }
+ }
+
+ #[test]
+ fn repository_overrides_must_be_plain_lowercase_repositories() {
+ assert_eq!(
+ choose_agent_image_repository(None).unwrap(),
+ DEFAULT_AGENT_IMAGE_REPOSITORY
+ );
+ for good in [
+ "ghcr.io/example/crosslink-agent",
+ "localhost:5000/crosslink-agent",
+ "registry.example.com/team/sub/agent_image",
+ ] {
+ assert_eq!(choose_agent_image_repository(Some(good)).unwrap(), good);
+ }
+ for bad in [
+ "ghcr.io/Example/agent",
+ "ghcr.io/example/agent:tag",
+ "ghcr.io/example/agent@sha256:abc",
+ "crosslink-agent",
+ "ghcr.io//agent",
+ "ghcr.io/example/agent/",
+ ] {
+ assert!(choose_agent_image_repository(Some(bad)).is_err(), "{bad}");
+ }
+ }
+}
+
+#[test]
+fn floating_tags_are_refreshed_and_pinned_ones_are_not() {
+ let repo = AGENT_IMAGE_REPOSITORY;
+ for floating in [
+ format!("{repo}:nightly"),
+ format!("{repo}:latest"),
+ repo.to_string(),
+ "localhost:5000/crosslink-agent".to_string(),
+ ] {
+ assert!(is_floating_image(&floating), "{floating}");
+ }
+ for pinned in [
+ format!("{repo}:0.10.0"),
+ format!("{repo}:nightly-388bed8"),
+ format!("{repo}@sha256:{}", "a".repeat(64)),
+ "localhost:5000/crosslink-agent:local".to_string(),
+ ] {
+ assert!(!is_floating_image(&pinned), "{pinned}");
+ }
+}
+
+#[test]
+fn one_resolver_picks_the_agent_image_for_every_command() {
+ let (image, source) =
+ resolve_agent_image_from(Some("example/agent:1"), Some("env/agent:2")).unwrap();
+ assert_eq!(
+ (image.as_str(), source),
+ ("example/agent:1", AgentImageSource::Flag)
+ );
+ let (image, source) = resolve_agent_image_from(Some(" "), Some("env/agent:2")).unwrap();
+ assert_eq!(
+ (image.as_str(), source),
+ ("env/agent:2", AgentImageSource::Environment)
+ );
+ let (image, source) = resolve_agent_image_from(None, Some("")).unwrap();
+ assert_eq!(
+ (image.as_str(), source),
+ (DEFAULT_AGENT_IMAGE, AgentImageSource::BuildDefault)
+ );
+ assert!(resolve_agent_image_from(Some("--privileged"), None).is_err());
+ assert!(resolve_agent_image_from(None, Some("-v/:/host")).is_err());
+ assert_eq!(
+ AgentImageSource::Environment.to_string(),
+ "CROSSLINK_CONTAINER_IMAGE"
+ );
+}
+
+#[test]
+fn local_launches_use_the_build_default_without_reading_image_settings() {
+ let (image, source) = agent_image_for(&ContainerMode::None, Some("example/agent:1")).unwrap();
+ assert_eq!(
+ (image.as_str(), source),
+ (DEFAULT_AGENT_IMAGE, AgentImageSource::BuildDefault)
+ );
+ // A malformed explicit image cannot fail a local launch.
+ assert!(agent_image_for(&ContainerMode::None, Some("--privileged")).is_ok());
+ assert!(agent_image_for(&ContainerMode::Docker, Some("--privileged")).is_err());
+ let (image, source) = agent_image_for(&ContainerMode::Docker, Some("example/agent:1")).unwrap();
+ assert_eq!(
+ (image.as_str(), source),
+ ("example/agent:1", AgentImageSource::Flag)
+ );
+}
diff --git a/crosslink/src/commands/kickoff/types.rs b/crosslink/src/commands/kickoff/types.rs
index 20e0a93c9..21a7c728d 100644
--- a/crosslink/src/commands/kickoff/types.rs
+++ b/crosslink/src/commands/kickoff/types.rs
@@ -3,7 +3,120 @@ use serde::{Deserialize, Serialize};
use std::path::Path;
use std::time::Duration;
-pub const DEFAULT_AGENT_IMAGE: &str = "ghcr.io/corvidae-coding-projects/crosslink-agent:latest";
+/// The agent image repository this build defaults to: the published one,
+/// unless the build set `CROSSLINK_AGENT_IMAGE_REPOSITORY` (forks that publish
+/// their own image). Chosen in `build.rs`.
+pub const AGENT_IMAGE_REPOSITORY: &str = env!("CROSSLINK_AGENT_IMAGE_REPOSITORY");
+
+/// The tag this build defaults to, chosen in `build.rs` from positive
+/// evidence only: the release version for published packages and for
+/// checkouts at their `v` tag, `nightly` (the image built from
+/// `develop`) for every other build, or an explicit build-time
+/// `CROSSLINK_AGENT_IMAGE_TAG`. A release build therefore runs the crosslink
+/// it was released with inside the container; a development build runs
+/// develop's crosslink, which can differ from a feature branch's code (build
+/// and pass your own image when changing hub or readiness code).
+pub const AGENT_IMAGE_TAG: &str = env!("CROSSLINK_AGENT_IMAGE_TAG");
+
+/// The image kickoff, swarm, sentinel and `container` commands use unless an
+/// image is given explicitly.
+pub const DEFAULT_AGENT_IMAGE: &str = concat!(
+ env!("CROSSLINK_AGENT_IMAGE_REPOSITORY"),
+ ":",
+ env!("CROSSLINK_AGENT_IMAGE_TAG")
+);
+
+/// Environment override for the agent image, honoured by every container
+/// command. Forks publish the agent image under their own registry path.
+pub const AGENT_IMAGE_ENV: &str = "CROSSLINK_CONTAINER_IMAGE";
+
+/// Where the agent image a command uses came from.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum AgentImageSource {
+ Flag,
+ Environment,
+ BuildDefault,
+}
+
+impl std::fmt::Display for AgentImageSource {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(match self {
+ Self::Flag => "--image",
+ Self::Environment => AGENT_IMAGE_ENV,
+ Self::BuildDefault => "this build's default",
+ })
+ }
+}
+
+/// The agent image every container command uses: an explicit `--image` wins,
+/// then `CROSSLINK_CONTAINER_IMAGE`, then this build's default. Blank values
+/// count as unset.
+pub fn resolve_agent_image_from(
+ explicit: Option<&str>,
+ env_value: Option<&str>,
+) -> Result<(String, AgentImageSource)> {
+ let non_empty = |value: Option<&str>| {
+ value
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .map(String::from)
+ };
+ let (image, source) = non_empty(explicit)
+ .map(|image| (image, AgentImageSource::Flag))
+ .or_else(|| non_empty(env_value).map(|image| (image, AgentImageSource::Environment)))
+ .unwrap_or_else(|| {
+ (
+ DEFAULT_AGENT_IMAGE.to_string(),
+ AgentImageSource::BuildDefault,
+ )
+ });
+ if image.starts_with('-') {
+ bail!("container image name cannot start with '-': {image}");
+ }
+ Ok((image, source))
+}
+
+/// The agent image for a launch in `mode`: resolved as for every container
+/// command when a container is used, and this build's default otherwise (a
+/// local launch never reads the image settings, so they cannot fail it).
+pub fn agent_image_for(
+ mode: &ContainerMode,
+ explicit: Option<&str>,
+) -> Result<(String, AgentImageSource)> {
+ if *mode == ContainerMode::None {
+ if explicit.is_some_and(|image| !image.trim().is_empty()) {
+ eprintln!("warning: --image is ignored without --container docker|podman");
+ }
+ return Ok((
+ DEFAULT_AGENT_IMAGE.to_string(),
+ AgentImageSource::BuildDefault,
+ ));
+ }
+ resolve_agent_image(explicit)
+}
+
+/// `resolve_agent_image_from` over the live `CROSSLINK_CONTAINER_IMAGE`.
+pub fn resolve_agent_image(explicit: Option<&str>) -> Result<(String, AgentImageSource)> {
+ resolve_agent_image_from(explicit, std::env::var(AGENT_IMAGE_ENV).ok().as_deref())
+}
+
+/// Tags that move to a new build without a version change. A local copy of
+/// one of these is re-pulled on every launch, so it cannot go stale.
+const FLOATING_IMAGE_TAGS: [&str; 2] = ["nightly", "latest"];
+
+/// Whether an image reference names a floating tag (`:nightly`, `:latest`,
+/// or no tag at all, which means `:latest`) that should be refreshed before a
+/// launch. Pinned tags and digests are not.
+pub fn is_floating_image(image: &str) -> bool {
+ if image.contains('@') {
+ return false;
+ }
+ let tag = image
+ .rsplit_once(':')
+ .filter(|(_, tag)| !tag.contains('/'))
+ .map_or("latest", |(_, tag)| tag);
+ FLOATING_IMAGE_TAGS.contains(&tag)
+}
pub const EFFORT_LEVELS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"];
diff --git a/crosslink/src/commands/sentinel/engine.rs b/crosslink/src/commands/sentinel/engine.rs
index e20d24da1..04f9ef7c4 100644
--- a/crosslink/src/commands/sentinel/engine.rs
+++ b/crosslink/src/commands/sentinel/engine.rs
@@ -446,13 +446,14 @@ fn spawn_agent(
None,
)?;
+ let (agent_image, _) = crate::commands::kickoff::agent_image_for(&ContainerMode::None, None)?;
let opts = KickoffOpts {
description: &scoped_description,
issue: Some(issue_id),
container: ContainerMode::None,
verify: scope.verify.clone(),
model: &scope.model,
- image: crate::commands::kickoff::DEFAULT_AGENT_IMAGE,
+ image: &agent_image,
timeout: scope.timeout,
dry_run: false,
branch: None,
diff --git a/crosslink/src/commands/swarm/lifecycle.rs b/crosslink/src/commands/swarm/lifecycle.rs
index 5fe21abd7..3ecaa3519 100644
--- a/crosslink/src/commands/swarm/lifecycle.rs
+++ b/crosslink/src/commands/swarm/lifecycle.rs
@@ -700,13 +700,14 @@ pub fn launch(crosslink_dir: &Path, db: &Database, phase_slug: &str, quiet: bool
timeout,
None,
)?;
+ let (agent_image, _) = kickoff::agent_image_for(&ContainerMode::None, None)?;
let opts = KickoffOpts {
description: &description,
issue: issue_id,
container: ContainerMode::None,
verify: VerifyLevel::Local,
model: &dials.model,
- image: kickoff::DEFAULT_AGENT_IMAGE,
+ image: &agent_image,
timeout,
dry_run: false,
branch: branch.as_deref(),
diff --git a/crosslink/src/main.rs b/crosslink/src/main.rs
index 5f9478853..a65c5437f 100644
--- a/crosslink/src/main.rs
+++ b/crosslink/src/main.rs
@@ -859,6 +859,11 @@ enum ContainerCommands {
#[arg(long)]
memory: Option,
+
+ /// Agent image (default: env `CROSSLINK_CONTAINER_IMAGE`, then the
+ /// published image matching this crosslink build)
+ #[arg(long)]
+ image: Option,
},
Ps,
@@ -1435,11 +1440,11 @@ enum KickoffCommands {
#[arg(long, default_value = "standard")]
model: String,
- #[arg(
- long,
- default_value = "ghcr.io/corvidae-coding-projects/crosslink-agent:latest"
- )]
- image: String,
+ /// Agent image (default: env `CROSSLINK_CONTAINER_IMAGE`, then the
+ /// published image matching this crosslink build — `:` for
+ /// releases, `:nightly` for development builds)
+ #[arg(long)]
+ image: Option,
#[arg(long, default_value = "1h")]
timeout: String,
diff --git a/docs_src/guides/container-agents.qmd b/docs_src/guides/container-agents.qmd
index d868af6cf..2ac0c7550 100644
--- a/docs_src/guides/container-agents.qmd
+++ b/docs_src/guides/container-agents.qmd
@@ -74,12 +74,35 @@ The default image used by `--container docker|podman` is published to GitHub Con
[`ghcr.io/corvidae-coding-projects/crosslink-agent`](https://github.com/Corvidae-Coding-Projects/crosslink/pkgs/container/crosslink-agent),
built for both `linux/amd64` and `linux/arm64`. Published tags:
-- `:latest` — the most recent tagged release
-- `:` (e.g. `:0.5.2`) — pinned release
+- `:` (e.g. `:0.10.0`) — the image built for that release
+- `:latest` — the most recent stable (non-prerelease) release; it moves only after
+ that release's image has passed an anonymous pull and smoke test
- `:nightly` — floating tag tracking `develop`
-- `:nightly-` — immutable nightly snapshots
-
-The first `--container docker` launch will pull the image automatically; subsequent launches reuse the local copy. Pin `--image ghcr.io/corvidae-coding-projects/crosslink-agent:` for reproducible runs.
+- `:nightly-` — the nightly built from that `develop` commit
+
+By default, crosslink uses the image that matches its own build. A released
+crosslink (installed from crates.io, or built from a checkout at its
+`v` tag) uses `:`, so the crosslink inside the container is
+the one it was released with. Every other build, such as `develop` or a feature
+branch, uses `:nightly`, the image built from the tip of `develop`: its
+crosslink can differ from your branch's code, so when you change hub or
+readiness code, build your own image (`just build-image`) and pass
+`--image ghcr.io/corvidae-coding-projects/crosslink-agent:local`. Builds from
+`cargo install --git` cannot see their release tag and also use `:nightly`;
+pass `--image` with the version you installed. Release CI sets the tag
+explicitly with `CROSSLINK_AGENT_IMAGE_TAG`, and fork builds can point at their
+own registry with `CROSSLINK_AGENT_IMAGE_REPOSITORY`.
+
+`:nightly` and `:latest` are refreshed before every launch. If the registry
+cannot be reached and a local copy exists, crosslink warns and uses it; pinned
+tags are pulled once and then reused. Because `:nightly` follows `develop`,
+anything merged there runs in every development build's next container, which
+mounts your provider and GitHub logins. To use another image, pass `--image`
+or set `CROSSLINK_CONTAINER_IMAGE`; every container command (kickoff,
+`container start`, `container auth`, swarm and sentinel launches) resolves the
+image the same way, flag first, then the variable, then the build default, and
+reports which one it used. Registry tags can be moved; for a byte-for-byte reproducible run,
+pin the image by digest (`--image ghcr.io/corvidae-coding-projects/crosslink-agent@sha256:`).
### Building the image locally
diff --git a/justfile b/justfile
index 0d25a40fe..9bc110cca 100644
--- a/justfile
+++ b/justfile
@@ -162,7 +162,9 @@ build-image tag="local":
aarch64|arm64) RUST_TARGET=aarch64-unknown-linux-musl; DOCKER_ARCH=arm64; PLATFORM=linux/arm64 ;;
*) echo "Unsupported host arch: $HOST_ARCH"; exit 1 ;;
esac
- echo "==> Building crosslink for ${RUST_TARGET}"
+ CRATE_VERSION="$(sed -n 's/^version = "\(.*\)"$/\1/p' crosslink/Cargo.toml | head -n 1)"
+ export CROSSLINK_VERSION="${CRATE_VERSION}+$(git rev-parse --short=7 HEAD)"
+ echo "==> Building crosslink ${CROSSLINK_VERSION} for ${RUST_TARGET}"
rustup target add "${RUST_TARGET}" >/dev/null
cd crosslink && cargo build --locked --release --target "${RUST_TARGET}"
cd ..
@@ -172,6 +174,7 @@ build-image tag="local":
docker buildx build \
--platform "${PLATFORM}" \
--build-arg "TARGETARCH=${DOCKER_ARCH}" \
+ --label "dev.crosslink.version=${CROSSLINK_VERSION}" \
--load \
-t "ghcr.io/corvidae-coding-projects/crosslink-agent:{{tag}}" \
crosslink/resources/container