Repository navigation
ci: guard main so a PR into it brings nothing not already on v2/main #5120
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+493
−6
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
a8a13d3
ci: guard main so a PR into it brings nothing not already on v2/main
cliffhall 9422c3f
test: pin main-source.yml's safety properties; name the guard as a ga…
cliffhall c9c6e0e
docs(release): the admin escape hatch for a release that cannot be a …
cliffhall 8002268
docs(release): run the escape hatch's disable, merge and re-enable un…
cliffhall File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| # A pull request into `main` brings nothing that is not already on `v2/main` | ||
| # (#5119). `main` only ever receives a milestone's merge PR, a pure merge of | ||
| # `v2/main`; GitHub rulesets cannot restrict a PR's source branch, so this | ||
| # check, required by the `main` ruleset, is what does. The rule and why it is | ||
| # on the head commit rather than the branch name are in | ||
| # `scripts/verify-main-source.mjs`. | ||
| # | ||
| # On `pull_request_target`, unlike dco.yml: it runs `main`'s copy of this | ||
| # workflow and of the script, so a PR cannot edit the guard into passing. That | ||
| # is safe here because nothing from the PR runs: the checkout is `main`, and | ||
| # the PR's head is only fetched as git objects for `git merge-base` to read. | ||
| # Never add a step that checks out, installs or runs the PR's code. | ||
| # `edited` is there so retargeting a PR onto `main` runs the check. | ||
| on: | ||
| pull_request_target: | ||
| types: [opened, synchronize, reopened, edited] | ||
|
Copilot marked this conversation as resolved.
|
||
| branches: [main] | ||
|
|
||
| # Read-only, and handed no secret, so this job holds no credential and stays | ||
| # outside the SHA-pin rule (`verify:action-pins`). | ||
| permissions: | ||
| contents: read | ||
|
|
||
| concurrency: | ||
| group: ${{ github.workflow }}-${{ github.event.pull_request.number }} | ||
| cancel-in-progress: true | ||
|
|
||
| jobs: | ||
| main-source: | ||
| timeout-minutes: 5 | ||
| name: Source is v2/main | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| # The base branch (`main`), which `pull_request_target` checks out by | ||
| # default; never the PR's ref. | ||
| - uses: actions/checkout@v6 | ||
| with: | ||
| persist-credentials: false | ||
|
|
||
| # The PR's head and `v2/main`, as objects only. A fork PR's head is | ||
| # reachable here through `refs/pull/<n>/head`. Full history, so | ||
| # `git merge-base` can walk both. | ||
| - name: Fetch the PR head and v2/main | ||
| env: | ||
| PR_NUMBER: ${{ github.event.pull_request.number }} | ||
| run: | | ||
| git fetch --no-tags --unshallow origin \ | ||
| "+refs/heads/v2/main:refs/remotes/origin/v2/main" \ | ||
| "+refs/pull/${PR_NUMBER}/head:refs/remotes/pr/head" | ||
| - uses: actions/setup-node@v7 | ||
| with: | ||
| node-version: 22 | ||
|
|
||
| # Node built-ins and git only, so no `npm ci`. | ||
| - name: Check the head is already on v2/main | ||
| env: | ||
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | ||
| run: node scripts/verify-main-source.mjs --head "$HEAD_SHA" | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,142 @@ | ||
| #!/usr/bin/env node | ||
| // Guard: a pull request into `main` brings nothing that is not already on | ||
| // `v2/main` (#5119). | ||
| // | ||
| // `main` is the release line. The one PR that may target it is a milestone's | ||
| // merge PR, a pure merge of `v2/main` (AGENTS.md, "Contributing"). GitHub | ||
| // rulesets can require a PR and its approvals, but cannot say where a PR comes | ||
| // from, so before this guard a PR from any branch could merge into `main`. | ||
| // | ||
| // The rule is on the COMMIT, not the branch name: the PR's head must be an | ||
| // ancestor of the source (`origin/v2/main`), or the source itself. The | ||
| // `release` skill opens the merge PR from `v2/chore/<N>-release-<milestone>`, | ||
| // a branch pushed from `origin/v2/main`, so a check on the head's name would | ||
| // refuse the one PR the rule exists to allow. An ancestor rather than only an | ||
| // equal commit, because `v2/main` keeps moving while the merge PR is open, and | ||
| // the PR is still a pure merge of what it was opened with. | ||
| // | ||
| // CI runs it from `main-source.yml` on `pull_request_target`, which runs | ||
| // `main`'s copy of the workflow and of this script, so a PR cannot edit the | ||
| // guard into passing. The PR's head is only fetched as git objects; none of | ||
| // its code runs. It is not a `local:gate` stage: every local push targets a | ||
| // v2 branch, where the rule does not apply. | ||
|
|
||
| import { spawnSync } from "node:child_process"; | ||
| import path from "node:path"; | ||
| import { fileURLToPath, pathToFileURL } from "node:url"; | ||
|
|
||
| /** The branch every commit on `main` must already be on. */ | ||
| export const DEFAULT_SOURCE = "origin/v2/main"; | ||
|
|
||
| /** How many of the offending commits the report lists. */ | ||
| export const MAX_LISTED = 20; | ||
|
|
||
| /** | ||
| * Pure: `--head <rev>` (required) and `--source <rev>`. | ||
| * | ||
| * @param {string[]} argv | ||
| */ | ||
| export function parseArgs(argv) { | ||
| const opts = { source: DEFAULT_SOURCE, head: "" }; | ||
| for (let i = 0; i < argv.length; i++) { | ||
| const flag = argv[i]; | ||
| if (flag !== "--source" && flag !== "--head") | ||
| throw new Error(`unknown argument: ${flag}`); | ||
| const value = argv[++i]; | ||
| if (!value) throw new Error(`${flag} needs a revision`); | ||
| opts[flag.slice(2)] = value; | ||
| } | ||
| if (!opts.head) throw new Error("--head is required"); | ||
| return opts; | ||
| } | ||
|
|
||
| /** | ||
| * Pure: the report for a head that carries commits the source does not. | ||
| * | ||
| * @param {{ source: string, head: string, commits: string[] }} args | ||
| * `commits` is `git log --oneline <source>..<head>`, one per entry | ||
| */ | ||
| export function formatReport({ source, head, commits }) { | ||
| const n = commits.length; | ||
| const listed = commits.slice(0, MAX_LISTED).map((c) => ` ${c}`); | ||
| if (n > MAX_LISTED) listed.push(` … and ${n - MAX_LISTED} more`); | ||
| return [ | ||
| `verify:main-source: ${head} carries ${n} commit${n === 1 ? "" : "s"} that ${source} does not:`, | ||
| ...listed, | ||
| "", | ||
| "A pull request into `main` must be a pure merge of `v2/main`: its head", | ||
| "is a commit already on `v2/main`. Land the change on `v2/main` through", | ||
| "its own PR, and it reaches `main` with the next milestone merge (the", | ||
| "`release` skill).", | ||
| ].join("\n"); | ||
| } | ||
|
|
||
| function git(args, cwd) { | ||
| const r = spawnSync("git", args, { cwd, encoding: "utf8" }); | ||
| if (r.error) throw r.error; | ||
| return r; | ||
| } | ||
|
|
||
| /** | ||
| * Check that `head` is `source` or an ancestor of it, in the repository at | ||
| * `cwd`. | ||
| * | ||
| * @returns {{ code: number, output: string }} | ||
| */ | ||
| export function verifyMainSource({ source, head, cwd }) { | ||
| for (const rev of [source, head]) { | ||
| if ( | ||
| git(["rev-parse", "--verify", "--quiet", `${rev}^{commit}`], cwd).status | ||
| ) | ||
| return { | ||
| code: 1, | ||
| output: | ||
| `verify:main-source: cannot resolve ${rev}.` + | ||
| (rev === DEFAULT_SOURCE | ||
| ? " Fetch it (`git fetch origin v2/main`), or name another source with --source." | ||
| : ""), | ||
| }; | ||
| } | ||
| const anc = git(["merge-base", "--is-ancestor", head, source], cwd); | ||
| if (anc.status === 0) | ||
| return { | ||
| code: 0, | ||
| output: `verify:main-source: ${head} is on ${source}; nothing else comes with it.`, | ||
| }; | ||
| if (anc.status !== 1) | ||
| return { | ||
| code: 1, | ||
| output: `verify:main-source: git merge-base failed:\n${anc.stderr}`, | ||
| }; | ||
| const log = git( | ||
| ["log", "--oneline", "--no-decorate", `${source}..${head}`], | ||
| cwd, | ||
| ); | ||
| if (log.status) | ||
| return { | ||
| code: 1, | ||
| output: `verify:main-source: git log failed:\n${log.stderr}`, | ||
| }; | ||
| const commits = log.stdout.split("\n").filter((l) => l.trim() !== ""); | ||
| return { code: 1, output: formatReport({ source, head, commits }) }; | ||
| } | ||
|
|
||
| if ( | ||
| process.argv[1] && | ||
| import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href | ||
| ) { | ||
| let opts; | ||
| try { | ||
| opts = parseArgs(process.argv.slice(2)); | ||
| } catch (e) { | ||
| console.error(`verify:main-source: ${e.message}`); | ||
| process.exit(2); | ||
| } | ||
| const repoRoot = path.resolve( | ||
| path.dirname(fileURLToPath(import.meta.url)), | ||
| "..", | ||
| ); | ||
| const { code, output } = verifyMainSource({ ...opts, cwd: repoRoot }); | ||
| (code === 0 ? console.log : console.error)(output); | ||
| process.exit(code); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.