diff --git a/.cursor/rules/medium-blog-sync.mdc b/.cursor/rules/medium-blog-sync.mdc index 485d646..7a6e4e2 100644 --- a/.cursor/rules/medium-blog-sync.mdc +++ b/.cursor/rules/medium-blog-sync.mdc @@ -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 diff --git a/.cursor/rules/sandbox-to-main-promotion.mdc b/.cursor/rules/sandbox-to-main-promotion.mdc new file mode 100644 index 0000000..b6fc498 --- /dev/null +++ b/.cursor/rules/sandbox-to-main-promotion.mdc @@ -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 `). +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. diff --git a/.github/workflows/promote-sandbox-to-main.yml b/.github/workflows/promote-sandbox-to-main.yml new file mode 100644 index 0000000..9a7dab9 --- /dev/null +++ b/.github/workflows/promote-sandbox-to-main.yml @@ -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 "
Error output" + echo + echo '```' + echo "$PROMOTION_ERROR" + echo '```' + echo + echo "
" + 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