Deploy PRD (Cloudflare via Alchemy) #1716
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 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 |