Skip to content

Deploy dev stack

Deploy dev stack #23

Workflow file for this run

name: Deploy stack
run-name: Deploy ${{ inputs.stage }} stack
# A build deploy sources both deployable components from one commit — on main, or the head of an
# open pull request when a change needs a real stage before it merges — each in its own job:
#
# Api → Build commit Api image ───────────────→ per-commit ECR tag
# Runner → Build C SDK → Build Runner Binary ────→ private per-commit S3 object
#
# The two legs share only resolve-ref, so they run side by side. The final deploy sets one
# BOXLITE_ARTIFACT_SOURCE=build selector for both and compiles neither. A release deploy uses the
# other selector and consumes the Api image / Runner tarball published for VERSION instead.
on:
workflow_dispatch:
inputs:
stage:
description: 'Stage to deploy (must already be bootstrapped — see apps/infra/README.md)'
required: true
default: dev
type: choice
options:
- dev
apply:
description: Preview again, then deploy the full stack
required: true
default: false
type: boolean
ref:
description: 'Full SHA of a commit on main. Mutually exclusive with pr. Defaults to current main.'
required: false
type: string
pr:
description: 'PR number to deploy its current head — same repo or fork. Mutually exclusive with ref.'
required: false
type: string
permissions:
contents: read
id-token: write
concurrency:
group: deploy-${{ inputs.stage }}-stack
cancel-in-progress: false
jobs:
resolve-ref:
name: Resolve deployable commit
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
# A job-level block replaces the workflow-level one rather than merging with it, so
# contents: read is restated. pull-requests: read is what lets a PR head qualify below.
permissions:
contents: read
pull-requests: read
outputs:
sha: ${{ steps.ref.outputs.sha }}
steps:
- name: Checkout main history
uses: actions/checkout@v5
with:
fetch-depth: 0
persist-credentials: false
- name: Require a commit on main or an open pull request
id: ref
shell: bash
env:
INPUT_REF: ${{ inputs.ref }}
INPUT_PR: ${{ inputs.pr }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
[ -z "$INPUT_REF" ] || [ -z "$INPUT_PR" ] || {
echo "ref and pr are mutually exclusive — give at most one" >&2
exit 1
}
# A PR number needs no SHA-shape check of its own: `gh pr view` is the validation, and
# it fails on a nonexistent PR the same way cat-file fails on a nonexistent commit below.
# This is the fix for the SHA-first design PR #1148 shipped: that guard took a SHA and
# asked the API which PR (if any) it belonged to via /commits/{sha}/pulls, which returns
# an empty array for a fork PR's head — confirmed live — so no patch to that lookup could
# ever accept a fork. Asking the API for a known PR NUMBER's head, instead, works
# unconditionally for same-repo and fork PRs alike; there is no lookup to fail.
if [ -n "$INPUT_PR" ]; then
[[ "$INPUT_PR" =~ ^[0-9]+$ ]] || { echo "pr must be a PR number" >&2; exit 1; }
pr_json="$(gh pr view "$INPUT_PR" --json state,headRefOid,headRepository,isCrossRepository)"
state="$(jq -r '.state' <<<"$pr_json")"
[ "$state" = "OPEN" ] || {
echo "PR #$INPUT_PR is $state, not open" >&2
exit 1
}
sha="$(jq -r '.headRefOid' <<<"$pr_json")"
fork="$(jq -r '.isCrossRepository' <<<"$pr_json")"
# $fork is logged for whoever reviews the run below, never gated on: PR #1148 refused a
# fork head here deliberately, as its own security boundary. Accepting one is the point
# of this change (it's how #1141 becomes deployable) — dispatching this workflow at all
# already requires repo write access, and build-api/deploy additionally sit behind the
# dev environment's required reviewer before their AWS OIDC role activates, same as for
# any ref. That part is unchanged: a fork head reaches those two jobs' secrets no more
# easily than a same-repo head always could.
#
# build-c/build-runner are the honest exception: neither declares an `environment:` or
# touches AWS credentials, so a fork's own Cargo/Go/make code now runs on a GitHub-hosted
# runner right after resolve-ref, with no second human look and nothing to steal. Risk
# there is compute abuse and build-time tampering (e.g. a rewritten .gitmodules URL), not
# credential theft — call it out rather than fold it into the build-api/deploy gate above.
echo "PR #$INPUT_PR ($([ "$fork" = "true" ] && echo fork || echo same-repo)) head is $sha"
echo "sha=$sha" >> "$GITHUB_OUTPUT"
exit 0
fi
# Otherwise: a commit already on main. `cat-file` only rules out a SHA this clone has
# never seen — fetch-depth: 0 brings every branch, so `merge-base` is the real test.
candidate="${INPUT_REF:-$GITHUB_SHA}"
[[ "$candidate" =~ ^[0-9a-f]{40}$ ]] || { echo "ref must be a full commit SHA" >&2; exit 1; }
if ! git cat-file -e "$candidate^{commit}" 2>/dev/null \
|| ! git merge-base --is-ancestor "$candidate" origin/main; then
echo "$candidate is not a commit on main" >&2
exit 1
fi
echo "$candidate is on main"
echo "sha=$candidate" >> "$GITHUB_OUTPUT"
build-api:
name: Build commit Api image
needs: resolve-ref
# Nothing here depends on the C SDK, so this runs beside build-c rather than behind it.
# The called workflow reaches ECR through OIDC, and a job-level block replaces the
# workflow-level one rather than merging with it, so contents: read is restated.
permissions:
contents: read
id-token: write
uses: ./.github/workflows/build-apps-api-image.yml
with:
stage: ${{ inputs.stage }}
ref: ${{ needs.resolve-ref.outputs.sha }}
build-c:
name: Build commit C SDK
needs: resolve-ref
# The called workflow's release-upload job declares contents: write, and a callee cannot go
# past what its caller grants (see the caller-grant test in release-deployment-safety.test.mjs
# for what is documented and what is inferred). Granted here rather than at workflow level so
# the deploy job below keeps contents: read.
permissions:
contents: write
uses: ./.github/workflows/build-c.yml
with:
ref: ${{ needs.resolve-ref.outputs.sha }}
linux_x64_only: true
build-runner:
name: Build commit Runner
needs: [resolve-ref, build-c]
# The called workflow's release-upload job declares contents: write, and a callee cannot go
# past what its caller grants (see the caller-grant test in release-deployment-safety.test.mjs
# for what is documented and what is inferred). Granted here rather than at workflow level so
# the deploy job below keeps contents: read.
permissions:
contents: write
uses: ./.github/workflows/build-runner-binary.yml
with:
ref: ${{ needs.resolve-ref.outputs.sha }}
libboxlite_source: artifact
deploy:
name: Deploy ${{ inputs.stage }} stack
needs: [resolve-ref, build-api, build-runner]
if: github.ref == 'refs/heads/main'
environment: ${{ inputs.stage }}
runs-on: ubuntu-24.04
timeout-minutes: 90
env:
AWS_REGION: ${{ vars.AWS_REGION || 'ap-southeast-1' }}
AWS_DEFAULT_REGION: ${{ vars.AWS_REGION || 'ap-southeast-1' }}
STAGE: ${{ inputs.stage }}
IAM_PERMISSIONS_BOUNDARY_STAGE: ${{ inputs.stage }}
BOXLITE_ARTIFACT_SOURCE: build
API_ARTIFACT_SOURCE: build
RUNNER_ARTIFACT_SOURCE: build
BOXLITE_ARTIFACT_REF: ${{ needs.resolve-ref.outputs.sha }}
steps:
- name: Checkout selected commit
uses: actions/checkout@v5
with:
ref: ${{ needs.resolve-ref.outputs.sha }}
persist-credentials: false
- name: Verify native AMD64 Docker
shell: bash
run: |
set -euo pipefail
test "$(uname -m)" = "x86_64"
test "$(docker info --format '{{.Architecture}}')" = "x86_64"
- name: Resolve commit version
shell: bash
run: |
set -euo pipefail
version=$(grep '^version = ' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
[[ "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]
echo "VERSION=$version" >> "$GITHUB_ENV"
- name: Configure AWS credentials through OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
role-session-name: deploy-${{ inputs.stage }}-stack-${{ github.run_id }}
- name: Download commit Runner artifact
uses: actions/download-artifact@v4
with:
name: runner-linux-amd64
path: dist/runner
- name: Stage commit Runner artifact
shell: bash
run: |
set -euo pipefail
account_id=$(aws sts get-caller-identity --query Account --output text)
bucket="boxlite-app-${STAGE}-artifacts-${account_id}"
archive="boxlite-runner-v${VERSION}-${BOXLITE_ARTIFACT_REF}-linux-amd64.tar.gz"
test -f "dist/runner/$archive"
test -f "dist/runner/$archive.sha256"
if ! aws s3api head-bucket --bucket "$bucket" >/dev/null 2>&1; then
echo "artifact bucket $bucket is missing; bootstrap $STAGE from apps/infra/ci/github-deploy-role.yaml first" >&2
exit 1
fi
# Write-once: the Pulumi trigger and the host's health-route comparison treat
# version+ref as an identity, so republishing different bytes under it would strand
# already-installed hosts on the old build. S3 returns 412 rather than overwriting.
#
# Decided over the ref as a whole, never per key — the same rule
# apps/infra/scripts/runner-artifact-build.mjs applies locally. A fully published ref is
# the desired end state. A half-published one is not completed here: the rebuild is not
# byte-identical (tar czf alone stamps a gzip mtime), so writing the absent manifest
# would describe bytes that are not the ones stored, and write-once then makes that
# unrepairable while every host fails the digest check.
prefix="runner/${BOXLITE_ARTIFACT_REF}"
present=0
for name in "$archive" "$archive.sha256"; do
if aws s3api head-object --bucket "$bucket" --key "$prefix/$name" >/dev/null 2>&1; then
present=$((present + 1))
fi
done
if [ "$present" -eq 2 ]; then
echo "$prefix/ is already published; leaving it untouched"
elif [ "$present" -eq 1 ]; then
echo "$prefix/ is partially published; delete the objects under it and rerun" >&2
exit 1
else
for name in "$archive" "$archive.sha256"; do
aws s3api put-object --bucket "$bucket" --key "$prefix/$name" \
--body "dist/runner/$name" --if-none-match '*' >/dev/null
done
fi
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: apps/infra/package-lock.json
- name: Install deployment dependencies
working-directory: apps/infra
run: npm ci
- name: Run deployment safety tests
working-directory: apps/infra
run: npm test
- name: Verify deploy role IAM boundary permissions
working-directory: apps/infra
run: node scripts/verify-deploy-role-boundary.mjs
- name: Materialize stage configuration
shell: bash
env:
DEPLOY_ENV: ${{ secrets.DEPLOY_ENV }}
run: |
set -euo pipefail
test -n "$DEPLOY_ENV"
umask 077
printf '%s\n' "$DEPLOY_ENV" > apps/infra/.env
node apps/infra/scripts/deploy-environment-validation.mjs apps/infra/.env
- name: Install SST providers
working-directory: apps/infra
run: npm run --silent sst -- install --stage "$STAGE"
# These stay hand-made scoped API tokens. Cloudflare's `cf` CLI can mint
# an OAuth token carrying dns_records:edit, but it expires in about an
# hour and is renewed through a browser, which this unattended job has
# no way to complete. Supplying them as Environment secrets is optional:
# scripts/sst-with-cloudflare.mjs prefers an already-set env var and
# otherwise reads SSM, so a stage seeded via SSM keeps working with
# these unset.
- name: Preview the full stack
shell: bash
working-directory: apps/infra
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_DEFAULT_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_DEFAULT_ACCOUNT_ID }}
run: |
set -euo pipefail
npm run --silent sst -- diff --stage "$STAGE" --policy . --json |
node scripts/deployment-preview.mjs
- name: Deploy the full stack
if: ${{ inputs.apply }}
working-directory: apps/infra
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_DEFAULT_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_DEFAULT_ACCOUNT_ID }}
run: npm run deploy -- --stage "$STAGE" --policy .
- name: Remove materialized configuration
if: always()
run: rm -f apps/infra/.env
# E2E cloud has no automatic trigger of its own; a deploy is the one
# event that makes running it meaningful, because only here are the
# deployed stack and the SDKs under test the same commit.
#
# Gated on `apply`: without it the deploy job only previews, so the
# stack is whatever it already was and the suite would just spend dev
# capacity re-testing it. The `if` carries no status check function, so
# the default success() still applies and a failed deploy skips this.
#
# The stage is always `dev`, so the suite's own BOXLITE_DEV_API_URL
# default is the right target and no api_url is passed.
e2e:
name: E2E suite against ${{ inputs.stage }}
needs: [resolve-ref, deploy]
if: ${{ inputs.apply }}
uses: ./.github/workflows/e2e-cloud.yml
with:
ref: ${{ needs.resolve-ref.outputs.sha }}
secrets:
BOXLITE_DEV_API_KEY: ${{ secrets.BOXLITE_DEV_API_KEY }}