fix(bot): survive a create whose body does not arrive (#164) #45
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: Release bot | |
| # Build, verify, and ship the bot in one pass, or re-ship a digest that already | |
| # passed. Splitting these across two workflows implied a review gate between | |
| # them; every environment on this repository has zero protection rules, so the | |
| # gate was a dispatch step and a change-ticket string, not a reviewer. Rollback | |
| # is the mode that survived: dispatch with a known-good digest to skip the build | |
| # and re-pin it. | |
| on: | |
| push: | |
| branches: [main] | |
| paths: | |
| - "bun.lock" | |
| - "package.json" | |
| - "packages/bot/**" | |
| - "packages/shared/**" | |
| - ".github/workflows/image.yml" | |
| workflow_dispatch: | |
| inputs: | |
| image: | |
| description: Existing vcr.vercel.com image@sha256 digest to re-ship. Leave empty to build HEAD. | |
| required: false | |
| type: string | |
| permissions: | |
| contents: read | |
| id-token: write | |
| # GitHub's attestation store, not the registry: VCR rejects cosign's | |
| # `application/vnd.dev.cosign.simplesigning.v1+json` layer mediaType. | |
| attestations: write | |
| # One release at a time. This was keyed per-SHA, so two merges could pin | |
| # `BOT_IMAGE` concurrently and the loser would still be reported as active. | |
| concurrency: | |
| group: release-bot | |
| cancel-in-progress: false | |
| env: | |
| VERCEL_CLI_VERSION: 58.7.1 | |
| SYFT_VERSION: 1.39.0 | |
| SYFT_LINUX_X64_SHA256: bb8f1ba8201d1ce0d99ab4f5243142629c1afba4da97a7c4ca2df97327f97190 | |
| jobs: | |
| release: | |
| if: github.ref == 'refs/heads/main' | |
| runs-on: ubuntu-latest | |
| environment: production | |
| env: | |
| VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} | |
| VERCEL_SCOPE: ${{ vars.VERCEL_SCOPE }} | |
| VCR_PROJECT: ${{ vars.VCR_PROJECT }} | |
| AGENT_PROJECT: ${{ vars.AGENT_VERCEL_PROJECT }} | |
| UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} | |
| UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} | |
| REQUESTED_IMAGE: ${{ inputs.image }} | |
| steps: | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 | |
| - uses: ./.github/actions/setup | |
| - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 | |
| - name: Validate registry configuration | |
| env: | |
| VCR_IMAGE: ${{ vars.VCR_IMAGE }} | |
| VCR_PROJECT: ${{ vars.VCR_PROJECT }} | |
| VERCEL_SCOPE: ${{ vars.VERCEL_SCOPE }} | |
| VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| test -n "$VCR_IMAGE" | |
| test -n "$VCR_PROJECT" | |
| test -n "$VERCEL_SCOPE" | |
| test -n "$VERCEL_TOKEN" | |
| case "$VCR_IMAGE" in | |
| vcr.vercel.com/*) ;; | |
| *) echo "VCR_IMAGE must be a vcr.vercel.com repository" >&2; exit 1 ;; | |
| esac | |
| - name: Log in to VCR | |
| env: | |
| VCR_PROJECT: ${{ vars.VCR_PROJECT }} | |
| VERCEL_SCOPE: ${{ vars.VERCEL_SCOPE }} | |
| VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} | |
| run: >- | |
| bunx vercel@${VERCEL_CLI_VERSION} vcr login docker | |
| --project "$VCR_PROJECT" --scope "$VERCEL_SCOPE" --token "$VERCEL_TOKEN" | |
| - name: Build and push immutable candidate | |
| id: build | |
| if: inputs.image == '' | |
| env: | |
| BUILDX_METADATA_PROVENANCE: max | |
| VCR_IMAGE: ${{ vars.VCR_IMAGE }} | |
| run: | | |
| set -euo pipefail | |
| docker buildx build --platform linux/amd64 --file packages/bot/Dockerfile --output "type=image,name=${VCR_IMAGE}:${GITHUB_SHA},push=true,oci-mediatypes=true,compression=zstd,compression-level=3,force-compression=true" --provenance mode=max --sbom true --metadata-file build-metadata.json . | |
| digest="$(bun -e ' | |
| const metadata = await Bun.file(process.argv[1]).json(); | |
| const digest = metadata["containerimage.digest"]; | |
| if (typeof digest !== "string" || !/^sha256:[a-f0-9]{64}$/.test(digest)) process.exit(1); | |
| console.log(digest); | |
| ' build-metadata.json)" | |
| image="${VCR_IMAGE}@${digest}" | |
| echo "image=$image" >> "$GITHUB_OUTPUT" | |
| echo "digest=$digest" >> "$GITHUB_OUTPUT" | |
| - name: Resolve released digest | |
| id: release | |
| run: | | |
| set -euo pipefail | |
| image="${REQUESTED_IMAGE:-${{ steps.build.outputs.image }}}" | |
| case "$image" in | |
| vcr.vercel.com/*@sha256:*) ;; | |
| *) echo "release target must be a vcr.vercel.com digest reference" >&2; exit 1 ;; | |
| esac | |
| echo "image=$image" >> "$GITHUB_OUTPUT" | |
| - name: Wait for VCR and verify platform | |
| env: | |
| IMAGE: ${{ steps.release.outputs.image }} | |
| run: bun packages/shared/scripts/release-check.ts image "$IMAGE" | |
| - name: Export SPDX SBOM with checksum-pinned Syft | |
| if: inputs.image == '' | |
| env: | |
| IMAGE: ${{ steps.build.outputs.image }} | |
| run: | | |
| set -euo pipefail | |
| archive="syft_${SYFT_VERSION}_linux_amd64.tar.gz" | |
| curl --fail --location --proto '=https' --tlsv1.2 --output "$archive" "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/${archive}" | |
| echo "${SYFT_LINUX_X64_SHA256} ${archive}" | sha256sum --check --strict | |
| tar -xzf "$archive" syft | |
| install -m 0755 syft /usr/local/bin/syft | |
| SYFT_CHECK_FOR_APP_UPDATE=false syft scan "$IMAGE" --output spdx-json > sbom.spdx.json | |
| test -s sbom.spdx.json | |
| - name: Extract reviewable SLSA provenance | |
| if: inputs.image == '' | |
| run: | | |
| bun -e ' | |
| const metadata = await Bun.file("build-metadata.json").json(); | |
| const provenance = metadata["buildx.build.provenance"]; | |
| if (typeof provenance !== "object" || provenance === null) { | |
| throw new Error("Buildx did not emit provenance metadata"); | |
| } | |
| await Bun.write("provenance.json", JSON.stringify(provenance, null, 2)); | |
| ' | |
| # Attestations go to GitHub rather than the registry. VCR refuses cosign's | |
| # signature layer (`MANIFEST_INVALID: unsupported layer mediaType; | |
| # application/vnd.dev.cosign.simplesigning.v1+json`), and `cosign attest` | |
| # has no `--registry-referrers-mode`, so OCI 1.1 referrers cannot carry | |
| # the SBOM or provenance either. The identity is unchanged — still keyless | |
| # sigstore, still this workflow's GitHub OIDC token — only the store moves. | |
| - name: Attest build provenance | |
| if: inputs.image == '' | |
| uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 | |
| with: | |
| subject-name: ${{ vars.VCR_IMAGE }} | |
| subject-digest: ${{ steps.build.outputs.digest }} | |
| push-to-registry: false | |
| - name: Attest SBOM | |
| if: inputs.image == '' | |
| uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0 | |
| with: | |
| subject-name: ${{ vars.VCR_IMAGE }} | |
| subject-digest: ${{ steps.build.outputs.digest }} | |
| sbom-path: sbom.spdx.json | |
| push-to-registry: false | |
| - name: Verify published attestations | |
| env: | |
| IMAGE: ${{ steps.release.outputs.image }} | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| gh attestation verify "oci://${IMAGE}" \ | |
| --repo "${GITHUB_REPOSITORY}" \ | |
| --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/image.yml" | |
| - name: Upload review record | |
| if: inputs.image == '' | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 | |
| with: | |
| name: bot-image-${{ github.sha }} | |
| path: | | |
| build-metadata.json | |
| provenance.json | |
| sbom.spdx.json | |
| if-no-files-found: error | |
| retention-days: 90 | |
| - name: Record currently active digest | |
| id: previous | |
| run: | | |
| set -euo pipefail | |
| echo "image=$(bun packages/shared/scripts/release-check.ts active)" >> "$GITHUB_OUTPUT" | |
| # Only the `BOT_IMAGE` pin changes, so the deployment already serving is | |
| # rebuilt with the new value rather than a fresh one deployed from this | |
| # checkout. Deploying here would ship the agent as a side effect of | |
| # releasing the bot — two changes in an action that verifies one. | |
| # `env add` writes through the linked project, not the flags, so this has to | |
| # come first. It replaced a `vercel pull`, which also downloaded the whole | |
| # production environment just to establish the same link. | |
| - name: Link the agent project | |
| run: >- | |
| bunx vercel@${VERCEL_CLI_VERSION} link --yes | |
| --project "$AGENT_PROJECT" --team "$VERCEL_SCOPE" --token "$VERCEL_TOKEN" | |
| - name: Pin the released digest and rebuild production | |
| id: deploy | |
| env: | |
| IMAGE: ${{ steps.release.outputs.image }} | |
| run: | | |
| set -euo pipefail | |
| bunx vercel@${VERCEL_CLI_VERSION} env add BOT_IMAGE production \ | |
| --value "$IMAGE" --yes --force --no-sensitive \ | |
| --scope "$VERCEL_SCOPE" --token "$VERCEL_TOKEN" | |
| current="$(curl -sS --fail-with-body \ | |
| -H "Authorization: Bearer $VERCEL_TOKEN" \ | |
| "https://api.vercel.com/v6/deployments?app=${AGENT_PROJECT}&target=production&state=READY&limit=1&slug=${VERCEL_SCOPE}" \ | |
| | bun -e ' | |
| const uid = JSON.parse(await Bun.stdin.text()).deployments?.[0]?.uid; | |
| if (typeof uid !== "string") { | |
| console.error("no READY production deployment to rebuild"); | |
| process.exit(1); | |
| } | |
| console.log(uid); | |
| ')" | |
| echo "rebuilding $current with the released digest" | |
| bunx vercel@${VERCEL_CLI_VERSION} redeploy "$current" \ | |
| --target production --scope "$VERCEL_SCOPE" --token "$VERCEL_TOKEN" | |
| echo "deployment=$current" >> "$GITHUB_OUTPUT" | |
| # The bot-supervisor schedule is the only writer of the generation record | |
| # and fires every five minutes, so this waits for a tick rather than | |
| # triggering one. A release is not a release until the digest is actually | |
| # serving and the bot reports ready. | |
| - name: Wait for the supervisor to adopt the digest | |
| env: | |
| IMAGE: ${{ steps.release.outputs.image }} | |
| run: | | |
| set -euo pipefail | |
| deadline=$(( SECONDS + 900 )) | |
| until bun packages/shared/scripts/release-check.ts smoke "$IMAGE"; do | |
| if [ "$SECONDS" -ge "$deadline" ]; then | |
| echo "released digest did not become active within 15 minutes" >&2 | |
| exit 1 | |
| fi | |
| echo "waiting for the bot-supervisor schedule to adopt $IMAGE…" | |
| sleep 30 | |
| done | |
| - name: Release record | |
| env: | |
| IMAGE: ${{ steps.release.outputs.image }} | |
| run: | | |
| { | |
| echo "## Bot release" | |
| echo | |
| echo "- Mode: ${{ inputs.image == '' && 'built from HEAD' || 're-shipped an existing digest' }}" | |
| echo "- Previous: \`${{ steps.previous.outputs.image }}\`" | |
| echo "- Current: \`$IMAGE\`" | |
| echo "- Rebuilt deployment: \`${{ steps.deploy.outputs.deployment }}\`" | |
| } >> "$GITHUB_STEP_SUMMARY" |