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