Skip to content

ci: Gate PR Preview Deploys from Outside Contributors - #2504

Open
rheisler-deque wants to merge 10 commits into
developfrom
ci/2486-gate-docs-pr-preview-deploys
Open

ci: Gate PR Preview Deploys from Outside Contributors#2504
rheisler-deque wants to merge 10 commits into
developfrom
ci/2486-gate-docs-pr-preview-deploys

Conversation

@rheisler-deque

@rheisler-deque rheisler-deque commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

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.yml builds and deploys the preview.
  • .github/workflows/pr-preview-cleanup.yml deletes the preview branch when the PR closes.
  • CONTRIBUTING.md describes 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.

  • Amplify keeps the preview URL and the root-served build. Pages would change the URL and serve from a subpath, which likely needs webpack publicPath and router base-path rework.
  • Pages would split hosting, because production docs stay on Amplify.
  • Pages' one real gain is removing the AWS credential. That gain is smaller than it looks, because Pages still needs the same gated build, the same Environment, and the same maintainer approval.

Implementation details and Decisions for Reviewers

Two gates, and why the build is gated

There are two gates.

  • Gate A, in this repo's code: one workflow on pull_request_target builds 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.
  • Gate B, a repo setting: "Require approval for all outside collaborators" under Settings, Actions, General. This gates every other fork workflow, mainly 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_target is 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.

  • The build job checks out the fork head and runs pnpm build with permissions: contents: read and no secret. Even though it runs fork code, there is nothing to steal.
  • The deploy job runs no fork code. It downloads the built files as an artifact and pushes them to Amplify. The fork controls the preview's static content. That is the point of a preview. No fork code executes in the job that holds the credential.

pull_request_target runs the workflow definition from the base branch, so the workflow code is trusted. author_association comes 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. Using pull_request_target safely 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.

  • Anyone without write access cannot push a branch to the repo. Their only path is to fork and open the PR from the fork. So every outside contributor PR is a fork PR.
  • A Deque org member can still work from a personal fork. Their PR would then be a fork PR even though they are a member.
  • A non-member could be granted collaborator write access. Their PR would then be a same-repo PR.

A plain fork check would wrongly block a member who works from a fork. Gating on author_association in OWNER, MEMBER, COLLABORATOR follows 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, since pnpm build is fork code. The job has no secret and only contents: read, so running the fork's dependencies action adds no new access. The checkout sets persist-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

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.
@rheisler-deque
rheisler-deque marked this pull request as ready for review August 7, 2026 15:30
@rheisler-deque
rheisler-deque requested a review from a team as a code owner August 7, 2026 15:30
Copilot AI review requested due to automatic review settings August 7, 2026 15:30
@rheisler-deque rheisler-deque changed the title ci: gate docs PR preview deploys ci: Gate PR Preview Deploys from Outside Contributors Aug 7, 2026
@rheisler-deque

rheisler-deque commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

To Be reviewed with the code changes in this PR

Facts you need first

  • Region: us-east-1.
  • Repo: dequelabs/cauldron.
  • AWS account id: 935235593273, the account that owns the Amplify apps. Confirm you are in it with aws sts get-caller-identity --query Account --output text.

Part 1: AWS

Step 1.1: Create the manual-deploy preview app

Create 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.

aws amplify create-app --name cauldron-pr-previews --platform WEB --region us-east-1 --query app.appId --output text

--platform WEB is a static site. Manual deploy does not support server-side rendered apps, and the docs site is static. The command prints the new app id. Save it as <PREVIEW_APP_ID>. You use it in Step 1.4 and in Part 2.

Step 1.2: Set the site rewrite rules

A new app has no rewrite rules. The docs site needs them, or /storybook and every client-side route return 404. These rules match what the existing apps already serve.

Save this as custom-rules.json:

[
  { "source": "/storybook", "target": "/storybook/", "status": "301" },
  { "source": "/storybook/", "target": "/storybook/index.html", "status": "200" },
  { "source": "/<*>", "target": "/index.html", "status": "404-200" }
]

Apply them:

aws amplify update-app --app-id <PREVIEW_APP_ID> --region us-east-1 --custom-rules file://custom-rules.json

The /<*> rule must stay last. Amplify reads the rules in order, and it is the catch-all that sends unmatched paths to the single-page-app entry point.

Step 1.3: Write the trust policy

This 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 sts.amazonaws.com, and only the subject repo:dequelabs/cauldron:pull_request. That subject is what a workflow job with no environment gets on a pull_request_target event. The deploy and cleanup jobs both match it.

Save this as trust-policy.json.

{
  "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 policy

This grants only the Amplify actions the workflows call, only on this one app. Save this as permissions-policy.json.

{
  "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 GetBranch, CreateBranch, CreateDeployment, and StartDeployment. The cleanup job calls GetBranch and DeleteBranch. The zip upload uses a presigned URL and needs no IAM permission. amplify:GetJob is not needed, because the workflow does not poll deployment status. Add it later only if you make the deploy wait on the job.

Step 1.5: Create the role and attach the policy

Create the role with the trust policy:

aws iam create-role --role-name cauldron-docs-preview-deploy --assume-role-policy-document file://trust-policy.json --description "GitHub OIDC role for gated Cauldron docs PR previews"

Attach the permissions policy inline:

aws iam put-role-policy --role-name cauldron-docs-preview-deploy --policy-name amplify-preview-deploy --policy-document file://permissions-policy.json

Read back the role ARN. You paste this into GitHub in Part 2.

aws iam get-role --role-name cauldron-docs-preview-deploy --query Role.Arn --output text

Part 2: GitHub

Step 2.1: Set the repo variables

Set 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.

gh variable set AMPLIFY_PREVIEW_ROLE_ARN --repo dequelabs/cauldron --body "arn:aws:iam::935235593273:role/cauldron-docs-preview-deploy"

gh variable set AMPLIFY_APP_ID --repo dequelabs/cauldron --body "<PREVIEW_APP_ID>"

Console path if you prefer: Settings, then Secrets and variables, then Actions, then the Variables tab, then New repository variable. Create both AMPLIFY_PREVIEW_ROLE_ARN and AMPLIFY_APP_ID. Both workflows fail if AMPLIFY_APP_ID is missing, so set it before the PR merges.

Step 2.2: Create the member environment

This is the no-reviewer path. Member PRs use it and deploy with no pause.

  • Go to Settings, then Environments, then New environment.
  • Name it exactly pr-preview-auto.
  • Add no required reviewers.
  • Under Deployment branches and tags, choose Selected branches and tags, and add a rule for develop. Under pull_request_target the ref is the base branch, which is develop.
  • Save.

Step 2.3: Create the gated environment

This is the approval path. Outside PRs use it and pause until a maintainer approves.

  • New environment. Name it exactly pr-preview-gated.
  • Turn on Required reviewers. Add the Cauldron maintainer team or the specific maintainers who should approve previews.
  • Set the same Deployment branches and tags rule for develop.
  • Save.

Note. Required reviewers on an environment are free for public repos. Cauldron is public, so this is available.

Warning. Never add a secret to pr-preview-auto or pr-preview-gated. The build job sets its environment to one of these and runs fork code inside it. A secret on either environment would be readable by that fork code. These environments carry only reviewers and branch rules, never secrets. The AWS role lives in a repo variable, not here.

Step 2.4: Turn on Gate B, the fork-approval setting

This gates the other fork workflows, mainly tests.yml. The preview workflow does not need it, because pull_request_target is exempt, but the rest of the repo does.

  • Go to Settings, then Actions, then General.
  • Find Fork pull request workflows from outside collaborators.
  • Select Require approval for all outside collaborators.
  • Save.

Part 3: Merge the PR

Merge this branch's PR to develop. A pull_request_target workflow runs the definition from the base branch, so these workflows only run once they are on develop.

Do Parts 1 and 2 before you merge. If the workflow goes live before the pr-preview-gated environment exists, GitHub auto-creates that environment on first use with no reviewers. An outside PR would then build with no pause during that window. The build holds no secret, so the risk is only CI compute use. The deploy would fail anyway, because the role variable would not exist yet.

Part 4: Confirm it works

  • Open a PR from a branch inside the repo, as a member. The build should run with no pause. The deploy should run. A comment with the preview link should appear. The site should load, including /storybook.
  • Open a PR from a fork by a non-member, or ask someone outside the org. The build job should pause with a "waiting for approval" state, and no runner should start.
  • Approve it. The build should run, the deploy should follow, and the link should comment.
  • Confirm the fork PR's tests.yml also waited for approval. That confirms Gate B is on.
  • Close both PRs. The cleanup workflow should delete the pr-<number> Amplify branches with no pause.

Part 5: Retire the old preview app

Do this only after Part 4 passes and the new previews work. This removes the old cauldron-preview app (d15792l1n26ww3). Native previews used it. Nothing needs it once previews run on the new app.

First confirm nothing depends on it. Check for a custom domain:

aws amplify list-domain-associations --app-id d15792l1n26ww3 --region us-east-1

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:

aws amplify delete-app --app-id d15792l1n26ww3 --region us-east-1

Leave cauldron-dev (d1gko6en628vir) alone. It is a separate app.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_target workflow that routes PRs to an auto-approve vs. maintainer-approved GitHub Environment based on author_association, builds the preview artifact, then deploys it to Amplify and comments the preview URL.
  • Adds a cleanup pull_request_target workflow to delete the corresponding Amplify preview branch when a PR is closed.
  • Updates CONTRIBUTING.md to 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.

Comment thread .github/workflows/pr-preview.yml
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.
Comment thread .github/workflows/pr-preview.yml Outdated
Comment thread .github/workflows/pr-preview.yml Outdated
Comment thread .github/workflows/pr-preview.yml
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.
chornonoh-vova
chornonoh-vova previously approved these changes Aug 11, 2026
Bracciata
Bracciata previously approved these changes Aug 11, 2026

@Bracciata Bracciata left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work!

Comment thread .github/workflows/pr-preview.yml Outdated
Co-authored-by: Anastasia Lanz <anastasia.lanz@deque.com>
Comment thread .github/workflows/pr-preview.yml Outdated
Comment thread .github/workflows/pr-preview.yml Outdated
Comment thread .github/workflows/pr-preview.yml Outdated

@frankensteinke frankensteinke left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working on this! Looks good overall, just left a couple of minor comments/questions.

Comment thread .github/workflows/pr-preview.yml
Comment thread .github/workflows/pr-preview-cleanup.yml
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Gate docs PR preview deploys so outside-contributor code does not deploy without approval

6 participants