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
39 changes: 39 additions & 0 deletions .github/workflows/preview-cleanup.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Cleanup PR Preview

# Removes the preview/pr-<n> 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
177 changes: 177 additions & 0 deletions .github/workflows/preview-deploy.yml
Original file line number Diff line number Diff line change
@@ -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,
});
}
93 changes: 32 additions & 61 deletions .github/workflows/preview.yml
Original file line number Diff line number Diff line change
@@ -1,22 +1,27 @@
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:
group: "preview-pr-${{ github.event.pull_request.number }}"
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
Expand Down Expand Up @@ -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 <<EOF > ./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
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading