diff --git a/.github/workflows/preview-cleanup.yml b/.github/workflows/preview-cleanup.yml new file mode 100644 index 0000000..522ca0a --- /dev/null +++ b/.github/workflows/preview-cleanup.yml @@ -0,0 +1,39 @@ +name: Cleanup PR Preview + +# Removes the preview/pr- directory from gh-pages when a PR closes. Uses +# pull_request_target (not pull_request) so it gets a write-scoped GITHUB_TOKEN even +# for fork-based PRs — the same 403 that hits preview.yml's old deploy step would +# otherwise hit this cleanup too. This is a safe use of pull_request_target because +# the job never checks out or executes the PR's code: it only checks out the +# maintainer-controlled gh-pages branch and deletes a directory named after the PR +# number. +on: + pull_request_target: + types: [closed] + branches: [main] + +concurrency: + group: "preview-cleanup-${{ github.event.pull_request.number }}" + cancel-in-progress: false + +jobs: + cleanup-preview: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout gh-pages branch + uses: actions/checkout@v6 + with: + ref: gh-pages + + - name: Remove preview directory + run: | + PR_DIR="preview/pr-${{ github.event.pull_request.number }}" + if [ -d "$PR_DIR" ]; then + git rm -rf "$PR_DIR" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git commit -m "Remove preview for PR #${{ github.event.pull_request.number }}" + git push origin gh-pages + fi diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml new file mode 100644 index 0000000..7d68f93 --- /dev/null +++ b/.github/workflows/preview-deploy.yml @@ -0,0 +1,177 @@ +name: Deploy PR Preview + +# Privileged consumer half of the preview split. Triggered by workflow_run once +# "Preview PR" (preview.yml) finishes, this workflow always runs against the base +# repo's workflow definition, so it gets a write-scoped GITHUB_TOKEN — even when the +# triggering PR came from a fork. Crucially, it never checks out or executes the PR's +# code: it only downloads the already-built static site artifact that preview.yml +# produced, so there's no risk of a malicious PR running arbitrary code with a +# write-capable token (the failure mode `pull_request_target` on the whole build +# would have exposed us to). +# +# Deploys are additionally gated on the PR author being a member of the +# forgebook-contributors GitHub team, matching CONTRIBUTING.md's "curated while +# stabilizing" publishing model. Checking team membership needs an org-scoped +# token — the default GITHUB_TOKEN cannot read team membership even for a visible +# ("closed" privacy) team — so this requires a maintainer-created fine-grained PAT +# (org `read:` members/teams permission only) stored as the FORGEBOOK_TEAM_READ_TOKEN +# repo secret. That secret is a manual one-time setup step; without it the team +# membership check step will fail loudly rather than silently treating everyone as +# a non-member. +on: + workflow_run: + workflows: ["Preview PR"] + types: [completed] + +concurrency: + group: "preview-deploy-${{ github.event.workflow_run.id }}" + cancel-in-progress: false + +jobs: + deploy: + if: ${{ github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + actions: read + steps: + - name: Download PR metadata artifact + uses: actions/download-artifact@v4 + with: + name: pr-metadata + path: ./pr-meta + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Download built site artifact + uses: actions/download-artifact@v4 + with: + name: preview-dist + path: ./site-dist + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Read PR metadata + id: metadata + run: | + PR_NUMBER=$(jq -r .prNumber ./pr-meta/metadata.json) + HEAD_SHA=$(jq -r .headSha ./pr-meta/metadata.json) + echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT" + + - name: Get PR author + id: pr + uses: actions/github-script@v9 + env: + PR_NUMBER: ${{ steps.metadata.outputs.pr_number }} + with: + script: | + const prNumber = Number(process.env.PR_NUMBER); + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + core.setOutput('author', pr.user.login); + + # Requires the FORGEBOOK_TEAM_READ_TOKEN repo secret — see comment at top of file. + - name: Check forgebook-contributors team membership + id: membership + uses: actions/github-script@v9 + env: + PR_AUTHOR: ${{ steps.pr.outputs.author }} + with: + github-token: ${{ secrets.FORGEBOOK_TEAM_READ_TOKEN }} + script: | + const username = process.env.PR_AUTHOR; + let isMember = false; + try { + const res = await github.rest.teams.getMembershipForUserInOrg({ + org: 'microsoft-foundry', + team_slug: 'forgebook-contributors', + username, + }); + isMember = res.data.state === 'active'; + } catch (err) { + if (err.status !== 404) { + throw err; + } + isMember = false; + } + core.setOutput('is_member', isMember); + + - name: Deploy preview to GitHub Pages subdirectory + if: steps.membership.outputs.is_member == 'true' + uses: JamesIves/github-pages-deploy-action@v4 + with: + branch: gh-pages + folder: ./site-dist + target-folder: preview/pr-${{ steps.metadata.outputs.pr_number }} + clean: true + + - name: Comment PR with preview URL + if: steps.membership.outputs.is_member == 'true' + uses: actions/github-script@v9 + env: + PR_NUMBER: ${{ steps.metadata.outputs.pr_number }} + with: + script: | + const prNumber = Number(process.env.PR_NUMBER); + const previewUrl = `https://microsoft-foundry.github.io/forgebook/preview/pr-${prNumber}/`; + const body = `## 🔍 Preview\n\nA live preview of this PR is available at:\n**${previewUrl}**`; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + }); + const botComment = comments.find(c => c.body.includes('## 🔍 Preview')); + if (botComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body, + }); + } + + - name: Comment PR that a maintainer needs to review before preview deploys + if: steps.membership.outputs.is_member == 'false' + uses: actions/github-script@v9 + env: + PR_NUMBER: ${{ steps.metadata.outputs.pr_number }} + PR_AUTHOR: ${{ steps.pr.outputs.author }} + with: + script: | + const prNumber = Number(process.env.PR_NUMBER); + const body = `## 🔍 Preview\n\nBuild and checks passed, but a live preview wasn't auto-deployed because @${process.env.PR_AUTHOR} isn't a member of \`forgebook-contributors\` yet. A maintainer can add you to the team and re-run this workflow to deploy the preview.`; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + }); + const botComment = comments.find(c => c.body.includes('## 🔍 Preview')); + if (botComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body, + }); + } diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index dbd21e7..250030b 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -1,8 +1,15 @@ name: Preview PR +# Unprivileged build/validate/test job. Runs on the `pull_request` event so it works +# identically for fork and same-repo PRs — but GitHub always issues a read-only +# GITHUB_TOKEN for fork-based pull_request runs, so this workflow never attempts to +# push anywhere. It hands its build output off as an artifact; the actual gh-pages +# deploy (which needs write access) happens in the separate preview-deploy.yml +# workflow, triggered via workflow_run once this one completes. See preview-deploy.yml +# for why the split exists and how the deploy is gated. on: pull_request: - types: [opened, synchronize, reopened, closed] + types: [opened, synchronize, reopened] branches: [main] concurrency: @@ -10,13 +17,11 @@ concurrency: cancel-in-progress: true jobs: - # --- Build, validate, and deploy preview --- + # --- Build, validate, and test; hand off to preview-deploy.yml via artifact --- build-and-preview: - if: ${{ github.event.action != 'closed' }} runs-on: ubuntu-latest permissions: - contents: write - pull-requests: write + contents: read steps: - name: Checkout repository uses: actions/checkout@v6 @@ -58,63 +63,29 @@ jobs: FORGEBOOK_TEST_BASE_PATH: /forgebook/preview/pr-${{ github.event.pull_request.number }} run: npm run test:a11y - - name: Deploy preview to GitHub Pages subdirectory - uses: JamesIves/github-pages-deploy-action@v4 - with: - branch: gh-pages - folder: ./site/dist - target-folder: preview/pr-${{ github.event.pull_request.number }} - clean: true + # workflow_run doesn't expose github.event.pull_request for fork-based PRs + # (GitHub leaves `pull_requests` empty in that case), so pass what + # preview-deploy.yml needs along explicitly via an artifact. + - name: Save PR metadata for preview-deploy workflow + run: | + mkdir -p ./pr-meta + cat < ./pr-meta/metadata.json + { + "prNumber": ${{ github.event.pull_request.number }}, + "headSha": "${{ github.event.pull_request.head.sha }}" + } + EOF - - name: Comment PR with preview URL - uses: actions/github-script@v9 + - name: Upload PR metadata + uses: actions/upload-artifact@v4 with: - script: | - const previewUrl = `https://microsoft-foundry.github.io/forgebook/preview/pr-${{ github.event.pull_request.number }}/`; - const body = `## 🔍 Preview\n\nA live preview of this PR is available at:\n**${previewUrl}**`; - - // Update existing comment or create new one - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - }); - const botComment = comments.find(c => c.body.includes('## 🔍 Preview')); - if (botComment) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: botComment.id, - body, - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body, - }); - } + name: pr-metadata + path: ./pr-meta/metadata.json + retention-days: 1 - # --- Clean up preview on PR close --- - cleanup-preview: - if: ${{ github.event.action == 'closed' }} - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Checkout gh-pages branch - uses: actions/checkout@v6 + - name: Upload built site + uses: actions/upload-artifact@v4 with: - ref: gh-pages - - - name: Remove preview directory - run: | - PR_DIR="preview/pr-${{ github.event.pull_request.number }}" - if [ -d "$PR_DIR" ]; then - git rm -rf "$PR_DIR" - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git commit -m "Remove preview for PR #${{ github.event.pull_request.number }}" - git push origin gh-pages - fi + name: preview-dist + path: ./site/dist + retention-days: 1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cb0f092..47d810f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,7 +39,7 @@ For site bugs, use the [Bug Report template](https://github.com/microsoft-foundr cd scripts && npx tsx validate-registry.ts ``` -CI runs registry validation and site build automatically. A live preview URL is posted on your PR. +CI runs registry validation, site build, and accessibility checks automatically on every PR. A live preview URL is posted on your PR once you're a member of the [`forgebook-contributors`](https://github.com/orgs/microsoft-foundry/teams/forgebook-contributors) GitHub team — this keeps auto-deployed previews scoped to approved contributors while publishing stays curated. If checks pass but you're not yet on the team, the PR gets a comment saying so instead of a preview link; ask a maintainer to add you and re-run the workflow. Recipe PRs should include review from the relevant Foundry DX, PM, or engineering owner for technical accuracy and product positioning.