Repository navigation
docker-image #58
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: docker-image | |
| # Package the published cli-server release into the GHCR image (spec | |
| # 2026-09-28 § 4). The release shards are the input: this workflow downloads, | |
| # signature-verifies, bakes, and pushes. It never compiles the server. | |
| # | |
| # The chain is `workflow_run` on the Release workflow, NOT `on: release`. | |
| # A cut's release is published by softprops under GITHUB_TOKEN, so it is an | |
| # Actions-created event, and GitHub starts no workflows from those (the | |
| # recursion guard): the original `on: release` trigger was dead on arrival, | |
| # and the rail recorded ZERO runs through cli-server v1.7.0 while the header | |
| # COMMENT described it working. workflow_run is the sanctioned exception. | |
| # Every completion of Release re-arms this trigger, so the plan job decides: | |
| # version from the parent's own head commit, skip when that version is | |
| # already packaged (the common case; a cut that didn't move the server | |
| # version is no work here), build when it is not. Dispatch with a version | |
| # rebuilds any release into its tag on purpose. | |
| # | |
| # :latest moves on a fresh chained build (the parent's smoke gates are | |
| # exactly the old design's "release event whose smoke job passed") and, as | |
| # the one dispatch exception, when the package has NO :latest yet (the rail | |
| # bootstrapped by hand after its trigger fix). A dispatch rebuild of an older | |
| # release repoints the version tag it names and never regresses the float. | |
| on: | |
| workflow_run: | |
| workflows: ["Release"] | |
| types: [completed] | |
| workflow_dispatch: | |
| inputs: | |
| version: | |
| description: "cli-server version to package (e.g. 2.3.1)" | |
| required: true | |
| type: string | |
| permissions: | |
| contents: read | |
| packages: write | |
| concurrency: | |
| group: docker-image | |
| cancel-in-progress: false | |
| env: | |
| IMAGE: ghcr.io/subshell-ai/subshell | |
| jobs: | |
| plan: | |
| runs-on: ubuntu-24.04 | |
| outputs: | |
| version: ${{ steps.resolve.outputs.version }} | |
| skip: ${{ steps.decide.outputs.skip }} | |
| move_latest: ${{ steps.decide.outputs.move_latest }} | |
| steps: | |
| - name: Check out the parent's head commit | |
| # workflow_run checks out the DEFAULT BRANCH by default, but the | |
| # version to package is the one the parent run shipped, so check out | |
| # the parent's own head sha. (The push of a releases.json commit | |
| # completes a Release run too; its head carries the same versions, | |
| # and the already-packaged check below makes that re-arm a no-op.) | |
| if: github.event_name == 'workflow_run' | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| ref: ${{ github.event.workflow_run.head_sha }} | |
| - name: Resolve the version | |
| id: resolve | |
| # Values arrive from the event payload, so they cross into the script | |
| # as env, never as interpolated text the shell would then parse. | |
| env: | |
| EVENT: ${{ github.event_name }} | |
| CONCLUSION: ${{ github.event.workflow_run.conclusion }} | |
| BRANCH: ${{ github.event.workflow_run.head_branch }} | |
| DISPATCH_VERSION: ${{ inputs.version }} | |
| run: | | |
| set -euo pipefail | |
| VERSION="" | |
| if [ "$EVENT" = "workflow_dispatch" ]; then | |
| VERSION="$DISPATCH_VERSION" | |
| elif [ "$CONCLUSION" != "success" ] || [ "$BRANCH" != "main" ]; then | |
| # A failed or off-main parent published nothing new. Only the | |
| # version comes out of THIS step: skip and move_latest belong to | |
| # `decide` (the job's outputs map there, and the first chained | |
| # run proved an empty skip lets a versionless build launch). | |
| echo "version=" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| else | |
| VERSION=$(node -e 'process.stdout.write(require("./apps/server/api/package.json").version)') | |
| fi | |
| # A pre-release version fails this guard on purpose: the image rail | |
| # packages full releases only. | |
| [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "::error::not a semver: $VERSION"; exit 1; } | |
| echo "version=$VERSION" >> "$GITHUB_OUTPUT" | |
| - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 | |
| if: steps.resolve.outputs.version != '' | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Fresh cut, re-arm, or rebuild? | |
| id: decide | |
| env: | |
| EVENT: ${{ github.event_name }} | |
| VERSION: ${{ steps.resolve.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| if [ -z "$VERSION" ]; then | |
| # Failed/off-main parent (or any other empty-version path): skip, | |
| # and never move :latest. | |
| echo "skip=true" >> "$GITHUB_OUTPUT" | |
| echo "move_latest=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| if docker buildx imagetools inspect "$IMAGE:$VERSION" >/dev/null 2>&1; then | |
| # This version is already baked: the re-arm case for chained | |
| # events (every Release completion re-fires this trigger, and | |
| # most move no server version), and a deliberate dispatch | |
| # re-bake of an existing tag (after a base-image fix, say). | |
| if [ "$EVENT" = "workflow_dispatch" ]; then | |
| echo "skip=false" >> "$GITHUB_OUTPUT" | |
| echo "move_latest=false" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "skip=true" >> "$GITHUB_OUTPUT" | |
| echo "move_latest=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| elif [ "$EVENT" = "workflow_run" ]; then | |
| # A chained build of a version that was NOT packaged yet is | |
| # exactly the old design's fresh release: smoke gates this run, | |
| # so :latest may move. | |
| echo "skip=false" >> "$GITHUB_OUTPUT" | |
| echo "move_latest=true" >> "$GITHUB_OUTPUT" | |
| else | |
| # A dispatch of a never-packaged version moves :latest ONLY when | |
| # the package has no :latest at all: a rail bootstrapped by hand | |
| # (this rail's real history: zero chained runs existed before the | |
| # workflow_run fix) otherwise waits with version tags and no | |
| # floating tag. A rebuild of an older release can never pass | |
| # both tests, so the no-regression rule stands. | |
| echo "skip=false" >> "$GITHUB_OUTPUT" | |
| if docker buildx imagetools inspect "$IMAGE:latest" >/dev/null 2>&1; then | |
| echo "move_latest=false" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "move_latest=true" >> "$GITHUB_OUTPUT" | |
| fi | |
| fi | |
| build: | |
| needs: plan | |
| # Belt and braces: skip must say so, AND a version must exist. The first | |
| # chained run leaked past a skip-only gate with an empty version. | |
| if: needs.plan.outputs.skip != 'true' && needs.plan.outputs.version != '' | |
| # One job per architecture, each on a NATIVE runner: the harness installers | |
| # run inside the image build and must execute real arm64/amd64 code. | |
| strategy: | |
| matrix: | |
| include: | |
| - arch: amd64 | |
| asset: linux-x64 | |
| runner: ubuntu-24.04 | |
| - arch: arm64 | |
| asset: linux-arm64 | |
| runner: ubuntu-24.04-arm | |
| runs-on: ${{ matrix.runner }} | |
| env: | |
| VERSION: ${{ needs.plan.outputs.version }} | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 | |
| with: | |
| bun-version: 1.4.2 | |
| - name: Download the release assets | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| mkdir -p docker/dl docker/bin | |
| gh release download "cli-server-v$VERSION" -R subshell-ai/subshell \ | |
| -p 'release-manifest.json' -p 'release-manifest.json.sig' \ | |
| -p "subshell-server-cli-${{ matrix.asset }}" -D docker/dl | |
| - name: Verify signature and digests (refused by name on anything else) | |
| run: bun run scripts/docker-release-verify.ts docker/dl "$VERSION" | |
| - name: Stage the verified binary | |
| run: cp "docker/dl/subshell-server-cli-${{ matrix.asset }}" "docker/bin/subshell-server-${{ matrix.arch }}" | |
| - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 | |
| - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 | |
| with: | |
| context: . | |
| push: true | |
| platforms: linux/${{ matrix.arch }} | |
| tags: ${{ env.IMAGE }}:${{ env.VERSION }}-${{ matrix.arch }} | |
| provenance: false | |
| cache-from: type=gha,scope=docker-image-${{ matrix.arch }} | |
| cache-to: type=gha,scope=docker-image-${{ matrix.arch }},mode=max | |
| manifest: | |
| needs: [plan, build] | |
| runs-on: ubuntu-24.04 | |
| steps: | |
| - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # same pin as above | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Compose and push the multi-arch version tag | |
| # :latest is deliberately NOT one of these tags: it moves only in the | |
| # `latest` job, after the smoke job has run this exact version. | |
| env: | |
| V: ${{ needs.plan.outputs.version }} | |
| IMAGE: ${{ env.IMAGE }} | |
| run: | | |
| docker buildx imagetools create \ | |
| -t "$IMAGE:$V" \ | |
| "$IMAGE:$V-amd64" "$IMAGE:$V-arm64" | |
| smoke: | |
| needs: [plan, build, manifest] | |
| runs-on: ubuntu-24.04 | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| # Package visibility is a one-time admin setting in GitHub. Test the | |
| # same anonymous pull installers use, so private images cannot pass | |
| # smoke and advance :latest under an Actions credential. | |
| - name: Verify anonymous image pull | |
| env: | |
| IMAGE: ${{ env.IMAGE }} | |
| V: ${{ needs.plan.outputs.version }} | |
| run: | | |
| DOCKER_CONFIG=$(mktemp -d) | |
| export DOCKER_CONFIG | |
| trap 'rm -rf "$DOCKER_CONFIG"' EXIT | |
| docker pull "$IMAGE:$V" || { | |
| echo "::error::anonymous image pull failed; check the tag and set package visibility to Public at https://github.com/orgs/subshell-ai/packages/container/subshell/settings" | |
| exit 1 | |
| } | |
| - name: Drive the container scenario against the pushed image | |
| env: | |
| IMAGE: ${{ env.IMAGE }} | |
| V: ${{ needs.plan.outputs.version }} | |
| run: bash scripts/cli-e2e/docker-image.sh "$IMAGE:$V" "$V" | |
| latest: | |
| # Last, and only when the plan says this run is the fresh chained build | |
| # of a never-packaged version: :latest moves to bytes the smoke job just | |
| # passed. A dispatch rebuild of an older release repoints the version tag | |
| # it names but must never regress the floating tag. | |
| needs: [plan, manifest, smoke] | |
| if: needs.plan.outputs.move_latest == 'true' | |
| runs-on: ubuntu-24.04 | |
| steps: | |
| - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # same pin as above | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Move :latest to the smoke-tested version | |
| env: | |
| IMAGE: ${{ env.IMAGE }} | |
| V: ${{ needs.plan.outputs.version }} | |
| run: docker buildx imagetools create -t "$IMAGE:latest" "$IMAGE:$V" |