From 6ef1f4f9d57fbe0bd553ab93a11acf390992417d Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 12 Sep 2026 17:46:16 +0800 Subject: [PATCH 1/3] feat: run GitLab E2E for labeled fork pull requests --- .github/workflows/e2e-request.yml | 23 +++ .github/workflows/gitlab-e2e.yml | 73 +++++++- README.md | 14 +- src/gitlab/workflow.test.ts | 286 ++++++++++++++++++++++++++++++ 4 files changed, 393 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/e2e-request.yml create mode 100644 src/gitlab/workflow.test.ts diff --git a/.github/workflows/e2e-request.yml b/.github/workflows/e2e-request.yml new file mode 100644 index 0000000..608286d --- /dev/null +++ b/.github/workflows/e2e-request.yml @@ -0,0 +1,23 @@ +name: GitLab E2E request + +# The privileged workflow verifies this file's Git blob before it trusts this +# run-name format. Keep all request metadata in GitHub's run record, not artifacts. +run-name: "PR #${{ github.event.pull_request.number }}: ${{ github.event.action }} ${{ github.event.label.name }} at ${{ github.event.pull_request.head.sha }}" + +on: + pull_request: + branches: [main] + # New commits need a fresh review and label event. + types: [labeled] + +permissions: {} + +jobs: + request: + if: >- + github.event.label.name == 'run-e2e' && + github.event.pull_request.head.repo.full_name != github.repository + runs-on: ubuntu-latest + timeout-minutes: 1 + steps: + - run: echo 'GitLab E2E requested. The workflow on main will validate this request.' diff --git a/.github/workflows/gitlab-e2e.yml b/.github/workflows/gitlab-e2e.yml index 57b4ad7..da08746 100644 --- a/.github/workflows/gitlab-e2e.yml +++ b/.github/workflows/gitlab-e2e.yml @@ -6,6 +6,9 @@ on: tags: ["v*"] pull_request: branches: [main] + workflow_run: + workflows: [GitLab E2E request] + types: [completed] merge_group: workflow_dispatch: inputs: @@ -30,8 +33,15 @@ permissions: jobs: gitlab-e2e: name: GitLab E2E + if: >- + github.event_name != 'workflow_run' || + (github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.path == '.github/workflows/e2e-request.yml' && + github.event.workflow_run.head_repository.full_name != github.repository) runs-on: ubuntu-latest timeout-minutes: 35 + # Keep this privileged job API-only: no PR checkout, artifacts, or caches. steps: - name: Resolve test parameters id: parameters @@ -39,6 +49,13 @@ jobs: EVENT_NAME: ${{ github.event_name }} EVENT_REF: ${{ github.ref }} EVENT_REF_NAME: ${{ github.ref_name }} + REQUEST_EVENT: ${{ github.event.workflow_run.event }} + REQUEST_CONCLUSION: ${{ github.event.workflow_run.conclusion }} + REQUEST_PATH: ${{ github.event.workflow_run.path }} + REQUEST_TITLE: ${{ github.event.workflow_run.display_title }} + REQUEST_ACTOR: ${{ github.event.workflow_run.actor.login }} + REQUEST_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + REQUEST_HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }} PR_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} PR_NUMBER: ${{ github.event.pull_request.number }} @@ -53,11 +70,12 @@ jobs: setup_vp_ref="$GITHUB_SHA" suite=required vite_plus_version=latest + skip_reason="" if [ "$EVENT_NAME" = "pull_request" ]; then if [ "$PR_HEAD_REPOSITORY" != "$GITHUB_REPOSITORY" ]; then should_run=false - echo "The workflow skips fork pull requests. The merge queue or a maintainer can test these changes." + skip_reason='A maintainer with write access can review this fork PR and add the run-e2e label to test its current commit. New commits require removing and re-adding the label. The merge queue also tests the reviewed merge commit.' else setup_vp_ref="$PR_HEAD_SHA" while IFS= read -r changed_path; do @@ -69,6 +87,56 @@ jobs: esac done < <(gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files" --jq '.[].filename') fi + elif [ "$EVENT_NAME" = "workflow_run" ]; then + should_run=false + skip_reason='Only a successful run-e2e label request for a fork PR can approve a GitLab E2E run.' + request_pattern='^PR #([1-9][0-9]*): labeled run-e2e at ([0-9a-f]{40})$' + if [ "$REQUEST_EVENT" = "pull_request" ] && [ "$REQUEST_CONCLUSION" = "success" ] && + [ "$REQUEST_PATH" = ".github/workflows/e2e-request.yml" ] && + [ "$REQUEST_HEAD_REPOSITORY" != "$GITHUB_REPOSITORY" ] && + [[ "$REQUEST_TITLE" =~ $request_pattern ]]; then + PR_NUMBER="${BASH_REMATCH[1]}" + if [ "${BASH_REMATCH[2]}" != "$REQUEST_HEAD_SHA" ]; then + echo "::error::The request title does not match the workflow run's head SHA." + exit 1 + fi + + # Use the original request actor, not the person who reruns it. + # GitHub maps the maintain role to write. + permission="$(gh api "repos/${GITHUB_REPOSITORY}/collaborators/${REQUEST_ACTOR}/permission" --jq '.permission')" + case "$permission" in + admin | write) ;; + *) + echo "::error::Adding run-e2e requires repository write access to approve a test run." + exit 1 + ;; + esac + + # A fork could change the request workflow's triggers or title. + # Compare immutable Git blobs before trusting its run-name format. + # Read these files as metadata only; never execute their contents. + workflow_path=".github/workflows/e2e-request.yml" + trusted_blob="$(gh api "repos/${GITHUB_REPOSITORY}/contents/${workflow_path}?ref=${GITHUB_SHA}" --jq '.sha')" + if ! request_blob="$(gh api "repos/${GITHUB_REPOSITORY}/contents/${workflow_path}?ref=${REQUEST_HEAD_SHA}" --jq '.sha')" || + [ "$request_blob" != "$trusted_blob" ]; then + echo "::error::The fork must include e2e-request.yml unchanged from main. Update the branch, then remove and re-add run-e2e." + exit 1 + fi + + # Fork runs can have an empty workflow_run.pull_requests array. + # Resolve the PR from the verified title and cross-check its head. + pull_request="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}")" + if jq -e --arg sha "$REQUEST_HEAD_SHA" --arg head_repo "$REQUEST_HEAD_REPOSITORY" --arg repo "$GITHUB_REPOSITORY" \ + '.state == "open" and .head.sha == $sha and .head.repo.full_name == $head_repo and + .base.repo.full_name == $repo and .base.ref == "main" and any(.labels[]; .name == "run-e2e")' \ + <<< "$pull_request" > /dev/null; then + should_run=true + setup_vp_ref="$REQUEST_HEAD_SHA" + suite=full + else + skip_reason='This approval is stale or does not match the PR: check its head, base, and run-e2e label. Review the current commit, then remove and re-add run-e2e to test it.' + fi + fi elif [ "$EVENT_NAME" = "merge_group" ]; then suite=full elif [ "$EVENT_NAME" = "workflow_dispatch" ]; then @@ -90,10 +158,11 @@ jobs: } >> "$GITHUB_OUTPUT" if [ "$should_run" = "false" ]; then + echo "$skip_reason" { echo "### GitLab E2E" echo - echo "The workflow skipped this fork pull request. The merge queue tests the reviewed merge commit." + echo "$skip_reason" } >> "$GITHUB_STEP_SUMMARY" fi diff --git a/README.md b/README.md index 1a64e50..37242bf 100644 --- a/README.md +++ b/README.md @@ -419,7 +419,7 @@ setup-vp also provides a GitLab CI/CD remote template hosted from this GitHub re See [GitLab integration notes](rfcs/gitlab-integration.md) for the design background, constraints, and follow-up work. -The dedicated [GitLab end-to-end test project](https://gitlab.com/fengmk2/setup-vp-gitlab-test) tests each setup-vp pull request, merge, and release. The pipeline loads the template, bootstrap script, and compiled runtime from the exact setup-vp commit or release tag that it tests. +The dedicated [GitLab end-to-end test project](https://gitlab.com/fengmk2/setup-vp-gitlab-test) tests same-repository pull requests, approved fork pull requests, merge queue commits, merges, and releases. The pipeline loads the template, bootstrap script, and compiled runtime from the exact setup-vp commit or release tag that it tests. ### Basic GitLab Usage @@ -689,6 +689,18 @@ vp install - Generated files under `dist/` must be committed, including `dist/index.mjs` for the GitHub Action, `dist/gitlab/index.mjs` for the GitLab template, and `dist/azure/index.mjs` for the Azure Pipelines runtime - Pre-commit hooks (via husky + lint-staged) will automatically run `vp check --fix` on staged files via `vpx lint-staged` +### GitLab E2E for Fork Pull Requests + +Fork pull requests skip the automatic GitLab E2E run because `pull_request` workflows cannot access `GITLAB_TRIGGER_TOKEN`. After reviewing the current commit, a maintainer with repository write access can add the `run-e2e` label to run the full GitLab suite. Create this label in the repository if it does not exist. + +The label starts `.github/workflows/e2e-request.yml` through `pull_request`. This small workflow has no secrets and records the label event, PR number, and head SHA in its run name. GitHub may require a maintainer to approve this fork workflow run. Its completion starts `.github/workflows/gitlab-e2e.yml` on `main` through `workflow_run`. + +Both workflows must first be merged into `main`. Update the fork branch from `main` so it includes `e2e-request.yml` unchanged. The handler checks the labeler's write permission and compares the request workflow's Git blob at the requested SHA with the trusted copy. This prevents a fork from changing the events or run name used for approval. It then checks the PR's head SHA, head repository, base branch, open state, and current label through the GitHub API. + +The handler calls the GitLab API without checking out PR code or loading artifacts or caches from the fork. The approval allows the GitLab test project to load and execute the fork's template, bootstrap script, and compiled runtime at the exact PR head SHA from the label event. `workflow_run` has access to secrets, so keep this handler limited to API calls. The GitLab trigger token is available only to the pipeline trigger step. + +Each approval applies to that commit only. New pushes do not trigger another GitLab pipeline, even if the label remains. Review the new commit, then remove and re-add `run-e2e`. A queued run or rerun skips if the PR head changed, the PR closed, or the label was removed. The workflow summary contains the tested SHA, suite, pipeline link, and result. + ### Releasing Releases are published as git tags; there is no npm package, but the `package.json` version tracks the latest release. Consumers pin an exact version tag such as `voidzero-dev/setup-vp@v1.19.0` or a commit SHA. The `v1` major tag is frozen at v1.15.0 and is never moved (an org-level ruleset rejects tag force-pushes). diff --git a/src/gitlab/workflow.test.ts b/src/gitlab/workflow.test.ts new file mode 100644 index 0000000..8144944 --- /dev/null +++ b/src/gitlab/workflow.test.ts @@ -0,0 +1,286 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { parse as parseYaml } from "yaml"; + +const workflow = parseYaml( + readFileSync(new URL("../../.github/workflows/gitlab-e2e.yml", import.meta.url), "utf8"), +); +const requestWorkflow = parseYaml( + readFileSync(new URL("../../.github/workflows/e2e-request.yml", import.meta.url), "utf8"), +); +const steps = workflow.jobs["gitlab-e2e"].steps as Array<{ + id?: string; + uses?: string; + if?: string; + env?: Record; + run: string; +}>; +const parameters = steps.find((step) => step.id === "parameters")!; +const headSha = "a".repeat(40); +const baseSha = "b".repeat(40); +const approvedPr = { + state: "open", + head: { sha: headSha, repo: { full_name: "contributor/setup-vp" } }, + base: { ref: "main", repo: { full_name: "upstream/setup-vp" } }, + labels: [{ name: "run-e2e" }], +}; +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function resolveParameters(overrides: Record = {}) { + const dir = mkdtempSync(join(tmpdir(), "setup-vp-gitlab-workflow-")); + tempDirs.push(dir); + const output = join(dir, "output"); + const summary = join(dir, "summary"); + const calls = join(dir, "calls"); + for (const file of [output, summary, calls]) writeFileSync(file, ""); + writeFileSync( + join(dir, "gh"), + `#!/usr/bin/env bash +set -euo pipefail +printf '%s\\n' "$*" >> "$MOCK_GH_CALLS" +case "$*" in + */permission*) + [ "$MOCK_GH_FAILURE" != permission ] + printf '%s\\n' "$MOCK_PERMISSION" + ;; + *"contents/.github/workflows/e2e-request.yml?ref=$GITHUB_SHA"*) + [ "$MOCK_GH_FAILURE" != trusted_workflow ] + printf '%s\\n' trusted-blob + ;; + *"contents/.github/workflows/e2e-request.yml?ref=$REQUEST_HEAD_SHA"*) + [ "$MOCK_GH_FAILURE" != request_workflow ] + printf '%s\\n' "$MOCK_REQUEST_BLOB" + ;; + */files*) printf '%s\\n' "$MOCK_CHANGED_FILES" ;; + "api repos/upstream/setup-vp/pulls/123") + [ "$MOCK_GH_FAILURE" != pull ] + printf '%s\\n' "$MOCK_PR" + ;; + *) exit 90 ;; +esac +`, + { mode: 0o755 }, + ); + const result = spawnSync("bash", ["-c", parameters.run], { + encoding: "utf8", + env: { + ...process.env, + PATH: `${dir}:${process.env.PATH}`, + GH_TOKEN: "test-token", + GITHUB_OUTPUT: output, + GITHUB_STEP_SUMMARY: summary, + GITHUB_REPOSITORY: "upstream/setup-vp", + GITHUB_SHA: baseSha, + GITHUB_ACTOR: "rerunner", + EVENT_NAME: "workflow_run", + REQUEST_EVENT: "pull_request", + REQUEST_CONCLUSION: "success", + REQUEST_PATH: ".github/workflows/e2e-request.yml", + REQUEST_TITLE: `PR #123: labeled run-e2e at ${headSha}`, + REQUEST_ACTOR: "reviewer", + REQUEST_HEAD_SHA: headSha, + REQUEST_HEAD_REPOSITORY: "contributor/setup-vp", + EVENT_REF: "refs/heads/main", + EVENT_REF_NAME: "main", + PR_HEAD_REPOSITORY: "contributor/setup-vp", + PR_HEAD_SHA: headSha, + PR_NUMBER: "123", + MANUAL_SETUP_REF: "", + MANUAL_SUITE: "", + MANUAL_VITE_PLUS_VERSION: "", + MOCK_GH_CALLS: calls, + MOCK_GH_FAILURE: "", + MOCK_PERMISSION: "write", + MOCK_REQUEST_BLOB: "trusted-blob", + MOCK_PR: JSON.stringify(approvedPr), + MOCK_CHANGED_FILES: "README.md", + ...overrides, + }, + }); + return { + ...result, + outputs: Object.fromEntries( + readFileSync(output, "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => line.split("=")), + ), + summary: readFileSync(summary, "utf8"), + calls: readFileSync(calls, "utf8"), + }; +} + +describe("GitLab E2E workflow", () => { + it("only accepts label events for privileged fork runs and never checks out PR code", () => { + expect(workflow.on).not.toHaveProperty("pull_request_target"); + expect(requestWorkflow.on).toEqual({ + pull_request: { branches: ["main"], types: ["labeled"] }, + }); + expect(requestWorkflow.permissions).toEqual({}); + expect(workflow.on.workflow_run).toEqual({ + workflows: [requestWorkflow.name], + types: ["completed"], + }); + expect(requestWorkflow["run-name"]).toBe( + "PR #${{ github.event.pull_request.number }}: ${{ github.event.action }} ${{ github.event.label.name }} at ${{ github.event.pull_request.head.sha }}", + ); + expect(workflow.permissions).toEqual({ contents: "read", "pull-requests": "read" }); + expect(steps.every((step) => !step.uses && !step.run.includes("${{"))).toBe(true); + expect(parameters.env?.REQUEST_HEAD_SHA).toBe("${{ github.event.workflow_run.head_sha }}"); + expect(parameters.env?.REQUEST_ACTOR).toBe("${{ github.event.workflow_run.actor.login }}"); + for (const step of steps.filter((step) => step !== parameters)) { + expect(step.if).toBe("steps.parameters.outputs.should_run == 'true'"); + } + }); + + it.each(["write", "admin"])( + "runs the full suite at the event SHA with %s access", + (permission) => { + const result = resolveParameters({ MOCK_PERMISSION: permission }); + expect(result.status, result.stderr).toBe(0); + expect(result.outputs).toEqual({ + should_run: "true", + setup_vp_ref: headSha, + suite: "full", + vite_plus_version: "latest", + }); + expect(result.calls).toContain("collaborators/reviewer/permission"); + expect(result.calls).not.toContain("/files"); + }, + ); + + it.each(["read", "none", ""])("rejects approval with %s permission", (permission) => { + const result = resolveParameters({ MOCK_PERMISSION: permission }); + expect(result.status).toBe(1); + expect(result.stdout).toContain("requires repository write access"); + expect(result.outputs.should_run).toBeUndefined(); + expect(result.calls).not.toContain("/pulls/"); + }); + + it.each(["permission", "pull", "trusted_workflow", "request_workflow"])( + "fails closed when the %s API request fails", + (endpoint) => { + const result = resolveParameters({ MOCK_GH_FAILURE: endpoint }); + expect(result.status).toBe(1); + expect(result.outputs.should_run).toBeUndefined(); + }, + ); + + it.each([ + ["new commit", { ...approvedPr, head: { ...approvedPr.head, sha: "c".repeat(40) } }], + [ + "different fork", + { ...approvedPr, head: { sha: headSha, repo: { full_name: "other/setup-vp" } } }, + ], + ["base change", { ...approvedPr, base: { ...approvedPr.base, ref: "release" } }], + [ + "different repository", + { ...approvedPr, base: { ref: "main", repo: { full_name: "other/setup-vp" } } }, + ], + ["closed PR", { ...approvedPr, state: "closed" }], + ["removed label", { ...approvedPr, labels: [{ name: "other-label" }] }], + ])("skips a stale approval after a %s", (_reason, pr) => { + const result = resolveParameters({ MOCK_PR: JSON.stringify(pr) }); + expect(result.status, result.stderr).toBe(0); + expect(result.outputs.should_run).toBe("false"); + expect(result.summary).toContain("This approval is stale"); + }); + + it.each([ + ["unrelated label", { REQUEST_TITLE: `PR #123: labeled bug at ${headSha}` }], + ["new push", { REQUEST_TITLE: `PR #123: synchronize run-e2e at ${headSha}` }], + ["same-repository PR", { REQUEST_HEAD_REPOSITORY: "upstream/setup-vp" }], + ["different event", { REQUEST_EVENT: "push" }], + ["failed request", { REQUEST_CONCLUSION: "failure" }], + ["different workflow", { REQUEST_PATH: ".github/workflows/fake-request.yml" }], + ["malformed title", { REQUEST_TITLE: "PR #123: labeled run-e2e at $(exit 1)" }], + ])("does not approve a privileged run for a %s", (_reason, env) => { + const result = resolveParameters(env); + expect(result.status, result.stderr).toBe(0); + expect(result.outputs.should_run).toBe("false"); + expect(result.calls).toBe(""); + }); + + it("rejects a title that claims a different commit than the request run", () => { + const result = resolveParameters({ REQUEST_TITLE: `PR #123: labeled run-e2e at ${baseSha}` }); + expect(result.status).toBe(1); + expect(result.outputs.should_run).toBeUndefined(); + expect(result.calls).toBe(""); + }); + + it("rejects a modified request workflow even with a valid title and write access", () => { + const result = resolveParameters({ MOCK_REQUEST_BLOB: "modified-blob" }); + expect(result.status).toBe(1); + expect(result.stdout).toContain("must include e2e-request.yml unchanged from main"); + expect(result.outputs.should_run).toBeUndefined(); + expect(result.calls).not.toContain("/pulls/"); + }); + + it("keeps ordinary fork PR runs unprivileged even when the approval label exists", () => { + const result = resolveParameters({ EVENT_NAME: "pull_request" }); + expect(result.status, result.stderr).toBe(0); + expect(result.outputs.should_run).toBe("false"); + expect(result.calls).toBe(""); + expect(result.summary).toContain("add the run-e2e label"); + }); + + it.each([ + ["README.md", "required"], + ["README.md\nsrc/ci/version.ts", "full"], + ])("keeps suite selection for same-repository PRs changing %s", (files, suite) => { + const result = resolveParameters({ + EVENT_NAME: "pull_request", + PR_HEAD_REPOSITORY: "upstream/setup-vp", + MOCK_CHANGED_FILES: files, + }); + expect(result.status, result.stderr).toBe(0); + expect(result.outputs).toMatchObject({ should_run: "true", setup_vp_ref: headSha, suite }); + expect(result.calls).not.toContain("/permission"); + }); + + it.each(["push", "merge_group", "workflow_dispatch"])("preserves %s defaults", (event) => { + const result = resolveParameters({ EVENT_NAME: event }); + expect(result.status, result.stderr).toBe(0); + expect(result.outputs).toEqual({ + should_run: "true", + setup_vp_ref: baseSha, + suite: "full", + vite_plus_version: "latest", + }); + expect(result.calls).toBe(""); + }); + + it("preserves release tags and manual input overrides", () => { + const release = resolveParameters({ + EVENT_NAME: "push", + EVENT_REF: "refs/tags/v1.19.0", + EVENT_REF_NAME: "v1.19.0", + }); + expect(release.status, release.stderr).toBe(0); + expect(release.outputs.setup_vp_ref).toBe("v1.19.0"); + + const manual = resolveParameters({ + EVENT_NAME: "workflow_dispatch", + MANUAL_SETUP_REF: headSha, + MANUAL_SUITE: "required", + MANUAL_VITE_PLUS_VERSION: "0.3.1", + }); + expect(manual.status, manual.stderr).toBe(0); + expect(manual.outputs).toEqual({ + should_run: "true", + setup_vp_ref: headSha, + suite: "required", + vite_plus_version: "0.3.1", + }); + }); +}); From 946b38508adf1a405f223ae9126ecb720c2d4ea5 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 12 Sep 2026 18:04:07 +0800 Subject: [PATCH 2/3] refactor: simplify fork E2E approval flow and docs --- .github/workflows/gitlab-e2e.yml | 106 +++++++++++++++------------- README.md | 10 +-- src/gitlab/workflow.test.ts | 117 +++++++++++++++---------------- 3 files changed, 115 insertions(+), 118 deletions(-) diff --git a/.github/workflows/gitlab-e2e.yml b/.github/workflows/gitlab-e2e.yml index da08746..55fad07 100644 --- a/.github/workflows/gitlab-e2e.yml +++ b/.github/workflows/gitlab-e2e.yml @@ -72,6 +72,62 @@ jobs: vite_plus_version=latest skip_reason="" + resolve_fork_request() { + should_run=false + skip_reason='Only a successful run-e2e label request for a fork PR can approve a GitLab E2E run.' + local request_pattern='^PR #([1-9][0-9]*): labeled run-e2e at ([0-9a-f]{40})$' + if [ "$REQUEST_EVENT" != "pull_request" ] || [ "$REQUEST_CONCLUSION" != "success" ] || + [ "$REQUEST_PATH" != ".github/workflows/e2e-request.yml" ] || + [ "$REQUEST_HEAD_REPOSITORY" = "$GITHUB_REPOSITORY" ] || + ! [[ "$REQUEST_TITLE" =~ $request_pattern ]]; then + return + fi + + local pr_number="${BASH_REMATCH[1]}" + if [ "${BASH_REMATCH[2]}" != "$REQUEST_HEAD_SHA" ]; then + echo "::error::The request title does not match the workflow run's head SHA." + exit 1 + fi + + # Check the original labeler's access, even on reruns. + # GitHub maps the maintain role to write. + local permission + permission="$(gh api "repos/${GITHUB_REPOSITORY}/collaborators/${REQUEST_ACTOR}/permission" --jq '.permission')" + case "$permission" in + admin | write) ;; + *) + echo "::error::Adding run-e2e requires repository write access to approve a test run." + exit 1 + ;; + esac + + # Compare Git blobs to reject altered request triggers or run names. + local workflow_url="repos/${GITHUB_REPOSITORY}/contents/${REQUEST_PATH}" + local trusted_blob request_blob + trusted_blob="$(gh api "${workflow_url}?ref=${GITHUB_SHA}" --jq '.sha')" + if ! request_blob="$(gh api "${workflow_url}?ref=${REQUEST_HEAD_SHA}" --jq '.sha')" || + [ "$request_blob" != "$trusted_blob" ]; then + echo "::error::The fork must include e2e-request.yml unchanged from main. Update the branch, then remove and re-add run-e2e." + exit 1 + fi + + # Fork runs can have an empty workflow_run.pull_requests array. + # Resolve the PR from the verified title and cross-check its head. + local pull_request + pull_request="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}")" + if ! jq -e --arg sha "$REQUEST_HEAD_SHA" --arg head_repo "$REQUEST_HEAD_REPOSITORY" --arg repo "$GITHUB_REPOSITORY" \ + '.state == "open" and .head.sha == $sha and .head.repo.full_name == $head_repo and + .base.repo.full_name == $repo and .base.ref == "main" and any(.labels[]; .name == "run-e2e")' \ + <<< "$pull_request" > /dev/null; then + skip_reason='This approval is stale or does not match the PR: check its head, base, and run-e2e label. Review the current commit, then remove and re-add run-e2e to test it.' + return + fi + + should_run=true + setup_vp_ref="$REQUEST_HEAD_SHA" + suite=full + } + if [ "$EVENT_NAME" = "pull_request" ]; then if [ "$PR_HEAD_REPOSITORY" != "$GITHUB_REPOSITORY" ]; then should_run=false @@ -88,55 +144,7 @@ jobs: done < <(gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files" --jq '.[].filename') fi elif [ "$EVENT_NAME" = "workflow_run" ]; then - should_run=false - skip_reason='Only a successful run-e2e label request for a fork PR can approve a GitLab E2E run.' - request_pattern='^PR #([1-9][0-9]*): labeled run-e2e at ([0-9a-f]{40})$' - if [ "$REQUEST_EVENT" = "pull_request" ] && [ "$REQUEST_CONCLUSION" = "success" ] && - [ "$REQUEST_PATH" = ".github/workflows/e2e-request.yml" ] && - [ "$REQUEST_HEAD_REPOSITORY" != "$GITHUB_REPOSITORY" ] && - [[ "$REQUEST_TITLE" =~ $request_pattern ]]; then - PR_NUMBER="${BASH_REMATCH[1]}" - if [ "${BASH_REMATCH[2]}" != "$REQUEST_HEAD_SHA" ]; then - echo "::error::The request title does not match the workflow run's head SHA." - exit 1 - fi - - # Use the original request actor, not the person who reruns it. - # GitHub maps the maintain role to write. - permission="$(gh api "repos/${GITHUB_REPOSITORY}/collaborators/${REQUEST_ACTOR}/permission" --jq '.permission')" - case "$permission" in - admin | write) ;; - *) - echo "::error::Adding run-e2e requires repository write access to approve a test run." - exit 1 - ;; - esac - - # A fork could change the request workflow's triggers or title. - # Compare immutable Git blobs before trusting its run-name format. - # Read these files as metadata only; never execute their contents. - workflow_path=".github/workflows/e2e-request.yml" - trusted_blob="$(gh api "repos/${GITHUB_REPOSITORY}/contents/${workflow_path}?ref=${GITHUB_SHA}" --jq '.sha')" - if ! request_blob="$(gh api "repos/${GITHUB_REPOSITORY}/contents/${workflow_path}?ref=${REQUEST_HEAD_SHA}" --jq '.sha')" || - [ "$request_blob" != "$trusted_blob" ]; then - echo "::error::The fork must include e2e-request.yml unchanged from main. Update the branch, then remove and re-add run-e2e." - exit 1 - fi - - # Fork runs can have an empty workflow_run.pull_requests array. - # Resolve the PR from the verified title and cross-check its head. - pull_request="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}")" - if jq -e --arg sha "$REQUEST_HEAD_SHA" --arg head_repo "$REQUEST_HEAD_REPOSITORY" --arg repo "$GITHUB_REPOSITORY" \ - '.state == "open" and .head.sha == $sha and .head.repo.full_name == $head_repo and - .base.repo.full_name == $repo and .base.ref == "main" and any(.labels[]; .name == "run-e2e")' \ - <<< "$pull_request" > /dev/null; then - should_run=true - setup_vp_ref="$REQUEST_HEAD_SHA" - suite=full - else - skip_reason='This approval is stale or does not match the PR: check its head, base, and run-e2e label. Review the current commit, then remove and re-add run-e2e to test it.' - fi - fi + resolve_fork_request elif [ "$EVENT_NAME" = "merge_group" ]; then suite=full elif [ "$EVENT_NAME" = "workflow_dispatch" ]; then diff --git a/README.md b/README.md index 37242bf..ea368e0 100644 --- a/README.md +++ b/README.md @@ -691,15 +691,9 @@ vp install ### GitLab E2E for Fork Pull Requests -Fork pull requests skip the automatic GitLab E2E run because `pull_request` workflows cannot access `GITLAB_TRIGGER_TOKEN`. After reviewing the current commit, a maintainer with repository write access can add the `run-e2e` label to run the full GitLab suite. Create this label in the repository if it does not exist. +Update the fork branch from `main`. After reviewing the commit, a maintainer with write access can add `run-e2e` to run the full GitLab suite. Approve the Actions run if prompted. -The label starts `.github/workflows/e2e-request.yml` through `pull_request`. This small workflow has no secrets and records the label event, PR number, and head SHA in its run name. GitHub may require a maintainer to approve this fork workflow run. Its completion starts `.github/workflows/gitlab-e2e.yml` on `main` through `workflow_run`. - -Both workflows must first be merged into `main`. Update the fork branch from `main` so it includes `e2e-request.yml` unchanged. The handler checks the labeler's write permission and compares the request workflow's Git blob at the requested SHA with the trusted copy. This prevents a fork from changing the events or run name used for approval. It then checks the PR's head SHA, head repository, base branch, open state, and current label through the GitHub API. - -The handler calls the GitLab API without checking out PR code or loading artifacts or caches from the fork. The approval allows the GitLab test project to load and execute the fork's template, bootstrap script, and compiled runtime at the exact PR head SHA from the label event. `workflow_run` has access to secrets, so keep this handler limited to API calls. The GitLab trigger token is available only to the pipeline trigger step. - -Each approval applies to that commit only. New pushes do not trigger another GitLab pipeline, even if the label remains. Review the new commit, then remove and re-add `run-e2e`. A queued run or rerun skips if the PR head changed, the PR closed, or the label was removed. The workflow summary contains the tested SHA, suite, pipeline link, and result. +For new commits, review the changes and remove and re-add `run-e2e`. Results and the GitLab pipeline link appear in the GitLab E2E workflow summary. ### Releasing diff --git a/src/gitlab/workflow.test.ts b/src/gitlab/workflow.test.ts index 8144944..40b2134 100644 --- a/src/gitlab/workflow.test.ts +++ b/src/gitlab/workflow.test.ts @@ -1,4 +1,4 @@ -import { spawnSync } from "node:child_process"; +import { spawnSync, type SpawnSyncReturns } from "node:child_process"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -29,42 +29,41 @@ const approvedPr = { }; const tempDirs: string[] = []; +type ParameterResult = SpawnSyncReturns & { + outputs: Record; + summary: string; + calls: string; +}; + afterEach(() => { for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); } }); -function resolveParameters(overrides: Record = {}) { +function resolveParameters(overrides: Record = {}): ParameterResult { const dir = mkdtempSync(join(tmpdir(), "setup-vp-gitlab-workflow-")); tempDirs.push(dir); const output = join(dir, "output"); const summary = join(dir, "summary"); const calls = join(dir, "calls"); - for (const file of [output, summary, calls]) writeFileSync(file, ""); + for (const file of [output, summary, calls]) { + writeFileSync(file, ""); + } writeFileSync( join(dir, "gh"), `#!/usr/bin/env bash set -euo pipefail printf '%s\\n' "$*" >> "$MOCK_GH_CALLS" +if [ -n "$MOCK_GH_FAILURE" ] && [[ "$*" == *"$MOCK_GH_FAILURE"* ]]; then + exit 1 +fi case "$*" in - */permission*) - [ "$MOCK_GH_FAILURE" != permission ] - printf '%s\\n' "$MOCK_PERMISSION" - ;; - *"contents/.github/workflows/e2e-request.yml?ref=$GITHUB_SHA"*) - [ "$MOCK_GH_FAILURE" != trusted_workflow ] - printf '%s\\n' trusted-blob - ;; - *"contents/.github/workflows/e2e-request.yml?ref=$REQUEST_HEAD_SHA"*) - [ "$MOCK_GH_FAILURE" != request_workflow ] - printf '%s\\n' "$MOCK_REQUEST_BLOB" - ;; + */permission*) printf '%s\\n' "$MOCK_PERMISSION" ;; + *"contents/.github/workflows/e2e-request.yml?ref=$GITHUB_SHA"*) printf '%s\\n' trusted-blob ;; + *"contents/.github/workflows/e2e-request.yml?ref=$REQUEST_HEAD_SHA"*) printf '%s\\n' "$MOCK_REQUEST_BLOB" ;; */files*) printf '%s\\n' "$MOCK_CHANGED_FILES" ;; - "api repos/upstream/setup-vp/pulls/123") - [ "$MOCK_GH_FAILURE" != pull ] - printf '%s\\n' "$MOCK_PR" - ;; + "api repos/upstream/setup-vp/pulls/123") printf '%s\\n' "$MOCK_PR" ;; *) exit 90 ;; esac `, @@ -138,7 +137,7 @@ describe("GitLab E2E workflow", () => { expect(steps.every((step) => !step.uses && !step.run.includes("${{"))).toBe(true); expect(parameters.env?.REQUEST_HEAD_SHA).toBe("${{ github.event.workflow_run.head_sha }}"); expect(parameters.env?.REQUEST_ACTOR).toBe("${{ github.event.workflow_run.actor.login }}"); - for (const step of steps.filter((step) => step !== parameters)) { + for (const step of steps.slice(1)) { expect(step.if).toBe("steps.parameters.outputs.should_run == 'true'"); } }); @@ -167,7 +166,7 @@ describe("GitLab E2E workflow", () => { expect(result.calls).not.toContain("/pulls/"); }); - it.each(["permission", "pull", "trusted_workflow", "request_workflow"])( + it.each(["/permission", "/pulls/", `?ref=${baseSha}`, `?ref=${headSha}`])( "fails closed when the %s API request fails", (endpoint) => { const result = resolveParameters({ MOCK_GH_FAILURE: endpoint }); @@ -177,20 +176,17 @@ describe("GitLab E2E workflow", () => { ); it.each([ - ["new commit", { ...approvedPr, head: { ...approvedPr.head, sha: "c".repeat(40) } }], - [ - "different fork", - { ...approvedPr, head: { sha: headSha, repo: { full_name: "other/setup-vp" } } }, - ], - ["base change", { ...approvedPr, base: { ...approvedPr.base, ref: "release" } }], + ["new commit", { head: { ...approvedPr.head, sha: "c".repeat(40) } }], + ["different fork", { head: { ...approvedPr.head, repo: { full_name: "other/setup-vp" } } }], + ["base change", { base: { ...approvedPr.base, ref: "release" } }], [ "different repository", - { ...approvedPr, base: { ref: "main", repo: { full_name: "other/setup-vp" } } }, + { base: { ...approvedPr.base, repo: { full_name: "other/setup-vp" } } }, ], - ["closed PR", { ...approvedPr, state: "closed" }], - ["removed label", { ...approvedPr, labels: [{ name: "other-label" }] }], - ])("skips a stale approval after a %s", (_reason, pr) => { - const result = resolveParameters({ MOCK_PR: JSON.stringify(pr) }); + ["closed PR", { state: "closed" }], + ["removed label", { labels: [{ name: "other-label" }] }], + ])("skips a stale approval after a %s", (_reason, changes) => { + const result = resolveParameters({ MOCK_PR: JSON.stringify({ ...approvedPr, ...changes }) }); expect(result.status, result.stderr).toBe(0); expect(result.outputs.should_run).toBe("false"); expect(result.summary).toContain("This approval is stale"); @@ -248,39 +244,38 @@ describe("GitLab E2E workflow", () => { expect(result.calls).not.toContain("/permission"); }); - it.each(["push", "merge_group", "workflow_dispatch"])("preserves %s defaults", (event) => { - const result = resolveParameters({ EVENT_NAME: event }); + it.each([ + ["push", { EVENT_NAME: "push" }, baseSha, "full", "latest"], + ["merge queue", { EVENT_NAME: "merge_group" }, baseSha, "full", "latest"], + ["manual defaults", { EVENT_NAME: "workflow_dispatch" }, baseSha, "full", "latest"], + [ + "release tag", + { EVENT_NAME: "push", EVENT_REF: "refs/tags/v1.19.0", EVENT_REF_NAME: "v1.19.0" }, + "v1.19.0", + "full", + "latest", + ], + [ + "manual overrides", + { + EVENT_NAME: "workflow_dispatch", + MANUAL_SETUP_REF: headSha, + MANUAL_SUITE: "required", + MANUAL_VITE_PLUS_VERSION: "0.3.1", + }, + headSha, + "required", + "0.3.1", + ], + ])("preserves %s parameters", (_name, env, ref, suite, version) => { + const result = resolveParameters(env); expect(result.status, result.stderr).toBe(0); expect(result.outputs).toEqual({ should_run: "true", - setup_vp_ref: baseSha, - suite: "full", - vite_plus_version: "latest", + setup_vp_ref: ref, + suite, + vite_plus_version: version, }); expect(result.calls).toBe(""); }); - - it("preserves release tags and manual input overrides", () => { - const release = resolveParameters({ - EVENT_NAME: "push", - EVENT_REF: "refs/tags/v1.19.0", - EVENT_REF_NAME: "v1.19.0", - }); - expect(release.status, release.stderr).toBe(0); - expect(release.outputs.setup_vp_ref).toBe("v1.19.0"); - - const manual = resolveParameters({ - EVENT_NAME: "workflow_dispatch", - MANUAL_SETUP_REF: headSha, - MANUAL_SUITE: "required", - MANUAL_VITE_PLUS_VERSION: "0.3.1", - }); - expect(manual.status, manual.stderr).toBe(0); - expect(manual.outputs).toEqual({ - should_run: "true", - setup_vp_ref: headSha, - suite: "required", - vite_plus_version: "0.3.1", - }); - }); }); From 08af7fc9b4fac73601ae06d8e1ecf47606daef51 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 12 Sep 2026 19:32:35 +0800 Subject: [PATCH 3/3] docs: shorten fork E2E instructions --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ea368e0..0658641 100644 --- a/README.md +++ b/README.md @@ -691,7 +691,7 @@ vp install ### GitLab E2E for Fork Pull Requests -Update the fork branch from `main`. After reviewing the commit, a maintainer with write access can add `run-e2e` to run the full GitLab suite. Approve the Actions run if prompted. +After reviewing the commit, a maintainer with write access can add `run-e2e` to run the full GitLab suite. Approve the Actions run if prompted. For new commits, review the changes and remove and re-add `run-e2e`. Results and the GitLab pipeline link appear in the GitLab E2E workflow summary.