diff --git a/.github/workflows/app-build-deploy-dev.yml b/.github/workflows/app-build-deploy-dev.yml index 2b80a32..86d16c1 100644 --- a/.github/workflows/app-build-deploy-dev.yml +++ b/.github/workflows/app-build-deploy-dev.yml @@ -1,8 +1,9 @@ name: App Build & Deploy Dev -# Reusable test-environment pipeline: -# pull_request → build (testnet) → deploy to `preview` (wrangler env `dev`) → Lighthouse -# push to main → build (testnet) → deploy to `demo` (wrangler env `dev`, direct deploy) +# Reusable test-environment pipeline. Both legs build in testnet mode and +# target the caller's `[env.nightly]` Worker: +# pull_request → `preview`: an unpromoted version, per-PR URL → Lighthouse +# push to main → `nightly`: direct deploy, so the Worker's URL tracks main # # GitHub Environments resolve against the caller's repo, so protection rules # stay per-app. @@ -79,7 +80,7 @@ jobs: id-token: write runs-on: ubuntu-latest environment: - name: ${{ github.event_name == 'pull_request' && 'preview' || 'demo' }} + name: ${{ github.event_name == 'pull_request' && 'preview' || 'nightly' }} url: ${{ steps.deploy.outputs.deployment-url }} outputs: deployment-url: ${{ steps.deploy.outputs.deployment-url }} @@ -93,7 +94,7 @@ jobs: id: deploy uses: centrifuge/github-actions-lib/actions/deploy-app@main with: - environment: ${{ github.event_name == 'pull_request' && 'dev' || 'demo' }} + environment: ${{ github.event_name == 'pull_request' && 'preview' || 'nightly' }} app-name: ${{ inputs.app-name }} cloudflare-api-token: ${{ secrets.cloudflare-api-token }} cloudflare-account-id: ${{ inputs.cloudflare-account-id }} diff --git a/.github/workflows/app-build-deploy-release.yml b/.github/workflows/app-build-deploy-release.yml index be604df..3ec80ca 100644 --- a/.github/workflows/app-build-deploy-release.yml +++ b/.github/workflows/app-build-deploy-release.yml @@ -3,7 +3,10 @@ name: App Build & Deploy Release # Reusable release pipeline, driven by the caller's `release` events: # prereleased → mainnet build (bundle attached to the release) → staging # (`versions upload --preview-alias staging`, tagged with the -# release tag) → optionally public-demo +# release tag) +# → for callers whose wrangler.toml declares `[env.demo]`, a +# parallel testnet-mode build (own bundle) deployed to that +# Worker (`deploy --env demo`) # released → notify Slack that the staged version is ready; an # authorized deployer promotes it by dispatching the app's # promote-production.yml caller (app-promote-production.yml) @@ -26,10 +29,10 @@ name: App Build & Deploy Release # whose bundle already exists fails. Cut a new prerelease for new code, or # delete the asset from the release page to rebuild the same tag. # -# GitHub Environments (`staging`, `public-demo`) resolve against the caller's -# repo. Caller permissions ceiling: contents: write (release bundle upload), -# deployments: write (staging deployment record). The released path only -# reads and posts to Slack, so it needs no extra scopes. +# GitHub Environments (`staging`, `demo`) resolve against the caller's repo. +# Caller permissions ceiling: contents: write (release bundle upload), +# deployments: write (deployment records). The released path only reads and +# posts to Slack, so it needs no extra scopes. on: workflow_call: @@ -66,11 +69,16 @@ on: description: 'Cloudflare account ID (a repository variable, not a secret)' required: true type: string - deploy-public-demo: - description: 'Whether prereleases also deploy to the public-demo environment' + demo-build-args: + description: 'Extra arguments passed to `pnpm build` for the demo build' required: false - type: boolean - default: false + type: string + default: '--mode testnet' + demo-build-env: + description: 'Multiline KEY=VALUE pairs exported to the demo build step environment' + required: false + type: string + default: '' production-url: description: 'Public production URL, surfaced in the promotion notification' required: false @@ -92,6 +100,9 @@ on: staging-url: description: 'Staging deployment URL (prereleased only)' value: ${{ jobs.deploy-staging.outputs.deployment-url }} + demo-url: + description: 'Demo deployment URL (prereleased, when the caller declares [env.demo])' + value: ${{ jobs.deploy-demo.outputs.deployment-url }} jobs: # One build per release, shared via `needs: build`. Artifact names are @@ -152,28 +163,124 @@ jobs: cloudflare-account-id: ${{ inputs.cloudflare-account-id }} github-token: ${{ github.token }} - deploy-public-demo: + # An `[env.demo]` section in the caller's wrangler.toml is the opt-in, + # and its custom-domain route is the public URL. Parsed with tomllib rather + # than grep so comments, key order and quoting can't change the answer. + detect-demo: runs-on: ubuntu-latest - needs: build - if: github.event.action == 'prereleased' && inputs.deploy-public-demo + if: github.event.action == 'prereleased' + permissions: + contents: read + outputs: + enabled: ${{ steps.detect.outputs.enabled }} + url: ${{ steps.detect.outputs.url }} + steps: + - name: '📥 Checkout Code from Release Tag' + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + + - name: '🔎 Detect [env.demo] in wrangler.toml' + id: detect + shell: bash + run: | + set -euo pipefail + python3 - >> "$GITHUB_OUTPUT" <<'PY' + import pathlib, sys, tomllib + + path = pathlib.Path("wrangler.toml") + enabled, url = False, "" + + if path.is_file(): + try: + with path.open("rb") as handle: + config = tomllib.load(handle) + except tomllib.TOMLDecodeError as exc: + # Fail loudly: silently skipping the demo leg would look + # like the app simply opted out. + print(f"::error::wrangler.toml is not valid TOML: {exc}", file=sys.stderr) + raise SystemExit(1) + + demo = (config.get("env") or {}).get("demo") + if demo is not None: + enabled = True + for route in demo.get("routes") or []: + if isinstance(route, dict) and route.get("custom_domain") and route.get("pattern"): + url = f"https://{route['pattern']}" + break + + print(f"enabled={'true' if enabled else 'false'}") + print(f"url={url}") + PY + + - name: '📋 Summary' + shell: bash + env: + ENABLED: ${{ steps.detect.outputs.enabled }} + URL: ${{ steps.detect.outputs.url }} + run: | + if [ "$ENABLED" = "true" ]; then + echo "Demo deploy enabled by wrangler.toml [env.demo]${URL:+ → $URL}" >> "$GITHUB_STEP_SUMMARY" + else + echo "No [env.demo] in wrangler.toml — skipping the demo leg." >> "$GITHUB_STEP_SUMMARY" + fi + + # Artifact and bundle names are suffixed because artifact names are + # immutable per run — colliding with the mainnet build would make one + # overwrite the other. + build-demo: + runs-on: ubuntu-latest + needs: detect-demo + if: github.event.action == 'prereleased' && needs.detect-demo.outputs.enabled == 'true' + # As on `build`: a protection rule on the environment gates the build too. + environment: demo + permissions: + # gh release upload of the built demo bundle + contents: write + steps: + - name: '📥 Checkout Code from Release Tag' + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + + - name: '🔨 Build App (demo, testnet mode)' + id: build + uses: centrifuge/github-actions-lib/actions/build-app@main + with: + app-name: ${{ inputs.app-name }}-demo + bundle-name-prefix: ${{ format('{0}-demo', inputs.bundle-name-prefix || inputs.app-name) }} + node-version: ${{ inputs.node-version }} + build-args: ${{ inputs.demo-build-args }} + build-env: ${{ inputs.demo-build-env }} + # No pool-cache-base-url: there is no testnet pool-cache Worker; + # these builds fall back to the repo's prebuilt snapshot. + github-token: ${{ github.token }} + + deploy-demo: + runs-on: ubuntu-latest + needs: [detect-demo, build-demo] + if: github.event.action == 'prereleased' && needs.detect-demo.outputs.enabled == 'true' environment: - name: public-demo - url: ${{ steps.deploy.outputs.deployment-url }} + name: demo + url: ${{ needs.detect-demo.outputs.url || steps.deploy.outputs.deployment-url }} permissions: contents: read + # wrangler-action records a GitHub deployment for the environment URL deployments: write + outputs: + deployment-url: ${{ steps.deploy.outputs.deployment-url }} steps: - name: '📥 Checkout Code' uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: persist-credentials: false - - name: '🚀 Deploy App to Public Demo' + - name: '🚀 Deploy App to Demo' id: deploy uses: centrifuge/github-actions-lib/actions/deploy-app@main with: - environment: public-demo - app-name: ${{ inputs.app-name }} + environment: demo + app-name: ${{ inputs.app-name }}-demo cloudflare-api-token: ${{ secrets.cloudflare-api-token }} cloudflare-account-id: ${{ inputs.cloudflare-account-id }} github-token: ${{ github.token }} @@ -210,12 +317,14 @@ jobs: RELEASE_URL="${SERVER_URL}/${REPOSITORY}/releases/tag/${TAG}" PROMOTE_URL="${SERVER_URL}/${REPOSITORY}/actions/workflows/promote-production.yml" DASH_URL="https://dash.cloudflare.com/${ACCOUNT_ID}/workers/services/view/${WORKER}/production/deployments" - GH_COMMAND="gh workflow run promote-production.yml --repo ${REPOSITORY} --ref main -f tag=${TAG}" + # No -f tag=...: the promote workflow takes no inputs, it resolves + # GitHub's latest release itself. Passing one is rejected by gh. + GH_COMMAND="gh workflow run promote-production.yml --repo ${REPOSITORY} --ref main" # printf per line keeps the message free of YAML/heredoc indentation. TEXT=$(printf '%s\n' \ "🚀 *${APP_NAME}* — release *${TAG}* is staged, not receiving production traffic yet." \ - "Staging already serves this version. Promotion is gated: a deployer listed in AUTHORIZED_DEPLOYERS must dispatch the promote workflow from main." \ + "Staging already serves this version. Promotion is gated: a deployer listed in AUTHORIZED_DEPLOYERS must dispatch the promote workflow from main. It promotes whatever GitHub reports as the latest release, so release *${TAG}* before promoting." \ "" \ "• Release: <${RELEASE_URL}|${TAG}> — by ${ACTOR}" \ "" \ diff --git a/.github/workflows/app-promote-production.yml b/.github/workflows/app-promote-production.yml index ef3d2fc..446a869 100644 --- a/.github/workflows/app-promote-production.yml +++ b/.github/workflows/app-promote-production.yml @@ -1,9 +1,14 @@ name: App Promote Production # Reusable gated production promotion. An app's thin `promote-production.yml` -# caller (workflow_dispatch, tag input) calls this after a `released` event +# caller (workflow_dispatch, no inputs) calls this after a `released` event # staged a version and posted the Slack notification. # +# There is no tag input: promotion targets GitHub's latest release (excludes +# drafts and prereleases), which is the tag the `released` event just staged. +# A newer prereleased tag does not become promotable until it is released. +# Promoting some other tag is app-rollback.yml's job, not this one. +# # The gate is GitHub-side, mirroring centrifuge/backend's # activate-production.yml: the dispatching actor must appear in the caller # repo's AUTHORIZED_DEPLOYERS variable (passed via the authorized-deployers @@ -19,9 +24,9 @@ name: App Promote Production # promote, and repo admins can edit the allowlist variable. The hard boundary # — who holds repo write/admin access and the Cloudflare token — is unchanged. # -# A tag can only be promoted if its commit is on the caller's main branch -# (hotfix releases cut from other branches are refused; the gated rollback -# path is the escape hatch if that is ever genuinely needed). +# The resolved tag can only be promoted if its commit is on the caller's main +# branch (hotfix releases cut from other branches are refused; the gated +# rollback path is the escape hatch if that is ever genuinely needed). # Pipeline-only pin, not caller-overridable — see README "Pinned # pipeline-only tool versions". Keep in sync with actions/deploy-app's @@ -33,10 +38,6 @@ env: on: workflow_call: inputs: - tag: - description: 'Release tag to promote (must have been prereleased — that is when its version was staged)' - required: true - type: string app-name: description: 'App name (used in Slack messages and log output)' required: true @@ -67,8 +68,33 @@ on: required: true jobs: + resolve-tag: + name: Resolve latest release + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + tag: ${{ steps.latest.outputs.tag }} + steps: + - name: '🔎 Look up the latest GitHub release' + id: latest + shell: bash + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + TAG=$(gh api "repos/${REPOSITORY}/releases/latest" --jq '.tag_name') + if [ -z "$TAG" ] || [ "$TAG" = "null" ]; then + echo "::error::No release found. GitHub's 'latest release' excludes drafts and prereleases — release (un-prerelease) a tag first." + exit 1 + fi + echo "Latest release: ${TAG}" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + authorize: name: Authorize deployer + needs: resolve-tag runs-on: ubuntu-latest permissions: contents: read @@ -108,7 +134,7 @@ jobs: env: SLACK_WEBHOOK_URL: ${{ secrets.slack-webhook-url }} APP_NAME: ${{ inputs.app-name }} - TAG: ${{ inputs.tag }} + TAG: ${{ needs.resolve-tag.outputs.tag }} ACTOR: ${{ github.actor }} run: | if [ "${{ steps.check-ref.outcome }}" = "failure" ]; then @@ -123,8 +149,8 @@ jobs: curl -sS -o /dev/null -X POST -H 'Content-type: application/json' --data "$PAYLOAD" "$SLACK_WEBHOOK_URL" || true promote: - name: 'Promote ${{ inputs.app-name }} ${{ inputs.tag }} to Production' - needs: authorize + name: 'Promote ${{ inputs.app-name }} ${{ needs.resolve-tag.outputs.tag }} to Production' + needs: [resolve-tag, authorize] runs-on: ubuntu-latest environment: name: production @@ -137,7 +163,7 @@ jobs: - name: '📥 Checkout Code from Release Tag' uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: - ref: ${{ inputs.tag }} + ref: ${{ needs.resolve-tag.outputs.tag }} # Full history: the on-main ancestry check below needs origin/main fetch-depth: 0 persist-credentials: false @@ -146,7 +172,7 @@ jobs: id: release shell: bash env: - TAG: ${{ inputs.tag }} + TAG: ${{ needs.resolve-tag.outputs.tag }} run: | set -euo pipefail RELEASE_SHA="$(git rev-parse --verify "refs/tags/${TAG}^{commit}")" @@ -174,7 +200,7 @@ jobs: CLOUDFLARE_ACCOUNT_ID: ${{ inputs.cloudflare-account-id }} CLOUDFLARE_API_TOKEN: ${{ secrets.cloudflare-api-token }} WORKER_NAME: ${{ inputs.production-worker-name }} - TAG: ${{ inputs.tag }} + TAG: ${{ needs.resolve-tag.outputs.tag }} run: | VERSION_ID=$(wrangler versions list --name "$WORKER_NAME" --env prod --json | jq -r ".[] | select(.annotations.\"workers/tag\" == \"$TAG\") | .id" | head -1) if [ -z "$VERSION_ID" ] || [ "$VERSION_ID" = "null" ]; then @@ -201,7 +227,7 @@ jobs: env: SLACK_WEBHOOK_URL: ${{ secrets.slack-webhook-url }} APP_NAME: ${{ inputs.app-name }} - TAG: ${{ inputs.tag }} + TAG: ${{ needs.resolve-tag.outputs.tag }} PRODUCTION_URL: ${{ inputs.production-url }} ACTOR: ${{ github.actor }} SERVER_URL: ${{ github.server_url }} @@ -228,7 +254,7 @@ jobs: env: SLACK_WEBHOOK_URL: ${{ secrets.slack-webhook-url }} APP_NAME: ${{ inputs.app-name }} - TAG: ${{ inputs.tag }} + TAG: ${{ needs.resolve-tag.outputs.tag }} run: | if [ "${{ steps.release.outcome }}" = "failure" ]; then REASON="release tag ${TAG} did not resolve to a commit on main" diff --git a/.github/workflows/app-rollback.yml b/.github/workflows/app-rollback.yml index 2be977e..cdbc8ce 100644 --- a/.github/workflows/app-rollback.yml +++ b/.github/workflows/app-rollback.yml @@ -5,6 +5,9 @@ name: App Rollback # (`versions deploy @100%`, no rebuild) # staging → download the release bundle and re-point the staging preview # alias (`versions upload --preview-alias staging`) +# demo → download the demo release bundle +# (`-demo-bundle.zip`) and redeploy the +# standalone demo Worker (`deploy --env demo`) # # NOTE: the prod path runs the same `versions deploy @100%` command as # gated promotion (app-promote-production.yml). It is the emergency @@ -14,6 +17,9 @@ name: App Rollback # a rollback. `authorized-deployers` is declared optional only so existing # callers keep parsing during rollout; at runtime an empty value refuses to # roll back — callers must pass their AUTHORIZED_DEPLOYERS variable. +# +# The gate applies to every environment, not just prod — demo and staging +# rollbacks need an authorized deployer too. # Pipeline-only pin, not caller-overridable — see README "Pinned # pipeline-only tool versions". Keep in sync with actions/deploy-app's @@ -29,7 +35,7 @@ on: required: true type: string environment: - description: "Environment to roll back ('staging' or 'prod')" + description: "Environment to roll back ('staging', 'demo' or 'prod')" required: false type: string default: 'prod' @@ -124,7 +130,7 @@ jobs: needs: authorize runs-on: ubuntu-latest environment: - name: ${{ inputs.environment == 'prod' && 'production' || 'staging' }} + name: ${{ inputs.environment == 'prod' && 'production' || inputs.environment }} url: ${{ steps.deploy.outputs.deployment-url }} permissions: contents: read @@ -155,7 +161,12 @@ jobs: GH_REPOSITORY: ${{ github.repository }} TAG: ${{ inputs.tag }} BUNDLE_PREFIX: ${{ inputs.bundle-name-prefix || inputs.app-name }} + INPUT_ENVIRONMENT: ${{ inputs.environment }} run: | + # Demo bundles are attached under their own suffixed prefix + if [ "$INPUT_ENVIRONMENT" = "demo" ]; then + BUNDLE_PREFIX="${BUNDLE_PREFIX}-demo" + fi BUNDLE_NAME="${BUNDLE_PREFIX}-bundle${TAG}.zip" gh release download "$TAG" \ --repo "$GH_REPOSITORY" \ @@ -184,8 +195,10 @@ jobs: CMD="versions deploy $VERSION_ID@100% --env prod --yes" elif [ "$INPUT_ENVIRONMENT" = "staging" ]; then CMD="versions upload --env prod --message \"Rollback to $TAG\" --preview-alias staging" + elif [ "$INPUT_ENVIRONMENT" = "demo" ]; then + CMD="deploy --env demo" else - echo "::error::Environment '$INPUT_ENVIRONMENT' is not supported (use 'staging' or 'prod')." + echo "::error::Environment '$INPUT_ENVIRONMENT' is not supported (use 'staging', 'demo' or 'prod')." exit 1 fi echo "Wrangler command: $CMD" diff --git a/CLAUDE.md b/CLAUDE.md index 10cfbb9..5e4b9ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,6 +67,10 @@ publishes production app code**. Treat every change as a supply-chain change. allowlist. Do not re-add an automatic prod deploy, and do not weaken the fail-closed allowlist checks, without an explicit decision recorded in the PR. +- **`app-promote-production.yml` takes no `tag` input.** It resolves GitHub's + latest release (excludes drafts and prereleases) and promotes that, so + there is nowhere to type an arbitrary tag. Promoting a specific non-latest + tag is `app-rollback.yml`'s job. Do not re-add a `tag` input here. - **`app-rollback.yml`'s prod path retains `versions deploy` on purpose** — it is the emergency traffic-shift path — but it is gated by the same `AUTHORIZED_DEPLOYERS` allowlist (fail-closed). Its `authorized-deployers` @@ -75,6 +79,25 @@ publishes production app code**. Treat every change as a supply-chain change. - **Release bundles are immutable.** `build-app` fails rather than overwrite an existing release asset; the deploy/rollback flows depend on that bundle not changing under a fixed tag. +- **The `demo` leg is a second, independent pipeline on the same prerelease**: + its own testnet-mode build (`--mode testnet` — the chain, not the + environment name), artifact and release bundle, and a direct + `wrangler deploy --env demo` to a standalone Worker — no staging alias or + version promotion, and it never touches the production Worker. Keep the + artifact/bundle names suffixed or the two builds overwrite each other. +- **It activates from the caller's `wrangler.toml`, not an input.** + `detect-demo` enables the leg when `[env.demo]` exists and takes the + deployment URL from its `custom_domain` route. Do not re-add a + `deploy-demo`/`demo-url` input; for future environments follow the same + pattern (README, "Adding an environment") and parse with `tomllib`, never + `grep`. +- **Environment vocabulary** (one name per tier, used for the pipeline input, + the wrangler env and the GitHub environment): `preview` (PR build), + `nightly` (tracks `main`), `demo` (release tag on testnet data), `staging` + (release tag on mainnet, a version alias on the prod Worker), `prod`. + `preview` and `nightly` share one Worker; `staging` and `prod` share + another. Only two names differ from their wrangler env: `staging`→`prod` + and `preview`→`nightly`. ## Layout @@ -82,10 +105,10 @@ publishes production app code**. Treat every change as a supply-chain change. .github/workflows/ lib-ci.yml # this repo's own CI: actionlint + pinact + yamllint app-ci-checks.yml # workflow_call: format/lint/audit/secrets-scan/pinact - app-build-deploy-dev.yml # workflow_call: PR preview + demo (+ Lighthouse) - app-build-deploy-release.yml # workflow_call: prerelease→staging, release→notify + app-build-deploy-dev.yml # workflow_call: PR preview + nightly (+ Lighthouse) + app-build-deploy-release.yml # workflow_call: prerelease→staging (+opt. demo), release→notify app-promote-production.yml # workflow_call: allowlist-gated prod traffic shift - app-rollback.yml # workflow_call: gated prod traffic-shift / staging re-upload + app-rollback.yml # workflow_call: gated prod traffic-shift / staging|demo re-upload actions/ setup-app/ # pnpm (from packageManager) + node + cache build-app/ # build + artifact upload + release bundle diff --git a/README.md b/README.md index 4b40306..0f284d1 100644 --- a/README.md +++ b/README.md @@ -11,15 +11,15 @@ consumer. ``` .github/workflows/ app-ci-checks.yml PR quality gate: format-n-lint, pnpm-audit (own check), TruffleHog, pinact - app-build-deploy-dev.yml PR → preview deploy; push to main → demo deploy; optional Lighthouse - app-build-deploy-release.yml prereleased → staging (+ optional public-demo); released → promotion notice + app-build-deploy-dev.yml PR → preview deploy; push to main → nightly deploy; optional Lighthouse + app-build-deploy-release.yml prereleased → staging (+ optional demo); released → promotion notice app-promote-production.yml allowlist-gated production promotion (workflow_dispatch caller) - app-rollback.yml allowlist-gated rollback (prod: version traffic shift; staging: bundle re-upload) + app-rollback.yml allowlist-gated rollback (prod: version traffic shift; staging/demo: bundle redeploy) lib-ci.yml this repo's own CI (actionlint, pinact, yamllint) actions/ setup-app/ Node + pnpm bootstrap (pnpm version from package.json "packageManager") build-app/ build + artifact upload (+ release bundle upload on prerelease) - deploy-app/ wrangler deploy per environment (dev/demo/public-demo/staging/prod) + deploy-app/ wrangler deploy per environment (preview/nightly/demo/staging/prod) lighthouserc.json shared LHCI config used by app-build-deploy-dev's performance job ``` @@ -120,25 +120,37 @@ Every input/secret/output is documented inline in each workflow's Output: `deployment-url`. - **`app-build-deploy-release.yml`** — same core contract plus `bundle-name-prefix` (release zip name, defaults to `app-name`), - `pool-cache-base-url`, `deploy-public-demo`, `production-url`, - `production-worker-name`. Secrets: `cloudflare-api-token`, - `slack-webhook-url`. Output: `staging-url`. **A tag must be `prereleased` - before it can be `released`.** `prereleased` builds once and deploys to - staging; `released` does **not** deploy — it posts a Slack notification - with the gated promotion instructions (`gh workflow run - promote-production.yml ...`). The notification links to the caller repo's + `pool-cache-base-url`, `production-url`, + `production-worker-name`, plus `demo-build-args` (default `--mode testnet`) + and `demo-build-env`. **The demo leg has no opt-in input**: prereleases run + a second, testnet-mode build and `wrangler deploy --env demo` it whenever + the caller's `wrangler.toml` declares an `[env.demo]` section, taking the + deployment URL from that section's custom-domain route. It uploads its own + artifact (`-demo-build-`) and release bundle + (`-demo-bundle.zip`). Secrets: + `cloudflare-api-token`, `slack-webhook-url`. Outputs: `staging-url`, + `demo-url`. **A tag must be `prereleased` before it can be + `released`.** `prereleased` builds once and deploys to staging; + `released` does **not** deploy — it posts a Slack notification with the + gated promotion instructions (`gh workflow run promote-production.yml + ...`). The notification links to the caller repo's `promote-production.yml`, so name your promote caller exactly that. + The demo leg is prerelease-only and never touches production. Release bundles are immutable: rebuilding a tag whose bundle already exists fails; cut a new prerelease, or delete the asset from the release page to rebuild the same tag. -- **`app-promote-production.yml`** — required: `tag`, `app-name`, +- **`app-promote-production.yml`** — required: `app-name`, `production-worker-name`, `cloudflare-account-id`, `authorized-deployers`; secrets `cloudflare-api-token`, `slack-webhook-url`. Called from a - `workflow_dispatch` caller; shifts production traffic to the Worker version - staged for `tag` (`versions deploy @100%`). Gated: the dispatching - actor must be in `authorized-deployers` (pass `vars.AUTHORIZED_DEPLOYERS`; - empty fails closed), the dispatch must run from `main`, and the tag's - commit must be on `main`. Failed attempts and successful promotions are + `workflow_dispatch` caller with no inputs of its own. **No `tag` input** — + it resolves GitHub's own "latest release" (excludes drafts and + prereleases) and shifts production traffic to the Worker version staged + for that tag (`versions deploy @100%`). An arbitrary tag can't be + promoted through this workflow; that's what the gated rollback path is + for. Gated: the dispatching actor must be in `authorized-deployers` (pass + `vars.AUTHORIZED_DEPLOYERS`; empty fails closed), the dispatch must run + from `main`, and the resolved tag's commit must be on `main`. Failed + attempts and successful promotions are announced to Slack. Mirrors centrifuge/backend's activate-production model; a process control, not a hard control (see the workflow header). - **`app-rollback.yml`** — required: `tag`, `app-name`, @@ -146,9 +158,26 @@ Every input/secret/output is documented inline in each workflow's `slack-webhook-url` for failed-attempt alerts). `environment: prod` (default) shifts traffic to the version tagged with `tag`; `environment: staging` re-uploads the release bundle behind the staging - preview alias. Gated by the same `authorized-deployers` allowlist as - promotion — declared optional for parse-compat, but empty **fails closed - at runtime**; callers must pass `vars.AUTHORIZED_DEPLOYERS`. + preview alias; `environment: demo` redeploys the demo release bundle to + the standalone demo Worker. Gated by the same `authorized-deployers` + allowlist as promotion — for every environment, demo included — declared optional for parse-compat, but empty **fails + closed at runtime**; callers must pass `vars.AUTHORIZED_DEPLOYERS`. + +## Adding an environment + +Derive the deployment target from config the app repo already owns rather +than adding a caller input for it: an input duplicates what `wrangler.toml` +already states, and forces every consumer into a workflow PR to adopt the +feature. `detect-demo` is the worked example — declaring `[env.demo]` is the +opt-in, and adopting it needs no caller-workflow edit. + +Parse that config with `tomllib` in a `python3` step, not `grep`. +`actions/deploy-app` still greps for the prod Worker name; comments, key +order or quoting can change its answer. Don't add more of it. + +Note the contract this implies: a Worker section added purely for local +`wrangler dev` becomes a real deploy target. Keep the pattern for deployment +targets, not for behavioral flags. ## Rules for consumers diff --git a/actions/deploy-app/action.yml b/actions/deploy-app/action.yml index 88a1503..1a9a2b9 100644 --- a/actions/deploy-app/action.yml +++ b/actions/deploy-app/action.yml @@ -6,7 +6,7 @@ description: >- inputs: environment: - description: 'Deployment environment (dev, demo, public-demo, staging, prod)' + description: 'Deployment environment (preview, nightly, demo, staging, prod)' required: true app-name: description: 'Prefix used for the run-scoped build artifact name (must match build-app)' @@ -74,13 +74,13 @@ runs: GITHUB_REF_NAME: ${{ github.ref_name }} PR_NUMBER: ${{ github.event.pull_request.number }} run: | - # Map pipeline environment names to wrangler environment names - if [ "$INPUT_ENVIRONMENT" = "demo" ]; then - WRANGLER_ENV="dev" - elif [ "$INPUT_ENVIRONMENT" = "public-demo" ]; then - WRANGLER_ENV="demo" - elif [ "$INPUT_ENVIRONMENT" = "staging" ]; then + # Pipeline and wrangler environment names match, except: staging is a + # version alias on the prod Worker, and PR previews are unpromoted + # versions of the nightly Worker. + if [ "$INPUT_ENVIRONMENT" = "staging" ]; then WRANGLER_ENV="prod" + elif [ "$INPUT_ENVIRONMENT" = "preview" ]; then + WRANGLER_ENV="nightly" else WRANGLER_ENV="$INPUT_ENVIRONMENT" fi @@ -99,12 +99,12 @@ runs: echo "Error: No version found for tag $GITHUB_REF_NAME" exit 1 fi - elif [ "$INPUT_ENVIRONMENT" = "dev" ]; then - # PR: Upload with PR info + elif [ "$INPUT_ENVIRONMENT" = "preview" ]; then + # PR: Upload an unpromoted version, tagged with the PR SHORT_SHA=$(echo "$GITHUB_SHA" | cut -c1-7) CMD="versions upload --env $WRANGLER_ENV --tag $SHORT_SHA --message \"PR #$PR_NUMBER - $SHORT_SHA\"" - elif [ "$INPUT_ENVIRONMENT" = "demo" ] || [ "$INPUT_ENVIRONMENT" = "public-demo" ]; then - # Demo environments: Direct deploy + elif [ "$INPUT_ENVIRONMENT" = "nightly" ] || [ "$INPUT_ENVIRONMENT" = "demo" ]; then + # Standalone Workers: direct deploy CMD="deploy --env $WRANGLER_ENV" else echo "Error: Environment '$INPUT_ENVIRONMENT' is not defined or supported."