diff --git a/.github/scripts/resolve-workflow-run-pr.mjs b/.github/scripts/resolve-workflow-run-pr.mjs
new file mode 100644
index 0000000..3a027db
--- /dev/null
+++ b/.github/scripts/resolve-workflow-run-pr.mjs
@@ -0,0 +1,43 @@
+export default async function resolveWorkflowRunPr({
+ github,
+ context,
+ expectedBaseBranch,
+}) {
+ const workflowRun = context.payload.workflow_run;
+ const headRepository = workflowRun?.head_repository?.full_name;
+ const headBranch = workflowRun?.head_branch;
+ const headSha = workflowRun?.head_sha;
+
+ if (!headRepository || !headBranch || !headSha) {
+ throw new Error("Workflow run is missing head repository, branch, or SHA");
+ }
+
+ const baseRepository = `${context.repo.owner}/${context.repo.repo}`;
+ const pullRequests = await github.paginate(github.rest.pulls.list, {
+ ...context.repo,
+ state: "open",
+ base: expectedBaseBranch,
+ per_page: 100,
+ });
+ const matches = pullRequests.filter(
+ (pullRequest) =>
+ pullRequest.head.repo?.full_name === headRepository &&
+ pullRequest.head.ref === headBranch &&
+ pullRequest.head.sha === headSha &&
+ pullRequest.base.repo?.full_name === baseRepository &&
+ pullRequest.base.ref === expectedBaseBranch,
+ );
+
+ if (matches.length !== 1) {
+ throw new Error(
+ `Expected exactly one open pull request for workflow run ${workflowRun.id}; found ${matches.length}`,
+ );
+ }
+
+ const pullRequestNumber = matches[0].number;
+ if (!Number.isSafeInteger(pullRequestNumber) || pullRequestNumber < 1) {
+ throw new Error("GitHub returned an invalid pull request number");
+ }
+
+ return pullRequestNumber;
+}
diff --git a/.github/scripts/resolve-workflow-run-pr.test.mjs b/.github/scripts/resolve-workflow-run-pr.test.mjs
new file mode 100644
index 0000000..a41cf13
--- /dev/null
+++ b/.github/scripts/resolve-workflow-run-pr.test.mjs
@@ -0,0 +1,109 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import resolveWorkflowRunPr from "./resolve-workflow-run-pr.mjs";
+
+const listPullRequests = () => {};
+
+function makeContext(overrides = {}) {
+ return {
+ repo: { owner: "graphql", repo: "gaps" },
+ payload: {
+ workflow_run: {
+ id: 123,
+ head_repository: { full_name: "contributor/gaps" },
+ head_branch: "preview",
+ head_sha: "abc123",
+ ...overrides,
+ },
+ },
+ };
+}
+
+function makePullRequest(overrides = {}) {
+ return {
+ number: 40,
+ head: {
+ repo: { full_name: "contributor/gaps" },
+ ref: "preview",
+ sha: "abc123",
+ },
+ base: {
+ repo: { full_name: "graphql/gaps" },
+ ref: "main",
+ },
+ ...overrides,
+ };
+}
+
+function makeGithub(pullRequests) {
+ return {
+ rest: { pulls: { list: listPullRequests } },
+ paginate: async (method, parameters) => {
+ assert.equal(method, listPullRequests);
+ assert.deepEqual(parameters, {
+ owner: "graphql",
+ repo: "gaps",
+ state: "open",
+ base: "main",
+ per_page: 100,
+ });
+ return pullRequests;
+ },
+ };
+}
+
+test("resolves the PR using the complete workflow identity", async () => {
+ const shellMetacharacterBranch = "preview$(touch${IFS}/tmp/pwned)";
+ const exactMatch = makePullRequest({
+ head: {
+ repo: { full_name: "contributor/gaps" },
+ ref: shellMetacharacterBranch,
+ sha: "abc123",
+ },
+ });
+ const sameBranchFromAnotherFork = makePullRequest({
+ number: 41,
+ head: {
+ repo: { full_name: "attacker/gaps" },
+ ref: shellMetacharacterBranch,
+ sha: "abc123",
+ },
+ });
+
+ const result = await resolveWorkflowRunPr({
+ github: makeGithub([sameBranchFromAnotherFork, exactMatch]),
+ context: makeContext({ head_branch: shellMetacharacterBranch }),
+ expectedBaseBranch: "main",
+ });
+
+ assert.equal(result, 40);
+});
+
+test("rejects stale and ambiguous workflow runs", async () => {
+ await assert.rejects(
+ resolveWorkflowRunPr({
+ github: makeGithub([
+ makePullRequest({
+ head: {
+ repo: { full_name: "contributor/gaps" },
+ ref: "preview",
+ sha: "newer-sha",
+ },
+ }),
+ ]),
+ context: makeContext(),
+ expectedBaseBranch: "main",
+ }),
+ /found 0/,
+ );
+
+ await assert.rejects(
+ resolveWorkflowRunPr({
+ github: makeGithub([makePullRequest(), makePullRequest({ number: 41 })]),
+ context: makeContext(),
+ expectedBaseBranch: "main",
+ }),
+ /found 2/,
+ );
+});
diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml
index 5956201..ea541c5 100644
--- a/.github/workflows/preview-deploy.yml
+++ b/.github/workflows/preview-deploy.yml
@@ -15,19 +15,36 @@ jobs:
name: Deploy preview to Cloudflare
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' }}
+ concurrency:
+ group: preview-deploy-${{ github.event.workflow_run.head_repository.id }}-${{ github.event.workflow_run.head_branch }}
+ cancel-in-progress: true
steps:
- name: Checkout wrangler config
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: main
+ persist-credentials: false
+ sparse-checkout-cone-mode: false
sparse-checkout: |
wrangler.jsonc
.github/scripts
+ - name: Resolve current PR
+ id: pr
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
+ with:
+ result-encoding: string
+ script: |
+ const { default: resolveWorkflowRunPr } = await import('${{ github.workspace }}/.github/scripts/resolve-workflow-run-pr.mjs');
+ return resolveWorkflowRunPr({
+ github,
+ context,
+ expectedBaseBranch: 'main',
+ });
+
# Security: actions/download-artifact handles zip extraction safely
- # (immune to zip-slip), and the artifact only contains static HTML/CSS/JS
- # built by the unprivileged preview-build workflow.
+ # (immune to zip-slip). Treat every extracted file as attacker-controlled.
- name: Download build artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
@@ -36,19 +53,15 @@ jobs:
github-token: ${{ secrets.GITHUB_TOKEN }}
path: _site
- - name: Get PR number
- id: pr
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ # These are valid Cloudflare configuration files. The PR-built artifact must
+ # not ever supply these. If the main branch ever adds these files for real,
+ # copy them over.
+ - name: Reject deployment config from artifact
run: |
- PR_NUM=$(gh pr list --repo "${{ github.repository }}" \
- --head "${{ github.event.workflow_run.head_branch }}" \
- --json number --jq '.[0].number')
- if ! [[ "$PR_NUM" =~ ^[0-9]+$ ]]; then
- echo "::error::Could not determine PR number"
+ if [[ -e _site/.assetsignore || -e _site/_headers || -e _site/_redirects ]]; then
+ echo "::error::Preview artifact contains Cloudflare configuration"
exit 1
fi
- echo "number=$PR_NUM" >> "$GITHUB_OUTPUT"
# Security: Wrangler is configured for static asset serving only (see
# wrangler.jsonc). The uploaded content is pre-built HTML/CSS/JS with no
@@ -59,40 +72,51 @@ jobs:
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
- command: versions upload --no-bundle --preview-alias "pr-${{ steps.pr.outputs.number }}" --message "PR #${{ steps.pr.outputs.number }} preview (${{ github.event.workflow_run.head_sha }})"
+ wranglerVersion: "4.124.0"
+ command: versions upload --no-bundle --preview-alias "pr-${{ steps.pr.outputs.result }}" --message "PR #${{ steps.pr.outputs.result }} preview (${{ github.event.workflow_run.head_sha }})"
- name: Hide old deploy preview comments
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
+ env:
+ PR_NUMBER: ${{ steps.pr.outputs.result }}
with:
script: |
const { default: run } = await import('${{ github.workspace }}/.github/scripts/hide-old-deploy-comments.mjs');
- await run({ github, context, prNumber: ${{ steps.pr.outputs.number }} });
+ await run({ github, context, prNumber: Number(process.env.PR_NUMBER) });
- name: Comment on PR (success)
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
+ env:
+ DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment-url }}
+ PR_NUMBER: ${{ steps.pr.outputs.result }}
with:
script: |
+ const prNumber = Number(process.env.PR_NUMBER);
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
- issue_number: ${{ steps.pr.outputs.number }},
+ issue_number: prNumber,
body: [
'',
'### 🚀 Deploy Preview',
'',
- '- **This commit:** ${{ steps.deploy.outputs.deployment-url }}',
- '- **This PR** (kept up to date): https://pr-${{ steps.pr.outputs.number }}-gaps.graphql-foundation.workers.dev'
+ `- **This commit:** ${process.env.DEPLOYMENT_URL}`,
+ `- **This PR** (kept up to date): https://pr-${prNumber}-gaps.graphql-foundation.workers.dev`
].join('\n')
});
+ # GitHub requires a PR number for this comment. If checkout or PR
+ # resolution fails, that number is unavailable.
- name: Comment on PR (failure)
- if: failure()
+ if: ${{ failure() && steps.pr.outcome == 'success' }}
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
+ env:
+ PR_NUMBER: ${{ steps.pr.outputs.result }}
with:
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
- issue_number: ${{ steps.pr.outputs.number }},
- body: 'Deploy preview failed.\n\n```\ngh run view ${{ github.run_id }} --repo ${{ github.repository }} --log-failed\n```'
+ issue_number: Number(process.env.PR_NUMBER),
+ body: `Deploy preview failed.\n\n\`\`\`\ngh run view ${context.runId} --repo ${context.repo.owner}/${context.repo.repo} --log-failed\n\`\`\``
});
diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml
index f06af2c..d07135e 100644
--- a/.github/workflows/validate.yml
+++ b/.github/workflows/validate.yml
@@ -24,5 +24,8 @@ jobs:
- name: Install dependencies
run: npm ci
+ - name: Test workflow helpers
+ run: npm run test:workflow-helpers
+
- name: Validate GAP structure
run: npm run test:structure
diff --git a/package.json b/package.json
index a2621eb..b7abe40 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
"test:format": "prettier --check . || npm run suggest:format",
"test:spelling": "cspell \"spec/**/*.md\" README.md LICENSE.md",
"test:structure": "find ./gaps -maxdepth 1 -type d -name 'GAP-*' | xargs -I{} ./scripts/validate-structure.js {}",
+ "test:workflow-helpers": "node --test .github/scripts/*.test.mjs",
"sync:codeowners": "node scripts/sync-codeowners.js"
},
"devDependencies": {