release: promote develop to main #40
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |