From ccd20392fa36e38a32213801bd8c39b5f04e136b Mon Sep 17 00:00:00 2001 From: Lef Date: Wed, 22 Apr 2026 11:14:06 +0200 Subject: [PATCH 1/5] ci: rebuild deploy around pr lifecycle; add close-triggered cleanup --- .github/workflows/cicd.yml | 158 ++++++++++++++++++++++++++++++++----- 1 file changed, 140 insertions(+), 18 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 6ef2ea7..44ff267 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -1,22 +1,49 @@ -name: Upload Website +name: Deploy on: push: + branches: [main] paths: - src/** - .github/workflows/** + pull_request: + types: [opened, synchronize, reopened, closed] + +# Serialize workflow runs per PR (or per ref for main pushes) so that a +# deploy cannot race with its matching cleanup. Running jobs finish before +# the next one starts. +concurrency: + group: deploy-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false jobs: deploy: + # Runs for: + # - push to main (production deploy) + # - PR opened / synchronize / reopened (preview create/update) + # Skipped for closed PRs and for PRs from forks (secrets are unavailable). + if: > + github.event_name == 'push' || + (github.event_name == 'pull_request' && + github.event.action != 'closed' && + github.event.pull_request.head.repo.full_name == github.repository) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: lfs: true + # On PR events, check out the PR head (not the merge commit) so the + # preview reflects exactly what the author pushed. + ref: ${{ github.event.pull_request.head.sha || github.sha }} - - name: Get branch name - id: branch-name - run: echo "BRANCH=${GITHUB_REF_NAME}" >> $GITHUB_OUTPUT + - name: Resolve branch name + id: branch + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "NAME=${{ github.event.pull_request.head.ref }}" >> $GITHUB_OUTPUT + else + echo "NAME=main" >> $GITHUB_OUTPUT + fi - name: Configure AWS Credentials uses: aws-actions/configure-aws-credentials@v4 @@ -28,10 +55,10 @@ jobs: - name: Determine S3 bucket name id: bucket run: | - if [ "${{ steps.branch-name.outputs.BRANCH }}" = "main" ]; then + if [ "${{ steps.branch.outputs.NAME }}" = "main" ]; then BUCKET=${{ secrets.AWS_S3_BUCKET }} else - BUCKET=${{ steps.branch-name.outputs.BRANCH }}.${{ secrets.AWS_S3_BUCKET }} + BUCKET=${{ steps.branch.outputs.NAME }}.${{ secrets.AWS_S3_BUCKET }} fi echo "NAME=${BUCKET}" >> $GITHUB_OUTPUT @@ -60,21 +87,51 @@ jobs: --follow-symlinks \ --delete - - name: Create DNS record for branch subdomain - if: steps.branch-name.outputs.BRANCH != 'main' + - name: Resolve Cloudflare zone name + if: steps.branch.outputs.NAME != 'main' + id: zone run: | - curl -sS -X POST \ - "https://api.cloudflare.com/client/v4/zones/${{ secrets.CLOUDFLARE_ZONE_ID }}/dns_records" \ + ZONE_NAME=$(curl -sS -X GET \ + "https://api.cloudflare.com/client/v4/zones/${{ secrets.CLOUDFLARE_ZONE_ID }}" \ -H "X-Auth-Email: ${{ secrets.EMAIL }}" \ -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_DNS_SECRET_API_TOKEN }}" \ - -H "Content-Type: application/json" \ - --data '{ - "type": "CNAME", - "name": "${{ steps.branch-name.outputs.BRANCH }}", - "content": "${{ steps.bucket.outputs.NAME }}.s3-website.${{ secrets.AWS_REGION }}.amazonaws.com", - "ttl": 1, - "proxied": true - }' + -H "Content-Type: application/json" | jq -r '.result.name') + echo "NAME=${ZONE_NAME}" >> $GITHUB_OUTPUT + + - name: Create or update DNS record for branch subdomain + if: steps.branch.outputs.NAME != 'main' + run: | + FQDN="${{ steps.branch.outputs.NAME }}.${{ steps.zone.outputs.NAME }}" + CONTENT="${{ steps.bucket.outputs.NAME }}.s3-website.${{ secrets.AWS_REGION }}.amazonaws.com" + + EXISTING=$(curl -sS -X GET \ + "https://api.cloudflare.com/client/v4/zones/${{ secrets.CLOUDFLARE_ZONE_ID }}/dns_records?type=CNAME&name=${FQDN}" \ + -H "X-Auth-Email: ${{ secrets.EMAIL }}" \ + -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_DNS_SECRET_API_TOKEN }}" \ + -H "Content-Type: application/json" | jq -r '.result[0].id // empty') + + PAYLOAD=$(jq -n \ + --arg name "${{ steps.branch.outputs.NAME }}" \ + --arg content "$CONTENT" \ + '{type:"CNAME", name:$name, content:$content, ttl:1, proxied:true}') + + if [ -n "$EXISTING" ]; then + echo "Updating existing CNAME (id=$EXISTING) for ${FQDN}" + curl -sS -X PUT \ + "https://api.cloudflare.com/client/v4/zones/${{ secrets.CLOUDFLARE_ZONE_ID }}/dns_records/$EXISTING" \ + -H "X-Auth-Email: ${{ secrets.EMAIL }}" \ + -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_DNS_SECRET_API_TOKEN }}" \ + -H "Content-Type: application/json" \ + --data "$PAYLOAD" > /dev/null + else + echo "Creating new CNAME for ${FQDN}" + curl -sS -X POST \ + "https://api.cloudflare.com/client/v4/zones/${{ secrets.CLOUDFLARE_ZONE_ID }}/dns_records" \ + -H "X-Auth-Email: ${{ secrets.EMAIL }}" \ + -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_DNS_SECRET_API_TOKEN }}" \ + -H "Content-Type: application/json" \ + --data "$PAYLOAD" > /dev/null + fi - name: Purge Cloudflare cache run: | @@ -84,3 +141,68 @@ jobs: -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_DNS_SECRET_API_TOKEN }}" \ -H "Content-Type: application/json" \ --data '{"purge_everything": true}' + + cleanup: + # Runs when a PR is closed (merged or not). Tears down the preview bucket + # and its Cloudflare CNAME. Best-effort: no-op if the resources are already + # gone. Fork PRs are skipped because their preview was never created. + if: > + github.event_name == 'pull_request' && + github.event.action == 'closed' && + github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Compute names + id: names + run: | + BRANCH="${{ github.event.pull_request.head.ref }}" + BUCKET="${BRANCH}.${{ secrets.AWS_S3_BUCKET }}" + echo "BRANCH=${BRANCH}" >> $GITHUB_OUTPUT + echo "BUCKET=${BUCKET}" >> $GITHUB_OUTPUT + + - name: Empty and delete preview S3 bucket + run: | + if aws s3api head-bucket --bucket "${{ steps.names.outputs.BUCKET }}" 2>/dev/null; then + echo "Emptying and deleting bucket ${{ steps.names.outputs.BUCKET }}" + aws s3 rm s3://${{ steps.names.outputs.BUCKET }}/ --recursive + aws s3 rb s3://${{ steps.names.outputs.BUCKET }} + else + echo "Bucket ${{ steps.names.outputs.BUCKET }} not found, skipping." + fi + + - name: Resolve Cloudflare zone name + id: zone + run: | + ZONE_NAME=$(curl -sS -X GET \ + "https://api.cloudflare.com/client/v4/zones/${{ secrets.CLOUDFLARE_ZONE_ID }}" \ + -H "X-Auth-Email: ${{ secrets.EMAIL }}" \ + -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_DNS_SECRET_API_TOKEN }}" \ + -H "Content-Type: application/json" | jq -r '.result.name') + echo "NAME=${ZONE_NAME}" >> $GITHUB_OUTPUT + + - name: Delete preview Cloudflare DNS record + run: | + FQDN="${{ steps.names.outputs.BRANCH }}.${{ steps.zone.outputs.NAME }}" + RECORD_ID=$(curl -sS -X GET \ + "https://api.cloudflare.com/client/v4/zones/${{ secrets.CLOUDFLARE_ZONE_ID }}/dns_records?type=CNAME&name=${FQDN}" \ + -H "X-Auth-Email: ${{ secrets.EMAIL }}" \ + -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_DNS_SECRET_API_TOKEN }}" \ + -H "Content-Type: application/json" | jq -r '.result[0].id // empty') + + if [ -n "$RECORD_ID" ]; then + echo "Deleting CNAME ${FQDN} (id=$RECORD_ID)" + curl -sS -X DELETE \ + "https://api.cloudflare.com/client/v4/zones/${{ secrets.CLOUDFLARE_ZONE_ID }}/dns_records/$RECORD_ID" \ + -H "X-Auth-Email: ${{ secrets.EMAIL }}" \ + -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_DNS_SECRET_API_TOKEN }}" \ + -H "Content-Type: application/json" > /dev/null + else + echo "No CNAME for ${FQDN}, skipping." + fi From 33be13e5df65e55c5a4fb00f2576d189aebd0963 Mon Sep 17 00:00:00 2001 From: Lef Date: Wed, 22 Apr 2026 11:29:39 +0200 Subject: [PATCH 2/5] ci: apply review fixes; document branch naming constraints --- .github/workflows/cicd.yml | 43 ++++++++++++++++++++------------------ CLAUDE.md | 24 ++++++++++++++++----- 2 files changed, 42 insertions(+), 25 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 44ff267..d1ee377 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -36,14 +36,10 @@ jobs: # preview reflects exactly what the author pushed. ref: ${{ github.event.pull_request.head.sha || github.sha }} + # push events only fire on `main`, so the fallback is always `main`. - name: Resolve branch name id: branch - run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - echo "NAME=${{ github.event.pull_request.head.ref }}" >> $GITHUB_OUTPUT - else - echo "NAME=main" >> $GITHUB_OUTPUT - fi + run: echo "NAME=${{ github.event.pull_request.head.ref || 'main' }}" >> $GITHUB_OUTPUT - name: Configure AWS Credentials uses: aws-actions/configure-aws-credentials@v4 @@ -91,11 +87,15 @@ jobs: if: steps.branch.outputs.NAME != 'main' id: zone run: | - ZONE_NAME=$(curl -sS -X GET \ + ZONE_NAME=$(curl --fail-with-body -sS -X GET \ "https://api.cloudflare.com/client/v4/zones/${{ secrets.CLOUDFLARE_ZONE_ID }}" \ -H "X-Auth-Email: ${{ secrets.EMAIL }}" \ -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_DNS_SECRET_API_TOKEN }}" \ - -H "Content-Type: application/json" | jq -r '.result.name') + -H "Content-Type: application/json" | jq -r '.result.name // empty') + if [ -z "$ZONE_NAME" ]; then + echo "::error::Failed to resolve Cloudflare zone name for zone ${{ secrets.CLOUDFLARE_ZONE_ID }}" + exit 1 + fi echo "NAME=${ZONE_NAME}" >> $GITHUB_OUTPUT - name: Create or update DNS record for branch subdomain @@ -104,7 +104,7 @@ jobs: FQDN="${{ steps.branch.outputs.NAME }}.${{ steps.zone.outputs.NAME }}" CONTENT="${{ steps.bucket.outputs.NAME }}.s3-website.${{ secrets.AWS_REGION }}.amazonaws.com" - EXISTING=$(curl -sS -X GET \ + EXISTING=$(curl --fail-with-body -sS -X GET \ "https://api.cloudflare.com/client/v4/zones/${{ secrets.CLOUDFLARE_ZONE_ID }}/dns_records?type=CNAME&name=${FQDN}" \ -H "X-Auth-Email: ${{ secrets.EMAIL }}" \ -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_DNS_SECRET_API_TOKEN }}" \ @@ -117,7 +117,7 @@ jobs: if [ -n "$EXISTING" ]; then echo "Updating existing CNAME (id=$EXISTING) for ${FQDN}" - curl -sS -X PUT \ + curl --fail-with-body -sS -X PUT \ "https://api.cloudflare.com/client/v4/zones/${{ secrets.CLOUDFLARE_ZONE_ID }}/dns_records/$EXISTING" \ -H "X-Auth-Email: ${{ secrets.EMAIL }}" \ -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_DNS_SECRET_API_TOKEN }}" \ @@ -125,7 +125,7 @@ jobs: --data "$PAYLOAD" > /dev/null else echo "Creating new CNAME for ${FQDN}" - curl -sS -X POST \ + curl --fail-with-body -sS -X POST \ "https://api.cloudflare.com/client/v4/zones/${{ secrets.CLOUDFLARE_ZONE_ID }}/dns_records" \ -H "X-Auth-Email: ${{ secrets.EMAIL }}" \ -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_DNS_SECRET_API_TOKEN }}" \ @@ -135,7 +135,7 @@ jobs: - name: Purge Cloudflare cache run: | - curl -sS -X POST \ + curl --fail-with-body -sS -X POST \ "https://api.cloudflare.com/client/v4/zones/${{ secrets.CLOUDFLARE_ZONE_ID }}/purge_cache" \ -H "X-Auth-Email: ${{ secrets.EMAIL }}" \ -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_DNS_SECRET_API_TOKEN }}" \ @@ -167,12 +167,11 @@ jobs: echo "BRANCH=${BRANCH}" >> $GITHUB_OUTPUT echo "BUCKET=${BUCKET}" >> $GITHUB_OUTPUT - - name: Empty and delete preview S3 bucket + - name: Delete preview S3 bucket run: | if aws s3api head-bucket --bucket "${{ steps.names.outputs.BUCKET }}" 2>/dev/null; then - echo "Emptying and deleting bucket ${{ steps.names.outputs.BUCKET }}" - aws s3 rm s3://${{ steps.names.outputs.BUCKET }}/ --recursive - aws s3 rb s3://${{ steps.names.outputs.BUCKET }} + echo "Deleting bucket ${{ steps.names.outputs.BUCKET }} (with contents)" + aws s3 rb s3://${{ steps.names.outputs.BUCKET }} --force else echo "Bucket ${{ steps.names.outputs.BUCKET }} not found, skipping." fi @@ -180,17 +179,21 @@ jobs: - name: Resolve Cloudflare zone name id: zone run: | - ZONE_NAME=$(curl -sS -X GET \ + ZONE_NAME=$(curl --fail-with-body -sS -X GET \ "https://api.cloudflare.com/client/v4/zones/${{ secrets.CLOUDFLARE_ZONE_ID }}" \ -H "X-Auth-Email: ${{ secrets.EMAIL }}" \ -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_DNS_SECRET_API_TOKEN }}" \ - -H "Content-Type: application/json" | jq -r '.result.name') + -H "Content-Type: application/json" | jq -r '.result.name // empty') + if [ -z "$ZONE_NAME" ]; then + echo "::error::Failed to resolve Cloudflare zone name for zone ${{ secrets.CLOUDFLARE_ZONE_ID }}" + exit 1 + fi echo "NAME=${ZONE_NAME}" >> $GITHUB_OUTPUT - name: Delete preview Cloudflare DNS record run: | FQDN="${{ steps.names.outputs.BRANCH }}.${{ steps.zone.outputs.NAME }}" - RECORD_ID=$(curl -sS -X GET \ + RECORD_ID=$(curl --fail-with-body -sS -X GET \ "https://api.cloudflare.com/client/v4/zones/${{ secrets.CLOUDFLARE_ZONE_ID }}/dns_records?type=CNAME&name=${FQDN}" \ -H "X-Auth-Email: ${{ secrets.EMAIL }}" \ -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_DNS_SECRET_API_TOKEN }}" \ @@ -198,7 +201,7 @@ jobs: if [ -n "$RECORD_ID" ]; then echo "Deleting CNAME ${FQDN} (id=$RECORD_ID)" - curl -sS -X DELETE \ + curl --fail-with-body -sS -X DELETE \ "https://api.cloudflare.com/client/v4/zones/${{ secrets.CLOUDFLARE_ZONE_ID }}/dns_records/$RECORD_ID" \ -H "X-Auth-Email: ${{ secrets.EMAIL }}" \ -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_DNS_SECRET_API_TOKEN }}" \ diff --git a/CLAUDE.md b/CLAUDE.md index fda88cc..72b0c99 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,11 +59,25 @@ See `.claude/context/tufte-patterns.md` for all HTML patterns (sidenotes, images ## Deployment -Automated via GitHub Actions (`.github/workflows/cicd.yml`): -- **Trigger**: Push to `src/` or `.github/workflows/` -- **main branch** → production S3 bucket (lef.fyi) -- **Other branches** → `{branch}.lef.fyi` subdomain (auto-created) -- Pipeline: checkout with LFS → configure bucket + policy → S3 sync → Cloudflare DNS + cache purge +Automated via GitHub Actions (`.github/workflows/cicd.yml`). Two triggers, two jobs: + +- **Push to `main`** → `deploy` job syncs to production S3 bucket (lef.fyi) and purges cache. +- **PR `opened` / `synchronize` / `reopened`** → `deploy` job creates or updates a preview at `{branch}.lef.fyi`. +- **PR `closed`** (merged or not) → `cleanup` job empties/removes the preview bucket and deletes its Cloudflare CNAME. + +Pushes to non-`main` branches without an open PR do not deploy anything on their own. Open a PR to get a preview. + +Pipeline (deploy job): checkout with LFS → configure bucket + policy → S3 sync → Cloudflare CNAME upsert → cache purge. + +### Branch naming constraints + +Branch names become S3 bucket prefixes and Cloudflare subdomain labels, so they must be: +- **lowercase** — S3 bucket names reject mixed case +- **no slashes** — `feature/foo` breaks bucket naming; use `feature-foo` instead +- **no underscores, no leading/trailing hyphens** — DNS label rules +- **short enough** — bucket is `{branch}.{base}`, must fit in 63 chars total + +Stick to flat kebab-case (`post-quiet-world`, `ci-pr-previews`) and you never think about it. ## Git From b27124659c3addac55259e0f7a6a132be0f4890a Mon Sep 17 00:00:00 2001 From: Lef Date: Wed, 22 Apr 2026 11:32:25 +0200 Subject: [PATCH 3/5] docs: clarify preview bucket name in CLAUDE.md --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 72b0c99..097911e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,7 +75,7 @@ Branch names become S3 bucket prefixes and Cloudflare subdomain labels, so they - **lowercase** — S3 bucket names reject mixed case - **no slashes** — `feature/foo` breaks bucket naming; use `feature-foo` instead - **no underscores, no leading/trailing hyphens** — DNS label rules -- **short enough** — bucket is `{branch}.{base}`, must fit in 63 chars total +- **short enough** — the preview bucket is named `{branch}.lef.fyi` (S3 caps bucket names at 63 chars, which leaves ~54 for the branch) Stick to flat kebab-case (`post-quiet-world`, `ci-pr-previews`) and you never think about it. From 6237b3098f76d4e3ca566598872ad63aaf8b9d76 Mon Sep 17 00:00:00 2001 From: Lef Date: Wed, 22 Apr 2026 11:40:12 +0200 Subject: [PATCH 4/5] ci: scope cache purge to touched hosts instead of purge_everything --- .github/workflows/cicd.yml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index d1ee377..33fff1e 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -83,8 +83,9 @@ jobs: --follow-symlinks \ --delete + # Always resolve the zone name: main uses it for the apex+www cache purge, + # previews use it to construct their FQDN. - name: Resolve Cloudflare zone name - if: steps.branch.outputs.NAME != 'main' id: zone run: | ZONE_NAME=$(curl --fail-with-body -sS -X GET \ @@ -133,14 +134,26 @@ jobs: --data "$PAYLOAD" > /dev/null fi + # Scope the purge to just the hosts this deploy actually touched so we + # don't nuke unrelated cached content in the zone on every push. - name: Purge Cloudflare cache run: | + if [ "${{ steps.branch.outputs.NAME }}" = "main" ]; then + HOSTS=$(jq -nc \ + --arg apex "${{ steps.zone.outputs.NAME }}" \ + '[$apex, ("www." + $apex)]') + else + HOSTS=$(jq -nc \ + --arg fqdn "${{ steps.branch.outputs.NAME }}.${{ steps.zone.outputs.NAME }}" \ + '[$fqdn]') + fi + echo "Purging hosts: $HOSTS" curl --fail-with-body -sS -X POST \ "https://api.cloudflare.com/client/v4/zones/${{ secrets.CLOUDFLARE_ZONE_ID }}/purge_cache" \ -H "X-Auth-Email: ${{ secrets.EMAIL }}" \ -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_DNS_SECRET_API_TOKEN }}" \ -H "Content-Type: application/json" \ - --data '{"purge_everything": true}' + --data "{\"hosts\": $HOSTS}" > /dev/null cleanup: # Runs when a PR is closed (merged or not). Tears down the preview bucket From 041d2f2d6d14e39052db3e4f897989256b51131f Mon Sep 17 00:00:00 2001 From: Lef Date: Wed, 22 Apr 2026 11:48:05 +0200 Subject: [PATCH 5/5] ci: unify name derivation, quarantine head ref via env, clarify concurrency comment --- .github/workflows/cicd.yml | 69 +++++++++++++++++++++----------------- 1 file changed, 38 insertions(+), 31 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 33fff1e..a95d4ac 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -9,9 +9,10 @@ on: pull_request: types: [opened, synchronize, reopened, closed] -# Serialize workflow runs per PR (or per ref for main pushes) so that a -# deploy cannot race with its matching cleanup. Running jobs finish before -# the next one starts. +# Serialize workflow runs per PR (and per ref for main pushes) so successive +# events on the same target never step on each other. For PRs this stops a +# deploy from racing its matching cleanup; for main it stops two pushes from +# fighting over the same bucket and cache. concurrency: group: deploy-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: false @@ -36,10 +37,22 @@ jobs: # preview reflects exactly what the author pushed. ref: ${{ github.event.pull_request.head.sha || github.sha }} - # push events only fire on `main`, so the fallback is always `main`. - - name: Resolve branch name - id: branch - run: echo "NAME=${{ github.event.pull_request.head.ref || 'main' }}" >> $GITHUB_OUTPUT + # Quarantine the PR head ref via env first; every downstream consumer + # reads the already-materialised GITHUB_OUTPUT values instead of + # interpolating user-controllable expressions into shell. + - name: Compute names + id: names + env: + HEAD_REF: ${{ github.event.pull_request.head.ref }} + run: | + BRANCH="${HEAD_REF:-main}" + if [ "$BRANCH" = "main" ]; then + BUCKET="${{ secrets.AWS_S3_BUCKET }}" + else + BUCKET="${BRANCH}.${{ secrets.AWS_S3_BUCKET }}" + fi + echo "BRANCH=${BRANCH}" >> $GITHUB_OUTPUT + echo "BUCKET=${BUCKET}" >> $GITHUB_OUTPUT - name: Configure AWS Credentials uses: aws-actions/configure-aws-credentials@v4 @@ -48,38 +61,28 @@ jobs: aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ${{ secrets.AWS_REGION }} - - name: Determine S3 bucket name - id: bucket - run: | - if [ "${{ steps.branch.outputs.NAME }}" = "main" ]; then - BUCKET=${{ secrets.AWS_S3_BUCKET }} - else - BUCKET=${{ steps.branch.outputs.NAME }}.${{ secrets.AWS_S3_BUCKET }} - fi - echo "NAME=${BUCKET}" >> $GITHUB_OUTPUT - - name: Create S3 bucket if needed run: | - if ! aws s3api head-bucket --bucket "${{ steps.bucket.outputs.NAME }}" 2>/dev/null; then - aws s3 mb s3://${{ steps.bucket.outputs.NAME }} + if ! aws s3api head-bucket --bucket "${{ steps.names.outputs.BUCKET }}" 2>/dev/null; then + aws s3 mb s3://${{ steps.names.outputs.BUCKET }} fi - name: Configure bucket for static website hosting run: | aws s3api delete-public-access-block \ - --bucket ${{ steps.bucket.outputs.NAME }} - sed "s/www.example.com/${{ steps.bucket.outputs.NAME }}/g" \ + --bucket ${{ steps.names.outputs.BUCKET }} + sed "s/www.example.com/${{ steps.names.outputs.BUCKET }}/g" \ ${{ github.workspace }}/.github/workflows/policy.json > /tmp/policy.json aws s3api put-bucket-policy \ - --bucket ${{ steps.bucket.outputs.NAME }} \ + --bucket ${{ steps.names.outputs.BUCKET }} \ --policy file:///tmp/policy.json - aws s3 website s3://${{ steps.bucket.outputs.NAME }}/ \ + aws s3 website s3://${{ steps.names.outputs.BUCKET }}/ \ --index-document index.html \ --error-document 404.html - name: Sync src/ to S3 run: | - aws s3 sync src/ s3://${{ steps.bucket.outputs.NAME }}/ \ + aws s3 sync src/ s3://${{ steps.names.outputs.BUCKET }}/ \ --follow-symlinks \ --delete @@ -100,10 +103,10 @@ jobs: echo "NAME=${ZONE_NAME}" >> $GITHUB_OUTPUT - name: Create or update DNS record for branch subdomain - if: steps.branch.outputs.NAME != 'main' + if: steps.names.outputs.BRANCH != 'main' run: | - FQDN="${{ steps.branch.outputs.NAME }}.${{ steps.zone.outputs.NAME }}" - CONTENT="${{ steps.bucket.outputs.NAME }}.s3-website.${{ secrets.AWS_REGION }}.amazonaws.com" + FQDN="${{ steps.names.outputs.BRANCH }}.${{ steps.zone.outputs.NAME }}" + CONTENT="${{ steps.names.outputs.BUCKET }}.s3-website.${{ secrets.AWS_REGION }}.amazonaws.com" EXISTING=$(curl --fail-with-body -sS -X GET \ "https://api.cloudflare.com/client/v4/zones/${{ secrets.CLOUDFLARE_ZONE_ID }}/dns_records?type=CNAME&name=${FQDN}" \ @@ -112,7 +115,7 @@ jobs: -H "Content-Type: application/json" | jq -r '.result[0].id // empty') PAYLOAD=$(jq -n \ - --arg name "${{ steps.branch.outputs.NAME }}" \ + --arg name "${{ steps.names.outputs.BRANCH }}" \ --arg content "$CONTENT" \ '{type:"CNAME", name:$name, content:$content, ttl:1, proxied:true}') @@ -138,13 +141,13 @@ jobs: # don't nuke unrelated cached content in the zone on every push. - name: Purge Cloudflare cache run: | - if [ "${{ steps.branch.outputs.NAME }}" = "main" ]; then + if [ "${{ steps.names.outputs.BRANCH }}" = "main" ]; then HOSTS=$(jq -nc \ --arg apex "${{ steps.zone.outputs.NAME }}" \ '[$apex, ("www." + $apex)]') else HOSTS=$(jq -nc \ - --arg fqdn "${{ steps.branch.outputs.NAME }}.${{ steps.zone.outputs.NAME }}" \ + --arg fqdn "${{ steps.names.outputs.BRANCH }}.${{ steps.zone.outputs.NAME }}" \ '[$fqdn]') fi echo "Purging hosts: $HOSTS" @@ -172,10 +175,14 @@ jobs: aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ${{ secrets.AWS_REGION }} + # Quarantine the PR head ref via env; downstream steps use the + # materialised GITHUB_OUTPUT values, not raw expression interpolation. - name: Compute names id: names + env: + HEAD_REF: ${{ github.event.pull_request.head.ref }} run: | - BRANCH="${{ github.event.pull_request.head.ref }}" + BRANCH="${HEAD_REF}" BUCKET="${BRANCH}.${{ secrets.AWS_S3_BUCKET }}" echo "BRANCH=${BRANCH}" >> $GITHUB_OUTPUT echo "BUCKET=${BUCKET}" >> $GITHUB_OUTPUT