Skip to content

Deploy PRD (Cloudflare via Alchemy) #1716

Deploy PRD (Cloudflare via Alchemy)

Deploy PRD (Cloudflare via Alchemy) #1716

Workflow file for this run

name: Deploy PRD (Cloudflare via Alchemy)
# Gated on CI rather than `push: main`. Both used to fire on the same push with
# separate concurrency groups, so a red CI still shipped to production.
# `workflow_dispatch` stays as the manual escape hatch and skips the gate.
on:
workflow_run:
workflows: ["CI"]
types: [completed]
branches: [main]
workflow_dispatch:
concurrency:
group: deploy-prd
cancel-in-progress: false
permissions:
contents: read
id-token: write # Infisical OIDC machine-identity auth
jobs:
# Separate job because it needs an arm64 runner — see the workflow itself.
ingest-binary:
uses: ./.github/workflows/build-ingest-binary.yml
deploy-prd:
needs: ingest-binary
runs-on: ubuntu-latest
timeout-minutes: 45
# On a workflow_run event `github.sha` is the default branch head, not the
# commit CI actually ran against — so pin everything to head_sha, both for
# what gets deployed and for the telemetry stamp.
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
environment: production
env:
INFISICAL_ENV_SLUG: prod
# Stamped onto deployed telemetry as `deployment.commit_sha` (server SDK
# reads COMMIT_SHA; web build reads VITE_COMMIT_SHA via Vite define).
COMMIT_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
VITE_COMMIT_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ github.event.workflow_run.head_sha || github.ref }}
- name: Setup mise (toolchain)
uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0
- name: Load deployment secrets from Infisical
uses: Infisical/secrets-action@77ab1f4ccd183a543cb5b42435fbd181189f4995 # v1.0.16
with:
method: "oidc"
identity-id: ${{ secrets.INFISICAL_MACHINE_IDENTITY_ID }}
project-slug: ${{ vars.INFISICAL_PROJECT_SLUG }}
env-slug: ${{ env.INFISICAL_ENV_SLUG }}
export-type: "env"
secret-path: "/"
# AFTER Infisical: the OIDC exchange writes AWS_ACCESS_KEY_ID /
# AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN / AWS_REGION into the env,
# which is exactly what alchemy's AWS auth reads (it picks the `env`
# method whenever CI=true). Running it last means these always win over
# anything stale carrying the same names out of Infisical.
- name: Configure AWS credentials (OIDC)
id: aws
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
with:
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
aws-region: us-east-1
- name: Install dependencies
run: bun install --frozen-lockfile
# Compiled by the `ingest-binary` job above, on an arm64 runner —
# the ECS tasks are Graviton. The artifact upload drops the exec
# bit, and `Dockerfile.prebuilt` COPYs the file mode straight into
# the image, so restore it here or the task exits on `permission
# denied`.
# `Dockerfile.prebuilt` builds for linux/arm64 (the tasks are Graviton)
# and has a `RUN apt-get` layer, so this x86 runner has to be able to
# execute arm64 binaries to build it. Registers the binfmt handlers.
# The COPYed binary itself is compiled natively by the build job —
# nothing heavy is emulated here, just one apt-get layer.
- name: Register arm64 binfmt handlers (QEMU)
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
with:
platforms: arm64
- name: Download ingest binary
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: maple-ingest
path: apps/ingest/dist
- name: Restore exec bit on ingest binary
run: chmod +x apps/ingest/dist/maple-ingest
# NOTE: prod schema migrations are applied OUT OF BAND (manually, via
# `bun run migrate:prod` → `ps:apply-schema main`, against the direct 5432
# port), NOT by this workflow — so a deploy never touches the prod database
# and needs no MAPLE_PG_URL/admin credential. The worker binds to the
# pre-configured `maple-prd` Hyperdrive (see apps/api/alchemy.run.ts).
# apply-schema installs default privileges granting PUBLIC before it
# migrates, so new and rebuilt tables are readable by every consumer —
# including the ingest gateway, which reaches Postgres through PSBouncer as
# a role that does NOT inherit `postgres`.
# alchemy's env-credential path (CI=true) otherwise discovers the account
# with an STS GetCallerIdentity issued while its own AWSEnvironment is
# still being built, and that call waits on the half-built environment
# for its endpoint resolver — a self-deadlock with no network I/O and no
# log line. That was the "AWS ingest deploy hang" (#378). With the id
# supplied, the lookup is skipped. Reproduced locally with CI=true and
# the id unset on alchemy 2.0.0-beta.64 through beta.74.
# continue-on-error so the certificate step below can finish the job;
# the last step turns an unrecovered failure back into a red run.
- name: Deploy PRD stack with Alchemy
id: deploy
continue-on-error: true
run: bun run alchemy:deploy:prd
env:
AWS_ACCOUNT_ID: ${{ steps.aws.outputs.aws-account-id }}
# First pass of a stage with the AWS half on: the ingest ACM certificate
# is created PENDING_VALIDATION and the 443 listener fails. Finish the
# job here — create the validation CNAME in the Cloudflare zone with the
# token Infisical already provides, wait for ISSUED, deploy again. A
# deploy that failed for any other reason reaches this too: the script
# is a no-op on an ISSUED certificate and the retry fails the same way.
- name: Validate the ingest certificate and retry the deploy
id: retry
if: ${{ steps.deploy.outcome == 'failure' }}
env:
INGEST_DOMAIN: ingest.maple.dev
AWS_ACCOUNT_ID: ${{ steps.aws.outputs.aws-account-id }}
run: |
bash scripts/ingest-cert-validate.sh
bun run alchemy:deploy:prd
- name: Fail the run if the deploy did not succeed
if: ${{ steps.deploy.outcome == 'failure' && steps.retry.outcome != 'success' }}
run: |
echo "::error::alchemy deploy failed and the certificate-validation retry did not recover it (retry outcome: ${{ steps.retry.outcome }})"
exit 1