From f8679ad1be21d37106590013778d0b1bf406fddd Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 10 Jun 2026 15:10:24 -0700 Subject: [PATCH 1/6] ci: dispatch GitHub Deployments for preview, staging, and production Adds a workflow that turns every deployable ref into a GitHub Deployment carrying a digest-pinned image reference: - pull request branches -> transient per-PR preview-pr- environment (deactivated automatically when the PR closes) - pushes to main -> staging - published releases -> production - workflow_dispatch -> manual staging/production dispatch Each run builds and pushes the Docker image for the exact deployed commit to ghcr (sha-pinned, plus pr-N / staging / semver convenience tags), then creates the Deployment with the image in its payload and seeds a `queued` status. The actual rollout is intentionally left to whatever subscribes to deployment events (webhook listener, Argo CD, or a follow-up workflow), which owns the in_progress -> success/failure status transitions. Actions are pinned to commit SHAs per the repo convention (#67). Co-Authored-By: Claude Fable 5 --- .github/workflows/dispatch-deployment.yml | 233 ++++++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 .github/workflows/dispatch-deployment.yml diff --git a/.github/workflows/dispatch-deployment.yml b/.github/workflows/dispatch-deployment.yml new file mode 100644 index 0000000..a5b3abd --- /dev/null +++ b/.github/workflows/dispatch-deployment.yml @@ -0,0 +1,233 @@ +# Dispatches a GitHub Deployment for every deployable ref: +# +# - pull request branches -> per-PR "preview-pr-" environment +# - push to main (staging) -> "staging" environment +# - published releases -> "production" environment +# +# Each run builds and pushes the Docker image for the exact commit being +# deployed, then creates a GitHub Deployment whose payload carries the +# immutable (digest-pinned) image reference. The actual rollout is left to +# whatever operates the environments — a webhook listener, Argo CD, or a +# follow-up workflow subscribed to `deployment` events — which should update +# the deployment status as it progresses. +# +# Preview deployments are marked transient and are deactivated when the PR +# closes. + +name: Dispatch Deployment + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + - closed + push: + branches: + - 'main' + release: + types: + - published + workflow_dispatch: + inputs: + environment: + description: Target environment + type: choice + options: + - staging + - production + default: staging + +# One in-flight dispatch per ref; a newer commit supersedes the older build. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + packages: write + deployments: write + +jobs: + # Map the trigger onto an environment and the commit to deploy. + resolve: + if: >- + github.event_name != 'pull_request' || + (github.event.action != 'closed' && github.event.pull_request.draft == false) + runs-on: ubuntu-latest + outputs: + environment: ${{ steps.target.outputs.environment }} + sha: ${{ steps.target.outputs.sha }} + transient: ${{ steps.target.outputs.transient }} + production: ${{ steps.target.outputs.production }} + steps: + - name: Resolve deployment target + id: target + env: + EVENT_NAME: ${{ github.event_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + INPUT_ENVIRONMENT: ${{ inputs.environment }} + SHA: ${{ github.sha }} + run: | + case "$EVENT_NAME" in + pull_request) + environment="preview-pr-${PR_NUMBER}" + sha="$PR_HEAD_SHA" + ;; + push) + environment="staging" + sha="$SHA" + ;; + release) + environment="production" + sha="$SHA" + ;; + workflow_dispatch) + environment="$INPUT_ENVIRONMENT" + sha="$SHA" + ;; + esac + { + echo "environment=$environment" + echo "sha=$sha" + echo "transient=${{ github.event_name == 'pull_request' }}" + echo "production=$([ "$environment" = production ] && echo true || echo false)" + } >> "$GITHUB_OUTPUT" + + # Build and push the image for the deployed commit so the rollout target is + # immutable and independent of branch movement. + build-push: + needs: resolve + runs-on: ubuntu-latest + outputs: + image: ghcr.io/${{ github.repository }}@${{ steps.push.outputs.digest }} + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ needs.resolve.outputs.sha }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata for Docker + id: meta + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=sha,format=long + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=staging,enable=${{ github.ref == 'refs/heads/main' }} + + - name: Build and push Docker image + id: push + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + # Create the GitHub Deployment that downstream automation acts on. + dispatch: + needs: + - resolve + - build-push + runs-on: ubuntu-latest + steps: + - name: Create deployment + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + ENVIRONMENT: ${{ needs.resolve.outputs.environment }} + DEPLOY_SHA: ${{ needs.resolve.outputs.sha }} + IMAGE: ${{ needs.build-push.outputs.image }} + TRANSIENT: ${{ needs.resolve.outputs.transient }} + PRODUCTION: ${{ needs.resolve.outputs.production }} + with: + script: | + const { ENVIRONMENT, DEPLOY_SHA, IMAGE, TRANSIENT, PRODUCTION } = + process.env; + + const { data: deployment } = await github.rest.repos.createDeployment({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: DEPLOY_SHA, + environment: ENVIRONMENT, + auto_merge: false, + // Checks run in parallel with this workflow; don't fail the + // dispatch because they haven't reported yet. + required_contexts: [], + transient_environment: TRANSIENT === 'true', + production_environment: PRODUCTION === 'true', + payload: { + image: IMAGE, + sha: DEPLOY_SHA, + triggered_by: context.eventName + }, + description: `Deploy ${DEPLOY_SHA.slice(0, 7)} to ${ENVIRONMENT}` + }); + + // Seed a queued status pointing back at this run; the deployer + // takes over from here (in_progress -> success/failure). + await github.rest.repos.createDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: deployment.id, + state: 'queued', + log_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, + description: `Image ${IMAGE} ready for rollout` + }); + + core.notice( + `Dispatched deployment ${deployment.id} of ${IMAGE} to ${ENVIRONMENT}` + ); + + # When a PR closes, retire its preview environment so stale deployments + # don't accumulate in the Deployments UI. + teardown-preview: + if: github.event_name == 'pull_request' && github.event.action == 'closed' + runs-on: ubuntu-latest + steps: + - name: Deactivate preview deployments + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + ENVIRONMENT: preview-pr-${{ github.event.pull_request.number }} + with: + script: | + const { ENVIRONMENT } = process.env; + + const deployments = await github.paginate( + github.rest.repos.listDeployments, + { + owner: context.repo.owner, + repo: context.repo.repo, + environment: ENVIRONMENT + } + ); + + for (const deployment of deployments) { + await github.rest.repos.createDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: deployment.id, + state: 'inactive', + description: 'PR closed; preview retired' + }); + } + + core.notice( + `Deactivated ${deployments.length} deployment(s) in ${ENVIRONMENT}` + ); From 10f7a251329ca65fc737bac1516966d87c83616b Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 10 Jun 2026 15:23:25 -0700 Subject: [PATCH 2/6] ci: deploy to Cloudflare Pages instead of dispatching deployments Replace the deployment-dispatch workflow with a direct Cloudflare Pages deploy: PR branches get isolated pr- previews, pushes to main serve as staging via the main branch alias, and published releases promote to the project's production branch (release). Builds run in CI and upload via wrangler so the monorepo build and repo-variable env config stay identical to deploy-gh.yml. Each PR gets a single self-updating preview-URL comment. Co-Authored-By: Claude Fable 5 --- .github/workflows/deploy-pages.yml | 169 ++++++++++++++++ .github/workflows/dispatch-deployment.yml | 233 ---------------------- 2 files changed, 169 insertions(+), 233 deletions(-) create mode 100644 .github/workflows/deploy-pages.yml delete mode 100644 .github/workflows/dispatch-deployment.yml diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml new file mode 100644 index 0000000..4c83d3a --- /dev/null +++ b/.github/workflows/deploy-pages.yml @@ -0,0 +1,169 @@ +# Deploys the client to Cloudflare Pages for every deployable ref: +# +# - pull request branches -> pr-..pages.dev (preview) +# - push to main -> main..pages.dev (staging) +# - published releases -> .pages.dev (production) +# +# The Cloudflare Pages project must have its production branch set to +# "release"; the "main" branch alias then serves as a stable staging URL and +# every PR gets an isolated preview. Builds run here (direct upload via +# wrangler) rather than through Cloudflare's git integration because the +# monorepo needs `plugins:build` before the client build and pulls its env +# vars from repository variables. +# +# Required repository secrets: +# CLOUDFLARE_API_TOKEN - API token with the Cloudflare Pages:Edit scope +# CLOUDFLARE_ACCOUNT_ID - target Cloudflare account +# +# Optional repository variable: +# CLOUDFLARE_PAGES_PROJECT - Pages project name (default: stac-manager) +# +# Note for OIDC setups: preview URLs are per-PR, so the OIDC client needs a +# wildcard redirect URI covering https://*..pages.dev, or previews +# run with auth disabled. + +name: Deploy Cloudflare Pages + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + push: + branches: + - 'main' + release: + types: + - published + workflow_dispatch: + inputs: + environment: + description: Target environment + type: choice + options: + - staging + - production + default: staging + +env: + # Cloudflare Pages serves every deployment from the domain root. + PUBLIC_URL: '' + REACT_APP_STAC_API: ${{ vars.REACT_APP_STAC_API }} + REACT_APP_STAC_BROWSER: ${{ vars.REACT_APP_STAC_BROWSER }} + PAGES_PROJECT: ${{ vars.CLOUDFLARE_PAGES_PROJECT || 'stac-manager' }} + +# One in-flight deploy per ref; a newer commit supersedes the older build. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + deploy: + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + + # Map the trigger onto a wrangler branch. The Pages project's + # production branch is "release"; everything else is a preview alias. + - name: Resolve deployment target + id: target + env: + EVENT_NAME: ${{ github.event_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + INPUT_ENVIRONMENT: ${{ inputs.environment }} + run: | + case "$EVENT_NAME" in + pull_request) + branch="pr-${PR_NUMBER}" + ;; + push) + branch="main" + ;; + release) + branch="release" + ;; + workflow_dispatch) + if [ "$INPUT_ENVIRONMENT" = production ]; then + branch="release" + else + branch="main" + fi + ;; + esac + echo "branch=$branch" >> "$GITHUB_OUTPUT" + + - name: Use Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version-file: '.nvmrc' + + - name: Cache node_modules + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: node_modules + key: ${{ runner.os }}-build-${{ hashFiles('**/package.json') }} + + - name: Install + run: npm install + + - name: Create .env file + run: mv packages/client/.env.example packages/client/.env + + - name: Build + run: npm run all:build + + - name: Add SPA redirects + run: printf '/* /index.html 200\n' > packages/client/dist/_redirects + + - name: Deploy to Cloudflare Pages + id: deploy + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + BRANCH: ${{ steps.target.outputs.branch }} + run: | + npx wrangler@4 pages deploy packages/client/dist \ + --project-name="$PAGES_PROJECT" \ + --branch="$BRANCH" \ + --commit-hash="$(git rev-parse HEAD)" \ + --commit-dirty=true | tee deploy.log + url=$(grep -Eo 'https://[a-z0-9.-]+\.pages\.dev' deploy.log | tail -n1) + echo "url=$url" >> "$GITHUB_OUTPUT" + + # Keep a single up-to-date preview-URL comment on the PR. + - name: Comment preview URL + if: github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + BRANCH: ${{ steps.target.outputs.branch }} + URL: ${{ steps.deploy.outputs.url }} + SHA: ${{ github.event.pull_request.head.sha }} + run: | + marker='' + alias_url="https://${BRANCH}.${PAGES_PROJECT}.pages.dev" + body="${marker} + ### Cloudflare Pages preview + + | Latest deploy | Branch alias | + |---|---| + | ${URL} | ${alias_url} | + + Built from ${SHA}." + comment_id=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \ + --jq ".[] | select(.body | startswith(\"$marker\")) | .id" | head -n1) + if [ -n "$comment_id" ]; then + gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${comment_id}" -f body="$body" + else + gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" -f body="$body" + fi diff --git a/.github/workflows/dispatch-deployment.yml b/.github/workflows/dispatch-deployment.yml deleted file mode 100644 index a5b3abd..0000000 --- a/.github/workflows/dispatch-deployment.yml +++ /dev/null @@ -1,233 +0,0 @@ -# Dispatches a GitHub Deployment for every deployable ref: -# -# - pull request branches -> per-PR "preview-pr-" environment -# - push to main (staging) -> "staging" environment -# - published releases -> "production" environment -# -# Each run builds and pushes the Docker image for the exact commit being -# deployed, then creates a GitHub Deployment whose payload carries the -# immutable (digest-pinned) image reference. The actual rollout is left to -# whatever operates the environments — a webhook listener, Argo CD, or a -# follow-up workflow subscribed to `deployment` events — which should update -# the deployment status as it progresses. -# -# Preview deployments are marked transient and are deactivated when the PR -# closes. - -name: Dispatch Deployment - -on: - pull_request: - types: - - opened - - synchronize - - reopened - - ready_for_review - - closed - push: - branches: - - 'main' - release: - types: - - published - workflow_dispatch: - inputs: - environment: - description: Target environment - type: choice - options: - - staging - - production - default: staging - -# One in-flight dispatch per ref; a newer commit supersedes the older build. -concurrency: - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - packages: write - deployments: write - -jobs: - # Map the trigger onto an environment and the commit to deploy. - resolve: - if: >- - github.event_name != 'pull_request' || - (github.event.action != 'closed' && github.event.pull_request.draft == false) - runs-on: ubuntu-latest - outputs: - environment: ${{ steps.target.outputs.environment }} - sha: ${{ steps.target.outputs.sha }} - transient: ${{ steps.target.outputs.transient }} - production: ${{ steps.target.outputs.production }} - steps: - - name: Resolve deployment target - id: target - env: - EVENT_NAME: ${{ github.event_name }} - PR_NUMBER: ${{ github.event.pull_request.number }} - PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - INPUT_ENVIRONMENT: ${{ inputs.environment }} - SHA: ${{ github.sha }} - run: | - case "$EVENT_NAME" in - pull_request) - environment="preview-pr-${PR_NUMBER}" - sha="$PR_HEAD_SHA" - ;; - push) - environment="staging" - sha="$SHA" - ;; - release) - environment="production" - sha="$SHA" - ;; - workflow_dispatch) - environment="$INPUT_ENVIRONMENT" - sha="$SHA" - ;; - esac - { - echo "environment=$environment" - echo "sha=$sha" - echo "transient=${{ github.event_name == 'pull_request' }}" - echo "production=$([ "$environment" = production ] && echo true || echo false)" - } >> "$GITHUB_OUTPUT" - - # Build and push the image for the deployed commit so the rollout target is - # immutable and independent of branch movement. - build-push: - needs: resolve - runs-on: ubuntu-latest - outputs: - image: ghcr.io/${{ github.repository }}@${{ steps.push.outputs.digest }} - steps: - - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - ref: ${{ needs.resolve.outputs.sha }} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - - - name: Login to GitHub Container Registry - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata for Docker - id: meta - uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 - with: - images: ghcr.io/${{ github.repository }} - tags: | - type=sha,format=long - type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=raw,value=staging,enable=${{ github.ref == 'refs/heads/main' }} - - - name: Build and push Docker image - id: push - uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 - with: - context: . - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - - # Create the GitHub Deployment that downstream automation acts on. - dispatch: - needs: - - resolve - - build-push - runs-on: ubuntu-latest - steps: - - name: Create deployment - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - ENVIRONMENT: ${{ needs.resolve.outputs.environment }} - DEPLOY_SHA: ${{ needs.resolve.outputs.sha }} - IMAGE: ${{ needs.build-push.outputs.image }} - TRANSIENT: ${{ needs.resolve.outputs.transient }} - PRODUCTION: ${{ needs.resolve.outputs.production }} - with: - script: | - const { ENVIRONMENT, DEPLOY_SHA, IMAGE, TRANSIENT, PRODUCTION } = - process.env; - - const { data: deployment } = await github.rest.repos.createDeployment({ - owner: context.repo.owner, - repo: context.repo.repo, - ref: DEPLOY_SHA, - environment: ENVIRONMENT, - auto_merge: false, - // Checks run in parallel with this workflow; don't fail the - // dispatch because they haven't reported yet. - required_contexts: [], - transient_environment: TRANSIENT === 'true', - production_environment: PRODUCTION === 'true', - payload: { - image: IMAGE, - sha: DEPLOY_SHA, - triggered_by: context.eventName - }, - description: `Deploy ${DEPLOY_SHA.slice(0, 7)} to ${ENVIRONMENT}` - }); - - // Seed a queued status pointing back at this run; the deployer - // takes over from here (in_progress -> success/failure). - await github.rest.repos.createDeploymentStatus({ - owner: context.repo.owner, - repo: context.repo.repo, - deployment_id: deployment.id, - state: 'queued', - log_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, - description: `Image ${IMAGE} ready for rollout` - }); - - core.notice( - `Dispatched deployment ${deployment.id} of ${IMAGE} to ${ENVIRONMENT}` - ); - - # When a PR closes, retire its preview environment so stale deployments - # don't accumulate in the Deployments UI. - teardown-preview: - if: github.event_name == 'pull_request' && github.event.action == 'closed' - runs-on: ubuntu-latest - steps: - - name: Deactivate preview deployments - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - ENVIRONMENT: preview-pr-${{ github.event.pull_request.number }} - with: - script: | - const { ENVIRONMENT } = process.env; - - const deployments = await github.paginate( - github.rest.repos.listDeployments, - { - owner: context.repo.owner, - repo: context.repo.repo, - environment: ENVIRONMENT - } - ); - - for (const deployment of deployments) { - await github.rest.repos.createDeploymentStatus({ - owner: context.repo.owner, - repo: context.repo.repo, - deployment_id: deployment.id, - state: 'inactive', - description: 'PR closed; preview retired' - }); - } - - core.notice( - `Deactivated ${deployments.length} deployment(s) in ${ENVIRONMENT}` - ); From cf02c34033b6d82c323dda847c94125cc1839dba Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 10 Jun 2026 15:31:03 -0700 Subject: [PATCH 3/6] ci: hand deployment to the Cloudflare Pages GitHub App Drop the Actions deploy workflow in favor of Cloudflare's git integration: the GitHub App builds and deploys every push, giving per-branch previews, a main staging alias, and a release production branch with no repo secrets. Add the SPA _redirects file the build command copies into dist, and document the project configuration in docs/DEPLOYMENT.md. Co-Authored-By: Claude Fable 5 --- .github/workflows/deploy-pages.yml | 169 ----------------------------- docs/DEPLOYMENT.md | 53 +++++++++ packages/client/_redirects | 1 + 3 files changed, 54 insertions(+), 169 deletions(-) delete mode 100644 .github/workflows/deploy-pages.yml create mode 100644 docs/DEPLOYMENT.md create mode 100644 packages/client/_redirects diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml deleted file mode 100644 index 4c83d3a..0000000 --- a/.github/workflows/deploy-pages.yml +++ /dev/null @@ -1,169 +0,0 @@ -# Deploys the client to Cloudflare Pages for every deployable ref: -# -# - pull request branches -> pr-..pages.dev (preview) -# - push to main -> main..pages.dev (staging) -# - published releases -> .pages.dev (production) -# -# The Cloudflare Pages project must have its production branch set to -# "release"; the "main" branch alias then serves as a stable staging URL and -# every PR gets an isolated preview. Builds run here (direct upload via -# wrangler) rather than through Cloudflare's git integration because the -# monorepo needs `plugins:build` before the client build and pulls its env -# vars from repository variables. -# -# Required repository secrets: -# CLOUDFLARE_API_TOKEN - API token with the Cloudflare Pages:Edit scope -# CLOUDFLARE_ACCOUNT_ID - target Cloudflare account -# -# Optional repository variable: -# CLOUDFLARE_PAGES_PROJECT - Pages project name (default: stac-manager) -# -# Note for OIDC setups: preview URLs are per-PR, so the OIDC client needs a -# wildcard redirect URI covering https://*..pages.dev, or previews -# run with auth disabled. - -name: Deploy Cloudflare Pages - -on: - pull_request: - types: - - opened - - synchronize - - reopened - - ready_for_review - push: - branches: - - 'main' - release: - types: - - published - workflow_dispatch: - inputs: - environment: - description: Target environment - type: choice - options: - - staging - - production - default: staging - -env: - # Cloudflare Pages serves every deployment from the domain root. - PUBLIC_URL: '' - REACT_APP_STAC_API: ${{ vars.REACT_APP_STAC_API }} - REACT_APP_STAC_BROWSER: ${{ vars.REACT_APP_STAC_BROWSER }} - PAGES_PROJECT: ${{ vars.CLOUDFLARE_PAGES_PROJECT || 'stac-manager' }} - -# One in-flight deploy per ref; a newer commit supersedes the older build. -concurrency: - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - pull-requests: write - -jobs: - deploy: - if: github.event_name != 'pull_request' || github.event.pull_request.draft == false - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - - # Map the trigger onto a wrangler branch. The Pages project's - # production branch is "release"; everything else is a preview alias. - - name: Resolve deployment target - id: target - env: - EVENT_NAME: ${{ github.event_name }} - PR_NUMBER: ${{ github.event.pull_request.number }} - INPUT_ENVIRONMENT: ${{ inputs.environment }} - run: | - case "$EVENT_NAME" in - pull_request) - branch="pr-${PR_NUMBER}" - ;; - push) - branch="main" - ;; - release) - branch="release" - ;; - workflow_dispatch) - if [ "$INPUT_ENVIRONMENT" = production ]; then - branch="release" - else - branch="main" - fi - ;; - esac - echo "branch=$branch" >> "$GITHUB_OUTPUT" - - - name: Use Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version-file: '.nvmrc' - - - name: Cache node_modules - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 - with: - path: node_modules - key: ${{ runner.os }}-build-${{ hashFiles('**/package.json') }} - - - name: Install - run: npm install - - - name: Create .env file - run: mv packages/client/.env.example packages/client/.env - - - name: Build - run: npm run all:build - - - name: Add SPA redirects - run: printf '/* /index.html 200\n' > packages/client/dist/_redirects - - - name: Deploy to Cloudflare Pages - id: deploy - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - BRANCH: ${{ steps.target.outputs.branch }} - run: | - npx wrangler@4 pages deploy packages/client/dist \ - --project-name="$PAGES_PROJECT" \ - --branch="$BRANCH" \ - --commit-hash="$(git rev-parse HEAD)" \ - --commit-dirty=true | tee deploy.log - url=$(grep -Eo 'https://[a-z0-9.-]+\.pages\.dev' deploy.log | tail -n1) - echo "url=$url" >> "$GITHUB_OUTPUT" - - # Keep a single up-to-date preview-URL comment on the PR. - - name: Comment preview URL - if: github.event_name == 'pull_request' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number }} - BRANCH: ${{ steps.target.outputs.branch }} - URL: ${{ steps.deploy.outputs.url }} - SHA: ${{ github.event.pull_request.head.sha }} - run: | - marker='' - alias_url="https://${BRANCH}.${PAGES_PROJECT}.pages.dev" - body="${marker} - ### Cloudflare Pages preview - - | Latest deploy | Branch alias | - |---|---| - | ${URL} | ${alias_url} | - - Built from ${SHA}." - comment_id=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \ - --jq ".[] | select(.body | startswith(\"$marker\")) | .id" | head -n1) - if [ -n "$comment_id" ]; then - gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${comment_id}" -f body="$body" - else - gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" -f body="$body" - fi diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..eea8d79 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,53 @@ +# Deployment (Cloudflare Pages) + +The client is a static single-page app, deployed to Cloudflare Pages via the +[Cloudflare GitHub App](https://dash.cloudflare.com/?to=/:account/workers-and-pages) +(git integration). Cloudflare builds and deploys on every push — there is no +deploy workflow in this repository. + +## Environments + +| Trigger | URL | +|---|---| +| Any PR / non-production branch | `.stac-manager.pages.dev` (preview) | +| Push to `main` | `main.stac-manager.pages.dev` (staging) | +| Push to `release` | `stac-manager.pages.dev` (production) | + +The Pages project's **production branch is `release`**. To promote what is on +`main` to production, fast-forward the `release` branch: + +```sh +git fetch origin +git push origin origin/main:release +``` + +Every other branch (including PR branches) gets an isolated preview +deployment, and the GitHub App posts the preview URL on the PR. + +## Project configuration + +One-time setup, in the Cloudflare dashboard (Workers & Pages → Create → +Pages → Connect to Git): + +- **Production branch**: `release` +- **Build command**: `npm run all:build && cp packages/client/_redirects packages/client/dist/` +- **Build output directory**: `packages/client/dist` +- **Root directory**: `/` (the monorepo root — plugins must build before the client) + +Environment variables (set for both production and preview): + +| Variable | Notes | +|---|---| +| `REACT_APP_STAC_API` | Required. STAC API endpoint. | +| `REACT_APP_STAC_BROWSER` | Optional. Defaults to Radiant Earth's STAC Browser. | +| `REACT_APP_OIDC_AUTHORITY` / `REACT_APP_OIDC_CLIENT_ID` | Optional. If set, note that preview URLs are per-branch, so the OIDC client needs a wildcard redirect URI (`https://*.stac-manager.pages.dev/*`) or auth stays disabled on previews. | + +`PUBLIC_URL` must be left unset: every Cloudflare deployment serves from the +domain root. + +The Node version is read from `.nvmrc` by Cloudflare's build image. + +## SPA routing + +`packages/client/_redirects` (`/* /index.html 200`) is copied into the build +output by the build command so deep links resolve to the app instead of 404s. diff --git a/packages/client/_redirects b/packages/client/_redirects new file mode 100644 index 0000000..7797f7c --- /dev/null +++ b/packages/client/_redirects @@ -0,0 +1 @@ +/* /index.html 200 From ff0d5917f2602fd179be1f253631261567c6e6e0 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 10 Jun 2026 15:45:34 -0700 Subject: [PATCH 4/6] =?UTF-8?q?docs:=20PUBLIC=5FURL=20must=20be=20set=20to?= =?UTF-8?q?=20/=20=E2=80=94=20the=20build=20script=20requires=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/DEPLOYMENT.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index eea8d79..b765ad5 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -41,9 +41,7 @@ Environment variables (set for both production and preview): | `REACT_APP_STAC_API` | Required. STAC API endpoint. | | `REACT_APP_STAC_BROWSER` | Optional. Defaults to Radiant Earth's STAC Browser. | | `REACT_APP_OIDC_AUTHORITY` / `REACT_APP_OIDC_CLIENT_ID` | Optional. If set, note that preview URLs are per-branch, so the OIDC client needs a wildcard redirect URI (`https://*.stac-manager.pages.dev/*`) or auth stays disabled on previews. | - -`PUBLIC_URL` must be left unset: every Cloudflare deployment serves from the -domain root. +| `PUBLIC_URL` | Required by the build script (`tasks/build.mjs`). Set to `/` for every environment — each Cloudflare deployment (preview, staging, production) serves from the root of its own subdomain. | The Node version is read from `.nvmrc` by Cloudflare's build image. From cc082a5b47b33d18ef98c800dd29c56ecd947cf4 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 11 Jun 2026 09:22:44 -0700 Subject: [PATCH 5/6] docs: APP_TITLE and APP_DESCRIPTION are required by the posthtml template Co-Authored-By: Claude Fable 5 --- docs/DEPLOYMENT.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index b765ad5..fe0fb5c 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -39,6 +39,8 @@ Environment variables (set for both production and preview): | Variable | Notes | |---|---| | `REACT_APP_STAC_API` | Required. STAC API endpoint. | +| `APP_TITLE` | Required by the posthtml template in `index.html` (e.g. `STAC Manager`). | +| `APP_DESCRIPTION` | Required by the posthtml template (e.g. `Plugin based STAC editor`). | | `REACT_APP_STAC_BROWSER` | Optional. Defaults to Radiant Earth's STAC Browser. | | `REACT_APP_OIDC_AUTHORITY` / `REACT_APP_OIDC_CLIENT_ID` | Optional. If set, note that preview URLs are per-branch, so the OIDC client needs a wildcard redirect URI (`https://*.stac-manager.pages.dev/*`) or auth stays disabled on previews. | | `PUBLIC_URL` | Required by the build script (`tasks/build.mjs`). Set to `/` for every environment — each Cloudflare deployment (preview, staging, production) serves from the root of its own subdomain. | From 4aff78571966f5993479bdd80dcc7f98dca79f58 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 11 Jun 2026 09:40:30 -0700 Subject: [PATCH 6/6] fix: strip trailing slash from PUBLIC_URL when building asset URLs PUBLIC_URL=/ produced protocol-relative //meta/... URLs for the header icon, favicons, and og:image, which browsers resolve against a host named "meta". Joining was only correct for absolute bases without a trailing slash. Co-Authored-By: Claude Fable 5 --- packages/client/posthtml.config.js | 4 +++- packages/client/src/App.tsx | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/client/posthtml.config.js b/packages/client/posthtml.config.js index 5213067..d982a2a 100644 --- a/packages/client/posthtml.config.js +++ b/packages/client/posthtml.config.js @@ -7,7 +7,9 @@ module.exports = { locals: { appTitle: process.env.APP_TITLE, appDescription: process.env.APP_DESCRIPTION, - baseurl: process.env.PUBLIC_URL || '/' + // Trailing slash stripped: the templates join with "/", and a + // bare "/" base would otherwise yield protocol-relative "//". + baseurl: (process.env.PUBLIC_URL || '/').replace(/\/+$/, '') } } } diff --git a/packages/client/src/App.tsx b/packages/client/src/App.tsx index 550cd55..6610da1 100644 --- a/packages/client/src/App.tsx +++ b/packages/client/src/App.tsx @@ -91,7 +91,7 @@ export function App() { >