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
4 changes: 4 additions & 0 deletions .cursor/rules/medium-blog-sync.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ Use this workflow when the user asks to sync Medium articles, add new Medium pos
6. Commit and push the updated files, using a descriptive message such as
`content: sync N new Medium post(s)` (adjust wording if only the homepage
selection or refreshed posts changed, e.g. `content: refresh synced Medium posts`).
7. Open a PR into `sandbox` (not `main`) with these changes. Once that PR is
merged, production promotion is handled automatically — see the
`sandbox-to-main-promotion` rule. Do not push to `main` yourself as part of
this flow.

## Guardrails

Expand Down
97 changes: 97 additions & 0 deletions .cursor/rules/sandbox-to-main-promotion.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
---
description: Promote validated sandbox changes to main/production after a build passes, via a human-reviewed PR.
alwaysApply: false
---

# Sandbox → Main Promotion

Use this workflow when the user asks to ship/release/deploy to production, promote `sandbox` to `main`,
or check whether recent `sandbox` changes (e.g. from the Medium blog sync flow) are ready for production.

## How this actually works (mostly automated)

Promotion to production is primarily handled by CI/CD, not by an agent:

1. A PR is merged into `sandbox` (e.g. the Medium sync PR from the
`medium-blog-sync` rule, or any other feature PR).
2. That merge is a push to `sandbox`, which triggers
`.github/workflows/promote-sandbox-to-main.yml`:
- Installs deps and runs `npm run build` as a hard gate (lint runs too, but
is informational only and never blocks promotion).
- If the build passes and `sandbox` has commits `main` doesn't have yet, it
opens (or updates, if one is already open) a `sandbox → main` pull request,
summarizing the commits being promoted, and requests review from the repo
owner (this is the notification — GitHub emails/notifies the requested
reviewer).
- If the build fails, no PR is created/updated, and the failed workflow run
itself is the signal that something needs to be fixed on `sandbox` first.
3. A human reviews the promotion PR and merges it manually. Merging it is a
push to `main`, which triggers `.github/workflows/deploy.yml` and deploys to
GitHub Pages.

**In the common case, no agent action is required after a PR merges into
`sandbox`** — the workflow above handles validation, PR creation, and
notification automatically.

## Failure path

The workflow is expected to fail loudly and notify a human rather than fail
silently:

- **Build fails** → the workflow opens/updates a GitHub Issue titled
"Sandbox promotion automation needs attention: build failed" with a link to
the run. No promotion PR is touched. The issue auto-closes the next time the
build passes.
- **PR create/update fails** → the workflow opens/updates a GitHub Issue titled
"Sandbox promotion automation needs attention: could not open/update PR"
with the captured error and remediation steps. The issue auto-closes the
next time the PR create/update succeeds.
- The most common cause of a PR-creation failure is the repo setting
**Settings → Actions → General → Workflow permissions → "Allow GitHub
Actions to create and approve pull requests"** being disabled (this is off
by default on new repos). The workflow detects this specific GraphQL error
and includes the exact fix in the issue body. As a fallback that doesn't
require flipping that repo-wide setting, add a repo secret named
`PROMOTION_PR_TOKEN` (a PAT with "Pull requests: Read and write" permission)
— the workflow automatically prefers it over the default token.
- If asked to investigate a failed run, check the Actions tab for the run logs
first, then check for an open tracking Issue with one of the titles above —
don't just re-run blindly without understanding why it failed.

## When an agent should act directly

Use this manual flow only if asked to promote directly, to check status, or if
CI/CD isn't available/didn't run:

1. Confirm the relevant PR(s) actually merged into `sandbox`
(`git log origin/sandbox` / `gh pr view <n>`).
2. Validate locally exactly like CI does:
- `npm install` (if `node_modules/` is missing)
- `npm run build` (must pass — this is the hard gate)
3. Check for an existing open promotion PR before creating a new one:
- `gh pr list --base main --head sandbox --state open`
- If one exists, update it (new commits/description) rather than opening a
duplicate.
4. If none exists and the build passed, open a `sandbox → main` PR (not draft,
since it needs human review) summarizing the commits being promoted
(`git log origin/main..origin/sandbox --oneline`).
5. Request review from the repo owner as the notification mechanism.

## Guardrails

- Never merge the promotion PR yourself, and never enable auto-merge on it —
promotion to production always requires a manual human merge, per explicit
user instruction.
- Never push directly to `main`. All production changes go through a
`sandbox → main` pull request.
- Never force-push `sandbox` or `main`.
- Do not open a promotion PR if the build fails, or if `sandbox` has no commits
ahead of `main` (nothing to promote).
- Treat `npm run build` as the required gate and `npm run lint` as informational
only — this repo has pre-existing lint findings that are unrelated to
promotion readiness (see `CLOUD.md`'s "main priority is to keep the site
building successfully").
- If you change the promotion automation itself
(`.github/workflows/promote-sandbox-to-main.yml`), keep the "build gate +
human-merged PR" contract intact; do not introduce auto-merge behavior
without an explicit user request.
261 changes: 261 additions & 0 deletions .github/workflows/promote-sandbox-to-main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
name: Validate sandbox and promote to main

# Runs after any merge into `sandbox` (a merged PR triggers a push to sandbox).
# 1. Validates that sandbox still builds successfully.
# 2. If valid, opens (or updates) a sandbox -> main pull request for a human to review and merge.
# This workflow NEVER merges the PR itself - promotion to production always requires a manual merge.
#
# Failure path: if the build fails, or the PR can't be created/updated (most
# commonly because "Allow GitHub Actions to create and approve pull requests"
# is disabled under Settings > Actions > General > Workflow permissions), this
# workflow opens/updates a tracking GitHub Issue so a human is notified even
# though the job itself still fails (visible as a red X in the Actions tab).
on:
push:
branches:
- sandbox
workflow_dispatch: {}

permissions:
contents: read
pull-requests: write
issues: write

concurrency:
group: promote-sandbox-to-main
cancel-in-progress: false

env:
# Falls back to the default token, but automatically uses a PAT instead if
# one is added later as a repo secret named PROMOTION_PR_TOKEN - useful if
# you'd rather not enable "Allow GitHub Actions to create and approve pull
# requests" repo-wide.
GH_TOKEN: ${{ secrets.PROMOTION_PR_TOKEN || secrets.GITHUB_TOKEN }}
BUILD_FAILURE_ISSUE_TITLE: "Sandbox promotion automation needs attention: build failed"
PR_FAILURE_ISSUE_TITLE: "Sandbox promotion automation needs attention: could not open/update PR"

jobs:
validate:
name: Validate sandbox build
runs-on: ubuntu-latest
outputs:
ahead-count: ${{ steps.diff.outputs.ahead-count }}
steps:
- name: Checkout sandbox
uses: actions/checkout@v4
with:
ref: sandbox
fetch-depth: 0

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"

- name: Install dependencies
run: npm ci

- name: Build (required gate)
run: npm run build

- name: Lint (informational only, does not block promotion)
id: lint
continue-on-error: true
run: npm run lint

- name: Check how far ahead sandbox is of main
id: diff
run: |
git fetch origin main --depth=1 2>/dev/null || git fetch origin main
AHEAD=$(git rev-list --count origin/main..HEAD)
echo "sandbox is $AHEAD commit(s) ahead of main"
echo "ahead-count=$AHEAD" >> "$GITHUB_OUTPUT"

- name: Record lint outcome
run: |
echo "Lint step outcome: ${{ steps.lint.outcome }}"

# Runs only when a previous required step (checkout/install/build) failed.
- name: Report build failure
if: failure()
env:
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
{
echo "The sandbox build failed in [this workflow run]($RUN_URL), so no"
echo "sandbox -> main promotion PR was created or updated."
echo
echo "Check the run logs for the actual error, fix it on \`sandbox\`, and this"
echo "workflow will automatically re-run on the next push (or trigger it manually"
echo "via Actions > Validate sandbox and promote to main > Run workflow)."
} > build-failure-body.md

EXISTING=$(gh issue list --state open --json number,title \
--jq ".[] | select(.title == \"$BUILD_FAILURE_ISSUE_TITLE\") | .number" | head -n1)

if [ -n "$EXISTING" ]; then
gh issue comment "$EXISTING" --body-file build-failure-body.md
else
gh issue create --title "$BUILD_FAILURE_ISSUE_TITLE" --body-file build-failure-body.md --label bug
fi

# Runs when the build succeeds, to auto-resolve a previously filed failure issue.
- name: Close stale build-failure issue if now fixed
if: success()
run: |
EXISTING=$(gh issue list --state open --json number,title \
--jq ".[] | select(.title == \"$BUILD_FAILURE_ISSUE_TITLE\") | .number" | head -n1)

if [ -n "$EXISTING" ]; then
gh issue comment "$EXISTING" --body "Build passed again at \`${{ github.sha }}\`. Closing."
gh issue close "$EXISTING"
fi

promote:
name: Open/update promotion PR
needs: validate
# Only runs if `validate` succeeded (default GitHub Actions behavior for
# jobs with `needs`) AND sandbox actually has commits main doesn't have yet.
if: success() && needs.validate.outputs.ahead-count != '0'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Build PR description file
env:
VALIDATED_SHA: ${{ github.sha }}
WORKFLOW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
{
echo "## Sandbox validated and ready for production"
echo
echo "This PR was opened automatically after \`sandbox\` was validated by"
echo "[this workflow run]($WORKFLOW_RUN_URL)."
echo
echo "- Build (\`npm run build\`): passed"
echo "- Commit validated: \`$VALIDATED_SHA\`"
echo
echo "### Commits included in this promotion"
echo
git log origin/main..origin/sandbox --pretty=format:'- %s (%h)'
echo
echo
echo "### Review checklist"
echo
echo "- [ ] Build passed in CI (see above)"
echo "- [ ] Changes reviewed and look correct for production"
echo "- [ ] Ready to deploy (merging this PR triggers the GitHub Pages deploy workflow)"
echo
echo "**This PR requires manual human review and must be merged by a person.**"
echo "This automation will never merge it."
} > promotion-pr-body.md

- name: Create or update promotion PR
id: promote_pr
env:
VALIDATED_SHA: ${{ github.sha }}
run: |
EXISTING_PR=$(gh pr list --base main --head sandbox --state open --json number --jq '.[0].number')
DELIM="PROMOTION_ERR_$$"

if [ -n "$EXISTING_PR" ]; then
echo "Updating existing promotion PR #$EXISTING_PR"
if ! ERROR_OUTPUT=$(gh pr edit "$EXISTING_PR" --body-file promotion-pr-body.md 2>&1); then
echo "$ERROR_OUTPUT"
{
echo "promotion-error<<$DELIM"
echo "$ERROR_OUTPUT"
echo "$DELIM"
} >> "$GITHUB_OUTPUT"
exit 1
fi
gh pr comment "$EXISTING_PR" --body "Sandbox re-validated at \`$VALIDATED_SHA\` - build passed. Ready for review." || true
echo "pr-number=$EXISTING_PR" >> "$GITHUB_OUTPUT"
else
echo "Creating new promotion PR"
if ! ERROR_OUTPUT=$(gh pr create --base main --head sandbox --title "Promote sandbox to main ($(date -u +%Y-%m-%d))" --body-file promotion-pr-body.md 2>&1); then
echo "$ERROR_OUTPUT"
{
echo "promotion-error<<$DELIM"
echo "$ERROR_OUTPUT"
echo "$DELIM"
} >> "$GITHUB_OUTPUT"
exit 1
fi
echo "$ERROR_OUTPUT"
fi

# Runs only when the PR create/update step above failed.
- name: Report promotion failure
if: failure() && steps.promote_pr.outcome == 'failure'
env:
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
PROMOTION_ERROR: ${{ steps.promote_pr.outputs.promotion-error }}
run: |
{
echo "The sandbox -> main promotion PR could not be created or updated"
echo "automatically in [this workflow run]($RUN_URL)."
echo
if echo "$PROMOTION_ERROR" | grep -qi "not permitted to create or approve pull requests"; then
echo "**Likely cause:** GitHub Actions is not allowed to create pull requests"
echo "in this repository."
echo
echo "**Fix:** go to **Settings > Actions > General > Workflow permissions**,"
echo "enable **\"Allow GitHub Actions to create and approve pull requests\"**,"
echo "save, then re-run this workflow (Actions tab > this workflow > Re-run"
echo "jobs), or just push again to \`sandbox\`."
echo
echo "Alternatively, add a repo secret named \`PROMOTION_PR_TOKEN\` containing a"
echo "personal access token with \"Pull requests: Read and write\" permission -"
echo "this workflow will automatically use it instead of the default token."
echo
fi
echo "<details><summary>Error output</summary>"
echo
echo '```'
echo "$PROMOTION_ERROR"
echo '```'
echo
echo "</details>"
echo
echo "You can also create the PR manually in the meantime:"
echo
echo '```'
echo "gh pr create --base main --head sandbox --title \"Promote sandbox to main\""
echo '```'
} > promotion-failure-body.md

EXISTING=$(gh issue list --state open --json number,title \
--jq ".[] | select(.title == \"$PR_FAILURE_ISSUE_TITLE\") | .number" | head -n1)

if [ -n "$EXISTING" ]; then
gh issue comment "$EXISTING" --body-file promotion-failure-body.md
else
gh issue create --title "$PR_FAILURE_ISSUE_TITLE" --body-file promotion-failure-body.md --label bug
fi

- name: Request review from repo owner (notification)
env:
REPO_OWNER: ${{ github.repository_owner }}
continue-on-error: true
run: |
PR_NUMBER=$(gh pr list --base main --head sandbox --state open --json number --jq '.[0].number')
if [ -n "$PR_NUMBER" ]; then
gh pr edit "$PR_NUMBER" --add-reviewer "$REPO_OWNER" || true
fi

# Runs when PR create/update succeeds, to auto-resolve a previously filed failure issue.
- name: Close stale promotion-failure issue if now fixed
if: steps.promote_pr.outcome == 'success'
run: |
EXISTING=$(gh issue list --state open --json number,title \
--jq ".[] | select(.title == \"$PR_FAILURE_ISSUE_TITLE\") | .number" | head -n1)

if [ -n "$EXISTING" ]; then
gh issue comment "$EXISTING" --body "Promotion PR created/updated successfully at \`${{ github.sha }}\`. Closing."
gh issue close "$EXISTING"
fi
Loading