Skip to content

release: promote develop to main #40

release: promote develop to main

release: promote develop to main #40

name: Container Image
on:
push:
branches: [develop]
tags: ['v*']
pull_request:
paths:
- 'crosslink/resources/container/**'
- '.github/workflows/container-image.yml'
- 'crosslink/Cargo.toml'
- 'crosslink/Cargo.lock'
workflow_dispatch:
permissions:
contents: read
packages: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
REGISTRY: ghcr.io
IMAGE_NAME: crosslink-agent
jobs:
build-binary:
name: Build binary (${{ matrix.arch }})
runs-on: ubuntu-latest
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
defaults:
run:
working-directory: crosslink
steps:
- uses: actions/checkout@v4
- name: Install musl-tools (amd64 native build)
if: matrix.use_cross == false
run: |
sudo apt-get update
sudo apt-get install -y musl-tools
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install cross (arm64 only)
if: matrix.use_cross == true
uses: taiki-e/install-action@v2
with:
tool: cross
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
crosslink/target/
key: ${{ runner.os }}-cargo-container-${{ matrix.target }}-${{ hashFiles('crosslink/Cargo.lock') }}
- name: Build release binary (native)
if: matrix.use_cross == false
run: cargo build --locked --release --target ${{ matrix.target }}
- name: Build release binary (cross)
if: matrix.use_cross == true
run: cross build --locked --release --target ${{ matrix.target }}
- name: Upload binary artifact
uses: actions/upload-artifact@v4
with:
name: crosslink-${{ matrix.arch }}
path: crosslink/target/${{ matrix.target }}/release/crosslink
if-no-files-found: error
retention-days: 1
publish-image:
name: Build & publish image
needs: build-binary
runs-on: ubuntu-latest
outputs:
primary_tag: ${{ steps.tags.outputs.primary_tag }}
version: ${{ steps.tags.outputs.version }}
pushed: ${{ steps.tags.outputs.pushed }}
steps:
- uses: actions/checkout@v4
- name: Download amd64 binary
uses: actions/download-artifact@v4
with:
name: crosslink-amd64
path: artifacts/amd64
- name: Download arm64 binary
uses: actions/download-artifact@v4
with:
name: crosslink-arm64
path: artifacts/arm64
- name: Stage binaries into build context
run: |
cp artifacts/amd64/crosslink crosslink/resources/container/crosslink-amd64
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
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}"
exit 1
fi
{
echo "tags<<EOF"
for t in "${TAGS[@]}"; do echo "$t"; done
echo "EOF"
echo "push=${PUSH}"
echo "primary_tag=${PRIMARY}"
echo "version=${VERSION}"
echo "pushed=${PUSH}"
echo "cache_ref=${BASE}:buildcache"
} >> "$GITHUB_OUTPUT"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: linux/amd64,linux/arm64
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
if: steps.tags.outputs.push == 'true'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@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 }}
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) || '' }}
container-pr-smoke:
name: Provider smoke (PR)
needs: build-binary
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: crosslink-amd64
path: artifacts/amd64
- name: Stage amd64 binary
run: |
cp artifacts/amd64/crosslink crosslink/resources/container/crosslink-amd64
chmod +x crosslink/resources/container/crosslink-amd64
- name: Build native smoke image
run: docker build --build-arg TARGETARCH=amd64 -t crosslink-agent:pr-smoke crosslink/resources/container
- name: Verify both CLIs and non-root dispatch
run: |
set -euo pipefail
docker run --rm -e CROSSLINK_AGENT_PROVIDER=claude crosslink-agent:pr-smoke bash -c 'test "$(id -u)" != 0 && claude --version'
docker run --rm -e CROSSLINK_AGENT_PROVIDER=codex crosslink-agent:pr-smoke bash -c 'test "$(id -u)" != 0 && codex --version'
- name: Verify timeout parity and no key environment
run: |
set -euo pipefail
for provider in claude codex; do
status="$(docker run --rm -e CROSSLINK_AGENT_PROVIDER="$provider" crosslink-agent:pr-smoke bash -c 'timeout 1s sh -c "sleep 5"; printf "%s\n" "$?"' | tail -n 1)"
test "$status" = 124
done
if docker inspect crosslink-agent:pr-smoke --format '{{json .Config.Env}}' \
| grep -E 'ANTHROPIC_API_KEY|OPENAI_API_KEY|CODEX_API_KEY'; then
echo 'Container image unexpectedly contains an API-key environment variable.' >&2
exit 1
fi
- name: Verify isolated account volumes and redacted status
run: |
set -euo pipefail
for provider in claude codex; do
volume="crosslink-pr-auth-${provider}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
docker volume create "$volume" >/dev/null
docker run --rm -v "$volume:/home/agent/.${provider}" --entrypoint sh crosslink-agent:pr-smoke -c 'printf refreshed > "$1/session-state"' sh "/home/agent/.${provider}"
state="$(docker run --rm -v "$volume:/home/agent/.${provider}" --entrypoint sh crosslink-agent:pr-smoke -c 'cat "$1/session-state"' sh "/home/agent/.${provider}")"
test "$state" = refreshed
if [ "$provider" = codex ]; then
status_command='codex login status'
else
status_command='claude auth status'
fi
status="$(docker run --rm -v "$volume:/home/agent/.${provider}" crosslink-agent:pr-smoke sh -c "$status_command" 2>&1 || true)"
if printf '%s' "$status" | grep -E 'sk-[A-Za-z0-9]|ANTHROPIC_API_KEY|OPENAI_API_KEY|CODEX_API_KEY'; then
echo 'Provider status exposed key-shaped account data.' >&2
exit 1
fi
docker volume rm "$volume" >/dev/null
if docker volume inspect "$volume" >/dev/null 2>&1; then
echo "Provider volume was not removed: $volume" >&2
exit 1
fi
done
smoke-verify:
name: Smoke verify (${{ matrix.platform }})
needs: publish-image
if: needs.publish-image.outputs.pushed == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
platform: [linux/amd64, linux/arm64]
steps:
- name: Set up QEMU (for non-native arch)
uses: docker/setup-qemu-action@v3
with:
platforms: linux/amd64,linux/arm64
- name: Log in to GHCR
uses: docker/login-action@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'
run: |
docker manifest inspect ${{ needs.publish-image.outputs.primary_tag }} \
| jq -e '[.manifests[].platform | "\(.os)/\(.architecture)"] | sort | . == ["linux/amd64","linux/arm64"]'
- name: Run crosslink --version
id: version
run: |
set -euo pipefail
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')
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"