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
215 changes: 180 additions & 35 deletions .github/workflows/cicd.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
24 changes: 19 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading