Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions .github/workflows/app-build-deploy-dev.yml
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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 }}
Expand All @@ -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 }}
Expand Down
147 changes: 128 additions & 19 deletions .github/workflows/app-build-deploy-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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}" \
"" \
Expand Down
58 changes: 42 additions & 16 deletions .github/workflows/app-promote-production.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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}")"
Expand Down Expand Up @@ -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
Expand All @@ -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 }}
Expand All @@ -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"
Expand Down
Loading
Loading