Skip to content
Open
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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ jobs:
REPO_OWNER="${{ github.repository_owner }}"

echo "short_hash=${SHORT_HASH}" >> $GITHUB_OUTPUT
echo "head_sha=${COMMIT_HASH}" >> $GITHUB_OUTPUT
echo "pr_number=${PR_NUMBER}" >> $GITHUB_OUTPUT
echo "storybook_url=https://${REPO_OWNER}.github.io/${REPO_NAME}/pr/${PR_NUMBER}/" >> $GITHUB_OUTPUT
echo "sandbox_url=https://${REPO_OWNER}.github.io/${REPO_NAME}/pr/${PR_NUMBER}/sandbox/" >> $GITHUB_OUTPUT
Expand Down Expand Up @@ -275,10 +276,13 @@ jobs:
# Preview URLs are now populated for ALL PRs (fork and same-repo alike)
# because deploy-preview.yml deploys every PR's preview from the trusted
# context — the old fork/same-repo split (empty URLs on forks) is gone.
# deploy-preview.yml also records a GitHub deployment against the
# head commit, which the short hash cannot name.
cat > pr-meta.json <<EOF
{
"prNumber": "${{ steps.urls.outputs.pr_number }}",
"shortHash": "${{ steps.urls.outputs.short_hash }}",
"headSha": "${{ steps.urls.outputs.head_sha }}",
"storybookUrl": "${{ steps.urls.outputs.storybook_url }}",
"sandboxUrl": "${{ steps.urls.outputs.sandbox_url }}",
"runId": "${{ github.run_id }}",
Expand Down
45 changes: 45 additions & 0 deletions .github/workflows/cleanup-previews.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ jobs:
permissions:
contents: write
pull-requests: read
deployments: write
steps:
- name: Checkout gh-pages
uses: actions/checkout@v7
Expand All @@ -66,6 +67,7 @@ jobs:
fetch-depth: 1

- name: Cleanup stale deployments
id: cleanup
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Expand All @@ -91,6 +93,9 @@ jobs:
# Every deleted path is recorded so the push-retry below can replay
# the removals on top of a refreshed gh-pages tip.
DELETED_PATHS=()
# PR numbers whose preview is going away, so the deployment records
# deploy-preview.yml wrote can be retired with it.
DELETED_PRS=()

# ----- Clean legacy hash directories -----
# Old deploy format put previews at /<7-char-hex>/. These are all
Expand Down Expand Up @@ -142,6 +147,7 @@ jobs:
git rm -rf --quiet "pr/${pr_num}"
fi
DELETED_PATHS+=("pr/${pr_num}")
DELETED_PRS+=("$pr_num")
DELETED=$((DELETED + 1))
fi
done
Expand Down Expand Up @@ -219,6 +225,10 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"

# Handed to the next step, which retires these PRs' deployment
# records once the removal is actually pushed.
echo "deleted_prs=${DELETED_PRS[*]:-}" >> "$GITHUB_OUTPUT"

# Push with retry. gh-pages has concurrent writers — most notably
# deploy.yml, which force-orphan pushes on every main push, i.e.
# exactly when a merged PR also fires this workflow — so a plain
Expand Down Expand Up @@ -260,3 +270,38 @@ jobs:

echo "::error::Failed to push cleanup after ${MAX_ATTEMPTS} attempts"
exit 1

# A deleted preview must stop advertising itself. deploy-preview.yml
# records each preview as a GitHub Deployment, and GitHub keeps offering
# "View deployment" on a closed PR until something says otherwise — which
# would be a button pointing at the /pr/<n>/ path just removed above.
# Marking the head commit's deployments inactive is enough: that is the
# commit the PR page and the deployments API answer for.
- name: Retire the deleted previews' deployment records
if: steps.cleanup.outputs.deleted_prs != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DELETED_PRS: ${{ steps.cleanup.outputs.deleted_prs }}
run: |
set -uo pipefail
read -ra pr_numbers <<< "$DELETED_PRS"
for pr_num in "${pr_numbers[@]}"; do
head_sha="$(gh pr view "$pr_num" --repo "$GITHUB_REPOSITORY" --json headRefOid --jq '.headRefOid' 2>/dev/null || true)"
if [ -z "$head_sha" ]; then
echo " pr/${pr_num}: head commit unknown (PR deleted?) — nothing to retire"
continue
fi
for environment in Storybook Sandbox; do
# A PR with nothing left to retire is the normal case on a rerun,
# so an empty list must not trip `set -u`.
ids=()
mapfile -t ids < <(gh api "/repos/${GITHUB_REPOSITORY}/deployments?sha=${head_sha}&environment=${environment}" --jq '.[].id' 2>/dev/null)
for id in ${ids[@]+"${ids[@]}"}; do
if gh api -X POST "/repos/${GITHUB_REPOSITORY}/deployments/${id}/statuses" -f state=inactive >/dev/null 2>&1; then
echo " pr/${pr_num}: ${environment} deployment ${id} inactive"
else
echo "::warning::pr/${pr_num}: could not retire ${environment} deployment ${id}"
fi
done
done
done
86 changes: 83 additions & 3 deletions .github/workflows/deploy-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@
# the pre-built static artifacts (storybook-<hash>, sandbox-<hash>) that CI
# already produced and copies them into gh-pages. Do not add checkout of the PR
# head or execution of PR code here.
#
# The preview is also recorded as a real GitHub Deployment on the PR's head
# commit (environments "Storybook" and "Sandbox"), which is what makes it read
# as a deployment everywhere GitHub renders one — the PR timeline's deployment
# box, the commit's status, and API consumers — rather than only as a bot
# comment. It is the same shape Vercel already uses on this repo for its
# "Preview" environment.

name: Deploy PR Preview

Expand Down Expand Up @@ -40,6 +47,7 @@ jobs:
permissions:
contents: write
actions: read
deployments: write
steps:
- name: Download PR metadata from the CI run
id: meta
Expand All @@ -64,6 +72,7 @@ jobs:

PR_NUMBER="$(jq -r '.prNumber // ""' pr-analysis/pr-meta.json)"
SHORT_HASH="$(jq -r '.shortHash // ""' pr-analysis/pr-meta.json)"
HEAD_SHA="$(jq -r '.headSha // ""' pr-analysis/pr-meta.json)"

# Validate PR number is digits and hash is hex — these name a
# gh-pages path and artifacts, so reject anything unexpected.
Expand All @@ -76,9 +85,19 @@ jobs:
exit 1
fi

echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
echo "short_hash=$SHORT_HASH" >> "$GITHUB_OUTPUT"
echo "deploy=true" >> "$GITHUB_OUTPUT"
# The head SHA only names a deployment, so a metadata file written
# before that field existed still deploys — it just records nothing.
if ! echo "$HEAD_SHA" | grep -qE '^[0-9a-f]{40}$'; then
echo "::warning::No usable head SHA in metadata — skipping deployment records."
HEAD_SHA=""
fi

{
echo "pr_number=$PR_NUMBER"
echo "short_hash=$SHORT_HASH"
echo "head_sha=$HEAD_SHA"
echo "deploy=true"
} >> "$GITHUB_OUTPUT"

- name: Download Storybook artifact
id: dl-storybook
Expand Down Expand Up @@ -121,6 +140,7 @@ jobs:
fi

- name: Deploy PR preview to GitHub Pages
id: deploy
if: steps.parse.outputs.deploy == 'true' && steps.verify.outputs.ready == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Expand Down Expand Up @@ -167,3 +187,63 @@ jobs:

echo "::error::Failed to deploy PR preview after $MAX_ATTEMPTS attempts"
exit 1

# Record the preview as a GitHub Deployment on the PR's head commit, so
# it reads as a deployment rather than as a comment: the PR timeline gets
# GitHub's own "deployed to Storybook" box with a View deployment button,
# and anything reading the Deployments API sees it too.
#
# always(): a failed push must post `failure`, not silence. Without it a
# lost gh-pages race would leave the previous commit's green deployment
# as the newest record on the PR, pointing at content that is no longer
# what the PR builds. A run that never had artifacts to deploy (a
# cancelled CI) records nothing at all — the newer run will.
- name: Record the preview as a GitHub deployment
if: always() && steps.parse.outputs.head_sha != '' && steps.verify.outputs.ready == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_SHA: ${{ steps.parse.outputs.head_sha }}
PR_NUMBER: ${{ steps.parse.outputs.pr_number }}
PAGES_ORIGIN: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}
LOG_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
DEPLOY_OUTCOME: ${{ steps.deploy.outcome }}
run: |
set -euo pipefail
STATE=failure
if [ "$DEPLOY_OUTCOME" = "success" ]; then STATE=success; fi
echo "Recording deployments as ${STATE} for ${HEAD_SHA}"

record() {
environment="$1"
environment_url="$2"

# required_contexts:[] is load-bearing — the default makes GitHub
# check the commit's statuses first and answer 409 "conflict" while
# any PR check is still running, which is most of the time here.
#
# transient_environment marks a preview that will be torn down when
# the PR closes (cleanup-previews.yml), so GitHub stops offering it.
deployment_id="$(
jq -nc \
--arg ref "$HEAD_SHA" \
--arg environment "$environment" \
--arg description "Preview for PR #${PR_NUMBER}" \
'{$ref, $environment, $description, auto_merge: false, required_contexts: [], transient_environment: true}' |
gh api -X POST "/repos/${GITHUB_REPOSITORY}/deployments" --input - --jq '.id'
)"

# auto_inactive:false, or a success here marks EVERY other PR's
# deployment in this environment inactive — they share the
# environment name but not the URL, and each one is still live.
jq -nc \
--arg state "$STATE" \
--arg environment_url "$environment_url" \
--arg log_url "$LOG_URL" \
'{$state, $environment_url, $log_url, auto_inactive: false}' |
gh api -X POST \
"/repos/${GITHUB_REPOSITORY}/deployments/${deployment_id}/statuses" \
--input - --jq '"\(.environment): \(.state) -> \(.environment_url)"'
}

record Storybook "${PAGES_ORIGIN}/pr/${PR_NUMBER}/"
record Sandbox "${PAGES_ORIGIN}/pr/${PR_NUMBER}/sandbox/"
57 changes: 57 additions & 0 deletions .github/workflows/redeploy-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ jobs:
contents: write
pull-requests: read
actions: read
deployments: write

env:
PR_NUMBER: ${{ github.event.inputs.pr_number }}
Expand Down Expand Up @@ -65,6 +66,7 @@ jobs:
run: pnpm -F @astryxdesign/sandbox build

- name: Deploy to GitHub Pages
id: deploy
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
Expand Down Expand Up @@ -105,3 +107,58 @@ jobs:

echo "::error::Failed to deploy PR preview after $MAX_ATTEMPTS attempts"
exit 1

# Replace the deployment record deploy-preview.yml wrote for this commit.
# A manual re-deploy is usually a rescue after the automatic one failed,
# so leaving its `failure` record standing would keep the PR claiming a
# broken preview that is now live. Inline rather than shared with
# deploy-preview.yml on purpose: that workflow deliberately checks out
# nothing, so it cannot call a local composite action (the gh-pages push
# loop above is duplicated for the same reason).
- name: Record the preview as a GitHub deployment
if: always()
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_SHA: ${{ steps.pr-info.outputs.head_sha }}
PAGES_ORIGIN: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}
LOG_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
DEPLOY_OUTCOME: ${{ steps.deploy.outcome }}
run: |
set -euo pipefail
if ! echo "$PR_NUMBER" | grep -qE '^[0-9]+$'; then
echo "::error::pr_number input is not a number"
exit 1
fi
STATE=failure
if [ "$DEPLOY_OUTCOME" = "success" ]; then STATE=success; fi

record() {
environment="$1"
environment_url="$2"

# required_contexts:[] is load-bearing — the default makes GitHub
# check the commit's own statuses first and answer 409 "conflict"
# while any PR check is still running.
deployment_id="$(
jq -nc \
--arg ref "$HEAD_SHA" \
--arg environment "$environment" \
--arg description "Preview for PR #${PR_NUMBER}" \
'{$ref, $environment, $description, auto_merge: false, required_contexts: [], transient_environment: true}' |
gh api -X POST "/repos/${GITHUB_REPOSITORY}/deployments" --input - --jq '.id'
)"

# auto_inactive:false, or a success here marks EVERY other PR's
# deployment in this environment inactive.
jq -nc \
--arg state "$STATE" \
--arg environment_url "$environment_url" \
--arg log_url "$LOG_URL" \
'{$state, $environment_url, $log_url, auto_inactive: false}' |
gh api -X POST \
"/repos/${GITHUB_REPOSITORY}/deployments/${deployment_id}/statuses" \
--input - --jq '"\(.environment): \(.state) -> \(.environment_url)"'
}

record Storybook "${PAGES_ORIGIN}/pr/${PR_NUMBER}/"
record Sandbox "${PAGES_ORIGIN}/pr/${PR_NUMBER}/sandbox/"
Loading