Deploy dev stack #23
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: 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 }} |