ci: Gate PR Preview Deploys from Outside Contributors - #2504
ci: Gate PR Preview Deploys from Outside Contributors#2504rheisler-deque wants to merge 10 commits into
Conversation
Add .github/workflows/pr-preview.yml with resolve and build jobs. The build job references a GitHub Environment resolved from author_association, so an outside PR pauses for maintainer approval before any fork code runs or any artifact uploads. The build runs with no secret and contents: read, checks out the fork head, runs pnpm build, and uploads docs/dist as the pr-preview-site artifact. The deploy job follows in a later commit.
Add the deploy job to .github/workflows/pr-preview.yml. It runs after the gated build, assumes a scoped IAM role via OIDC, and deploys the built docs/dist to an Amplify branch named pr-<number> using the manual deploy flow. It posts the preview URL as a sticky PR comment. The job references no environment, so it does not prompt a second approval, and it runs no fork code. AWS role ARN comes from the AMPLIFY_PREVIEW_ROLE_ARN repo variable.
Add .github/workflows/pr-preview-cleanup.yml. On pull_request_target closed for develop, it assumes the scoped IAM role via OIDC and deletes the Amplify branch pr-<number>. It checks the branch exists first, so a PR that never got a preview is a no-op. It references no environment, so cleanup never waits for approval.
fixed broken link to is-browser.ts
|
To Be reviewed with the code changes in this PR Facts you need first
Part 1: AWSStep 1.1: Create the manual-deploy preview appCreate a new Amplify app with no repository. An app created without a repository is in manual-deploy mode, which is what the workflow needs. The workflow deploys with the Amplify manual deploy API, and that API only works on an app not connected to Git. Both existing Cauldron apps are Git-connected, so the previews need their own app.
Step 1.2: Set the site rewrite rulesA new app has no rewrite rules. The docs site needs them, or Save this as [
{ "source": "/storybook", "target": "/storybook/", "status": "301" },
{ "source": "/storybook/", "target": "/storybook/index.html", "status": "200" },
{ "source": "/<*>", "target": "/index.html", "status": "404-200" }
]Apply them:
The Step 1.3: Write the trust policyThis account already has the GitHub OIDC provider that GitHub Actions uses, confirmed on 2026-08-18. So there is no provider-creation step. This says who may assume the role. It allows only the GitHub OIDC provider, only the audience Save this as {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::935235593273:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:dequelabs/cauldron:pull_request"
}
}
}
]
}Step 1.4: Write the permissions policyThis grants only the Amplify actions the workflows call, only on this one app. Save this as {
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AmplifyPreviewDeploy",
"Effect": "Allow",
"Action": [
"amplify:GetBranch",
"amplify:CreateBranch",
"amplify:DeleteBranch",
"amplify:CreateDeployment",
"amplify:StartDeployment"
],
"Resource": [
"arn:aws:amplify:us-east-1:935235593273:apps/<PREVIEW_APP_ID>",
"arn:aws:amplify:us-east-1:935235593273:apps/<PREVIEW_APP_ID>/branches/*"
]
}
]
}Note. This is the exact set the two workflows call. The deploy job calls Step 1.5: Create the role and attach the policyCreate the role with the trust policy:
Attach the permissions policy inline:
Read back the role ARN. You paste this into GitHub in Part 2.
Part 2: GitHubStep 2.1: Set the repo variablesSet the two Actions variables both workflows read. Use the ARN from Step 1.5 for the first. Use the new app id for the second.
Console path if you prefer: Settings, then Secrets and variables, then Actions, then the Variables tab, then New repository variable. Create both Step 2.2: Create the member environmentThis is the no-reviewer path. Member PRs use it and deploy with no pause.
Step 2.3: Create the gated environmentThis is the approval path. Outside PRs use it and pause until a maintainer approves.
Note. Required reviewers on an environment are free for public repos. Cauldron is public, so this is available. Warning. Never add a secret to Step 2.4: Turn on Gate B, the fork-approval settingThis gates the other fork workflows, mainly
Part 3: Merge the PRMerge this branch's PR to Do Parts 1 and 2 before you merge. If the workflow goes live before the Part 4: Confirm it works
Part 5: Retire the old preview appDo this only after Part 4 passes and the new previews work. This removes the old First confirm nothing depends on it. Check for a custom domain:
If that returns any domain, stop. Do not delete the app. A custom domain means something still points at it. Move the domain off it first, or leave the app in place. If there is no domain, and you have confirmed the app serves nothing you need, delete it:
Leave |
There was a problem hiding this comment.
Pull request overview
This PR replaces AWS Amplify’s automatic PR preview builds with a gated GitHub Actions-based flow to prevent untrusted outside-contributor code from building/deploying without explicit maintainer approval, while still allowing Deque members’ PRs to preview automatically.
Changes:
- Adds a gated
pull_request_targetworkflow that routes PRs to an auto-approve vs. maintainer-approved GitHub Environment based onauthor_association, builds the preview artifact, then deploys it to Amplify and comments the preview URL. - Adds a cleanup
pull_request_targetworkflow to delete the corresponding Amplify preview branch when a PR is closed. - Updates
CONTRIBUTING.mdto document the new preview behavior and update the SSR utility link.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
CONTRIBUTING.md |
Updates contributor documentation to reflect the new gated preview workflow and corrects the SSR helper link. |
.github/workflows/pr-preview.yml |
Implements the gated preview build+deploy workflow and PR comment posting of the preview URL. |
.github/workflows/pr-preview-cleanup.yml |
Implements cleanup of the Amplify preview branch on PR close. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
The comment step calls github.rest.issues.createComment on the PR. The issues.createComment endpoint is served by both the issues and pull-requests permissions, and reports of a 403 with pull-requests:write alone are common enough that we grant issues:write to remove the risk. The deploy job runs no fork code, so the added scope does not widen the trust model.
Two self-review items on #2504: - Pin actions/upload-artifact, actions/download-artifact, and actions/github-script to full commit SHAs, matching actions/checkout and aws-actions/configure-aws-credentials. A floating major tag moves when the owner republishes it. - Move the Amplify app id into a repo variable, AMPLIFY_APP_ID, so it is set in one place instead of hardcoded in the deploy step, the comment script, and the cleanup workflow.
The build job runs contributor code inside the routed GitHub Environment, so a secret on pr-preview-auto or pr-preview-gated could be read by that code. Record the invariant in two permanent places: a comment on the build job's environment block, and a maintainer note in the CONTRIBUTING preview section. GitHub Environments have no description field, so the constraint cannot live on the environment config itself.
Co-authored-by: Anastasia Lanz <anastasia.lanz@deque.com>
4dca9f6
…loys' into ci/2486-gate-docs-pr-preview-deploys
frankensteinke
left a comment
There was a problem hiding this comment.
Thanks for working on this! Looks good overall, just left a couple of minor comments/questions.
What this does
Until recently, AWS Amplify's own GitHub App built and deployed a docs preview for every PR, including fork PRs, with no approval. That ran untrusted PR code in an environment that holds AWS access. Amplify's native previews were turned off as an interim mitigation. This PR replaces them with a gated GitHub Actions workflow.
A PR from a Deque org member deploys a preview automatically. A PR from an outside contributor pauses before anything builds until a Cauldron maintainer approves it, and every push waits for a fresh approval. No AWS credential ever reaches PR code.
Files
.github/workflows/pr-preview.ymlbuilds and deploys the preview..github/workflows/pr-preview-cleanup.ymldeletes the preview branch when the PR closes.CONTRIBUTING.mddescribes the new flow for contributors.Decision: keep Amplify, gate it with a GitHub Actions workflow
We kept AWS Amplify as the preview host and moved the trigger into a gated GitHub Actions workflow. We did not switch to GitHub Pages.
publicPathand router base-path rework.Implementation details and Decisions for Reviewers
Two gates, and why the build is gated
There are two gates.
pull_request_targetbuilds and deploys the preview. The build job carries a GitHub Environment. For an outside PR that environment has required reviewers, so the build pauses before any fork code runs or any artifact uploads. A member PR routes to a no-reviewer environment and runs at once.tests.yml, so a fork's push does not run those or upload their artifacts until a maintainer approves.The gate sits on the build job, not the deploy job. This is deliberate. If the build ran before approval, an outside author could push commit after commit, each firing a build that uploads a large artifact. That would consume Actions minutes and artifact storage with no approval. Gating the build closes that path. Nothing runs for an outside PR until a maintainer approves.
pull_request_targetis exempt from the Gate B setting, because it runs trusted base-branch code. So Gate A covers the preview and Gate B covers the rest. Together they mean no fork workflow runs unattended.The trust model of the single workflow
The build job runs untrusted fork code (only after review and approval by a member of this repo). The deploy job holds the credential. They are separate jobs, so they never share a runner.
pnpm buildwithpermissions: contents: readand no secret. Even though it runs fork code, there is nothing to steal.pull_request_targetruns the workflow definition from the base branch, so the workflow code is trusted.author_associationcomes straight from the trusted event, not from the artifact and not from an API call, so a non-member cannot spoof their identity and route their PR to the no-approval environment. Usingpull_request_targetsafely depends on one rule: the job that checks out fork code gets minimal permissions and no secret. The design follows that rule.Why the gate keys on author_association, not a fork check
The GitHub rule is about write access, not org membership.
A plain fork check would wrongly block a member who works from a fork. Gating on
author_associationinOWNER,MEMBER,COLLABORATORfollows the trust boundary we want.Why not a label
One early idea was to allow members to add a label to a PR they had reviewed and deemed safe, but a label would be exploitable. An outside author could open an innocuous PR, wait for a maintainer to apply the label, then push malicious code that still deploys, because the label persists across pushes.
The Environment approval strategy I used binds to one workflow run and one commit. Each push makes a new run that must be approved again.
The required PR review is unchanged
Cauldron requires an approving review from a write-access person before any PR can merge. This work does not touch that rule. The preview approval is a separate gate that only controls whether the docs preview deploys. It is not a PR review and does not replace one.
The intended order is that a maintainer reviews the full PR first, including its code and security, and the preview approval is the last step to confirm the change works. So the preview never builds or deploys code a maintainer has not already read.
One point a reviewer may ask about
The build job checks out the fork head, then runs
./.github/actions/dependencies. That local action resolves from the checked-out fork tree, so it is the fork's copy of the action, not the base branch's. That is intentional. The build job runs fork code either way, sincepnpm buildis fork code. The job has no secret and onlycontents: read, so running the fork's dependencies action adds no new access. The checkout setspersist-credentials: false, so the read-only token is not left in git config.Requires setup outside this repo before previews work
Important
This PR cannot enable previews on its own. Someone with AWS and GitHub admin access must set up a Github OIDC provider and an IAM role, then in Github set up a repo variable, 2 environments, and turn on a gate.
The details and order of operations are below. Reviewers please also review that.
Closes: #2486