Skip to content

fix(bot): survive a create whose body does not arrive (#164) #45

fix(bot): survive a create whose body does not arrive (#164)

fix(bot): survive a create whose body does not arrive (#164) #45

Workflow file for this run

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"