diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 6ef2ea7..a95d4ac 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -1,22 +1,58 @@ -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 (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 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 + # 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 @@ -25,62 +61,171 @@ 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-name.outputs.BRANCH }}" = "main" ]; then - BUCKET=${{ secrets.AWS_S3_BUCKET }} - else - BUCKET=${{ steps.branch-name.outputs.BRANCH }}.${{ 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 - - name: Create DNS record for branch subdomain - if: steps.branch-name.outputs.BRANCH != 'main' + # 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 + id: zone run: | - curl -sS -X POST \ - "https://api.cloudflare.com/client/v4/zones/${{ secrets.CLOUDFLARE_ZONE_ID }}/dns_records" \ + 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" \ - --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 // 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 + if: steps.names.outputs.BRANCH != 'main' + run: | + 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}" \ + -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.names.outputs.BRANCH }}" \ + --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 --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 }}" \ + -H "Content-Type: application/json" \ + --data "$PAYLOAD" > /dev/null + else + echo "Creating new CNAME for ${FQDN}" + 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 }}" \ + -H "Content-Type: application/json" \ + --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: | - curl -sS -X POST \ + 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.names.outputs.BRANCH }}.${{ 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 + # 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 }} + + # 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="${HEAD_REF}" + BUCKET="${BRANCH}.${{ secrets.AWS_S3_BUCKET }}" + echo "BRANCH=${BRANCH}" >> $GITHUB_OUTPUT + echo "BUCKET=${BUCKET}" >> $GITHUB_OUTPUT + + - name: Delete preview S3 bucket + run: | + if aws s3api head-bucket --bucket "${{ steps.names.outputs.BUCKET }}" 2>/dev/null; then + 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 + + - name: Resolve Cloudflare zone name + id: zone + run: | + 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 // 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 --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 }}" \ + -H "Content-Type: application/json" | jq -r '.result[0].id // empty') + + if [ -n "$RECORD_ID" ]; then + echo "Deleting CNAME ${FQDN} (id=$RECORD_ID)" + 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 }}" \ + -H "Content-Type: application/json" > /dev/null + else + echo "No CNAME for ${FQDN}, skipping." + fi diff --git a/CLAUDE.md b/CLAUDE.md index fda88cc..097911e 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** — 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. ## Git