Skip to content

fix(deploy): compare the lists that route requests, not only key path… #3

fix(deploy): compare the lists that route requests, not only key path…

fix(deploy): compare the lists that route requests, not only key path… #3

Workflow file for this run

name: Release images
on:
push:
tags:
- "v*"
permissions:
contents: read
packages: write
env:
REGISTRY: ghcr.io
jobs:
images:
name: Build and push images
runs-on: ubuntu-latest
outputs:
box-digest: ${{ steps.box.outputs.digest }}
broker-digest: ${{ steps.broker.outputs.digest }}
image-owner: ${{ steps.namespace.outputs.owner }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# GHCR image names must be lowercase; the repository owner may not be.
# Computed once and published as a job output, so the release notes name
# the same images this job actually pushed.
- name: Compute lowercase image namespace
id: namespace
env:
OWNER: ${{ github.repository_owner }}
run: |
owner="$(printf '%s' "$OWNER" | tr '[:upper:]' '[:lower:]')"
echo "IMAGE_OWNER=$owner" >> "$GITHUB_ENV"
echo "owner=$owner" >> "$GITHUB_OUTPUT"
- name: Build and push box
id: box
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
file: packages/box/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
provenance: true
tags: |
ghcr.io/${{ env.IMAGE_OWNER }}/blitz-box:${{ github.ref_name }}
ghcr.io/${{ env.IMAGE_OWNER }}/blitz-box:latest
- name: Build and push broker
id: broker
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
file: packages/broker/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
provenance: true
tags: |
ghcr.io/${{ env.IMAGE_OWNER }}/blitz-broker:${{ github.ref_name }}
ghcr.io/${{ env.IMAGE_OWNER }}/blitz-broker:latest
# Deploys the control plane pinned to the box image this run just pushed, so
# a version tag is a complete release with no manual publish step. Opt-in via
# two repository secrets (docs/BOX-IMAGE.md § Automatic releases); without
# them the job skips with a notice, so forks and CI-only checkouts are
# unaffected. Secrets stay in Cloudflare: the deploy only checks their
# presence with `wrangler secret list`.
deploy-control-plane:
name: Deploy control plane with the released box image
needs: images
runs-on: ubuntu-latest
# Client prod. Add a required reviewer to this environment in repository
# settings and the tag still triggers the release, but a person approves
# the deploy. The images above are already built and pushed by then, so an
# approval that never comes costs nothing but an unused image tag.
environment: production
steps:
- name: Check for deploy configuration
id: gate
env:
PROD_WRANGLER_TOML: ${{ secrets.PROD_WRANGLER_TOML }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
if [ -z "$PROD_WRANGLER_TOML" ] || [ -z "$CLOUDFLARE_API_TOKEN" ]; then
echo "configured=false" >> "$GITHUB_OUTPUT"
echo "::notice::Control-plane deploy skipped: set the PROD_WRANGLER_TOML and CLOUDFLARE_API_TOKEN repository secrets to deploy on tag pushes (docs/BOX-IMAGE.md)."
else
echo "configured=true" >> "$GITHUB_OUTPUT"
fi
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
if: steps.gate.outputs.configured == 'true'
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
if: steps.gate.outputs.configured == 'true'
with:
node-version-file: package.json
cache: npm
- run: npm ci
if: steps.gate.outputs.configured == 'true'
- name: Write the deployment wrangler.toml
if: steps.gate.outputs.configured == 'true'
env:
PROD_WRANGLER_TOML: ${{ secrets.PROD_WRANGLER_TOML }}
run: printf '%s\n' "$PROD_WRANGLER_TOML" > packages/control-plane/wrangler.toml
- name: Pin the released box image
if: steps.gate.outputs.configured == 'true'
env:
IMAGE_OWNER: ${{ needs.images.outputs.image-owner }}
BOX_DIGEST: ${{ needs.images.outputs.box-digest }}
run: node packages/control-plane/scripts/set-box-image-ref.mjs --ref "ghcr.io/$IMAGE_OWNER/blitz-box@$BOX_DIGEST"
# VM bootstraps pull with no registry login, and GHCR packages are
# born private, so deploying an unpullable digest would break every new
# workspace. Failing here leaves the deployment on its previous image —
# flip the package public (one-time) and re-run this job.
- name: Verify the image is publicly pullable
if: steps.gate.outputs.configured == 'true'
env:
IMAGE_OWNER: ${{ needs.images.outputs.image-owner }}
BOX_DIGEST: ${{ needs.images.outputs.box-digest }}
run: |
token="$(curl -fsS "https://ghcr.io/token?service=ghcr.io&scope=repository:${IMAGE_OWNER}/blitz-box:pull" | jq -r '.token // empty')"
code="$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer $token" \
-H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' \
"https://ghcr.io/v2/${IMAGE_OWNER}/blitz-box/manifests/${BOX_DIGEST}")"
if [ "$code" != "200" ]; then
echo "::error::ghcr.io/${IMAGE_OWNER}/blitz-box is not publicly pullable (manifest HTTP $code). Make the package public (Package settings → Danger zone → Change visibility), then re-run this job. Not deploying an image workspaces cannot pull."
exit 1
fi
- name: Deploy
if: steps.gate.outputs.configured == 'true'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npm run deploy -w packages/control-plane
# A green deploy command proves upload, not content. Ask the deployment
# which commit it is, and fail when it disagrees.
- name: Verify the deployment reports this commit
if: steps.gate.outputs.configured == 'true'
run: |
origin="$(node -e 'const fs=require("node:fs");import("smol-toml").then(({parse})=>{const v=parse(fs.readFileSync("packages/control-plane/wrangler.toml","utf8")).vars||{};process.stdout.write(v.APP_URL||"")})')"
if [ -z "$origin" ]; then
echo "::warning::PROD_WRANGLER_TOML sets no APP_URL, so the deploy cannot be verified from here."
exit 0
fi
for attempt in 1 2 3 4 5; do
reported="$(curl -fsS "$origin/version" | jq -r '.commit // empty' || true)"
[ "$reported" = "$GITHUB_SHA" ] && echo "$origin reports $reported" && exit 0
echo "attempt $attempt: reported '${reported:-none}', expected $GITHUB_SHA"
sleep 6
done
echo "::error::$origin/version reports '${reported:-none}', not $GITHUB_SHA."
exit 1
release-notes:
name: Add image digests to GitHub Release
needs: images
runs-on: ubuntu-latest
permissions:
contents: write
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BOX_DIGEST: ${{ needs.images.outputs.box-digest }}
BROKER_DIGEST: ${{ needs.images.outputs.broker-digest }}
IMAGE_OWNER: ${{ needs.images.outputs.image-owner }}
steps:
- name: Create or update release notes
shell: bash
run: |
tag="${GITHUB_REF_NAME}"
if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release create "$tag" --repo "$GITHUB_REPOSITORY" --generate-notes
fi
# Re-running a release must replace the digest section, not append a
# second one, so strip any section this step wrote before and rebuild
# it from the digests this run pushed.
# Command substitution drops trailing newlines, so the separator below
# is the only one and the output is byte-identical on every re-run.
kept="$(gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json body --jq .body \
| awk '/^## Container image digests$/ { exit } { print }')"
{
printf '%s\n\n' "$kept"
printf '## Container image digests\n\n'
printf -- '- `ghcr.io/%s/blitz-box:%s` — `%s`\n' "$IMAGE_OWNER" "$tag" "$BOX_DIGEST"
printf -- '- `ghcr.io/%s/blitz-broker:%s` — `%s`\n' "$IMAGE_OWNER" "$tag" "$BROKER_DIGEST"
} >"$RUNNER_TEMP/release-notes.md"
gh release edit "$tag" --repo "$GITHUB_REPOSITORY" --notes-file "$RUNNER_TEMP/release-notes.md"