Repository navigation
ci: pin the breaking-change signoff to the release, not the commit hash #434
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
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
29b4bf3
feat(ci): pin the breaking-change signoff to the release, not the com…
Kyleasmth fba8c9e
chore: add an empty changeset for the CI-only signoff change
Kyleasmth d7c7e9b
fix(ci): emit the signoff token, and cover it with behavioural tests
Kyleasmth f14b1d2
refactor(ci): keep the one-line destructure and still split at the fi…
Kyleasmth a5ee7b9
Merge remote-tracking branch 'origin/main' into YPE-6068/signoff-token
Kyleasmth 4793442
Merge remote-tracking branch 'origin/main' into YPE-6068/signoff-token
Kyleasmth 62e73f4
ci: key the signoff token on this PR's changesets and config, and run…
Kyleasmth 1fc2a7f
Merge branch 'main' into YPE-6068/signoff-token
Kyleasmth 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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| --- | ||
| --- | ||
|
|
||
| Pin the breaking-change signoff to the release's changesets and version rather than the PR head commit, so an ordinary push no longer voids a valid signoff. CI-only, no package changes. |
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,90 @@ | ||
| // Drives signoffToken against real git trees. Each case mutates a scratch repo and asserts | ||
| // whether the token moved, which is the property the gate relies on. | ||
| import { execFileSync } from 'node:child_process' | ||
| import { mkdtempSync, mkdirSync, writeFileSync, rmSync, renameSync } from 'node:fs' | ||
| import { tmpdir } from 'node:os' | ||
| import { join } from 'node:path' | ||
| import { signoffToken } from '../../scripts/signoff-token.mjs' | ||
|
|
||
| const repo = mkdtempSync(join(tmpdir(), 'signoff-token-')) | ||
| const git = (...a) => execFileSync('git', a, { cwd: repo, encoding: 'utf8' }).trim() | ||
| const commit = (msg) => { git('add', '-A'); git('-c', 'commit.gpgsign=false', 'commit', '-q', '-m', msg); return git('rev-parse', 'HEAD') } | ||
| let base | ||
| const tok = (head, v = '2.0.0') => signoffToken({ repoRoot: repo, base, head, nextVersion: v }) | ||
|
|
||
| git('init', '-q', '-b', 'main'); git('config', 'user.email', 't@t'); git('config', 'user.name', 't') | ||
| mkdirSync(join(repo, '.changeset')) | ||
| writeFileSync(join(repo, '.changeset', 'a.md'), "---\n'p': major\n---\n\nbreak\n") | ||
| writeFileSync(join(repo, '.changeset', 'README.md'), 'docs\n') | ||
| writeFileSync(join(repo, 'src.txt'), 'code\n') | ||
| writeFileSync(join(repo, '.changeset', 'config.json'), '{}\n') | ||
| base = commit('base'); const baseTok = tok(base) | ||
|
|
||
| const results = [] | ||
| const check = (name, actual, expectSame) => | ||
| results.push({ name, ok: (actual === baseTok) === expectSame, actual, expectSame }) | ||
|
|
||
| writeFileSync(join(repo, 'src.txt'), 'code changed\n') | ||
| check('ordinary code push keeps the token', tok(commit('code')), true) | ||
|
|
||
| writeFileSync(join(repo, '.changeset', 'README.md'), 'docs edited\n') | ||
| check('editing .changeset/README.md keeps the token', tok(commit('readme')), true) | ||
|
|
||
| check('a version change replaces the token', tok(git('rev-parse', 'HEAD'), '2.0.1'), false) | ||
|
|
||
| const afterDocs = git('rev-parse', 'HEAD') | ||
| writeFileSync(join(repo, '.changeset', 'b.md'), "---\n'p': patch\n---\n\nfix\n") | ||
| check('adding a changeset replaces the token', tok(commit('add changeset')), false) | ||
|
|
||
| git('reset', '-q', '--hard', afterDocs) | ||
| writeFileSync(join(repo, '.changeset', 'a.md'), "---\n'p': major\n---\n\nbreak differently\n") | ||
| check('editing a changeset replaces the token', tok(commit('edit changeset')), false) | ||
|
|
||
| git('reset', '-q', '--hard', afterDocs) | ||
| rmSync(join(repo, '.changeset', 'a.md')) | ||
| check('deleting a changeset replaces the token', tok(commit('delete changeset')), false) | ||
|
|
||
| git('reset', '-q', '--hard', afterDocs) | ||
| renameSync(join(repo, '.changeset', 'a.md'), join(repo, '.changeset', 'renamed.md')) | ||
| check('renaming a changeset replaces the token', tok(commit('rename changeset')), false) | ||
|
|
||
| // A tab in the path is why the blob line is split at the first tab only. Splitting on every | ||
| // tab drops the file from the digest, and a dropped file cannot be seen to change, so the | ||
| // discriminating assertion is that EDITING it moves the token. | ||
| git('reset', '-q', '--hard', afterDocs) | ||
| renameSync(join(repo, '.changeset', 'a.md'), join(repo, '.changeset', 'with\ttab.md')) | ||
| const tabBefore = tok(commit('tab-named changeset')) | ||
| writeFileSync(join(repo, '.changeset', 'with\ttab.md'), "---\n'p': major\n---\n\nbreak, reworded\n") | ||
| const tabAfter = tok(commit('edit the tab-named changeset')) | ||
| results.push({ | ||
| name: 'editing a tab-named changeset replaces the token', | ||
| ok: tabBefore !== tabAfter, | ||
| }) | ||
|
|
||
| git('reset', '-q', '--hard', afterDocs) | ||
| writeFileSync(join(repo, '.changeset', 'config.json'), '{"fixed": []}\n') | ||
| check('editing .changeset/config.json replaces the token', tok(commit('edit config')), false) | ||
|
|
||
| // The branch has to be current with main before it merges, and every other PR brings its own | ||
| // changeset. Merging main in must leave the signoff standing, so only this PR's changesets count. | ||
| git('reset', '-q', '--hard', afterDocs) | ||
| git('checkout', '-q', '-b', 'pr') | ||
| writeFileSync(join(repo, '.changeset', 'mine.md'), "---\n'p': major\n---\n\nmine\n") | ||
| const prTok = tok(commit('pr changeset')) | ||
| git('checkout', '-q', 'main') | ||
| git('reset', '-q', '--hard', afterDocs) | ||
| writeFileSync(join(repo, '.changeset', 'theirs.md'), "---\n'p': patch\n---\n\ntheirs\n") | ||
| commit('another PR lands on main') | ||
| git('checkout', '-q', 'pr') | ||
| git('-c', 'commit.gpgsign=false', 'merge', '-q', '--no-edit', 'main') | ||
| const savedBase = base | ||
| base = 'main' | ||
| results.push({ | ||
| name: 'merging a changeset in from main keeps the token', | ||
| ok: tok(git('rev-parse', 'HEAD')) === prTok, | ||
| }) | ||
| base = savedBase | ||
|
|
||
| rmSync(repo, { recursive: true, force: true }) | ||
| for (const r of results) console.log(`${r.ok ? 'ok' : 'FAIL'}\t${r.name}`) | ||
| process.exit(results.every((r) => r.ok) ? 0 : 1) |
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
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,66 @@ | ||
| #!/usr/bin/env node | ||
| /** | ||
| * The identifier a breaking-change signoff names. | ||
| * | ||
| * Pinning a signoff to the head commit meant every push voided it, so a lint fix or a review | ||
| * tweak cost a fresh signoff while changing nothing about the release. That trains people to | ||
| * rubber-stamp the gate, and it is stricter than this repo's own review policy, which keeps | ||
| * approvals across pushes (`dismiss_stale_reviews_on_push: false`). | ||
| * | ||
| * Keyed on the changesets this PR adds, edits, renames or removes since `base` (the merge-base), plus | ||
| * `.changeset/config.json` at head and the resulting version. Keying on every changeset at head | ||
| * voided the signoff whenever main was merged in, because main brings other PRs' changesets. | ||
| * Every changeset this PR touches counts, not only the ones declaring major: any changeset edit | ||
| * can move the release, and erring toward asking again is the safe direction. Blob ids come from | ||
| * git, so the hash changes if and only if the content does. Renaming a changeset re-triggers, | ||
| * which is correct: Changesets reads it by path. | ||
| * | ||
| * `config.json` is read from the head commit, not the working tree: the preview job restores | ||
| * main's copy before running, but after the merge this PR's copy is the one that governs. | ||
| * | ||
| * 16 hex characters (64 bits). The signoff names this value, so a collaborator who wanted a | ||
| * different release to inherit an existing signoff would have to grind a second preimage against | ||
| * it; 64 bits puts that out of reach, where a short prefix would not. | ||
| * | ||
| * Lives in its own module so the gate's suite can exercise it against real git trees. Testing it | ||
| * through `preview-release.mjs` would mean standing up Changesets and a workspace first, which is | ||
| * how it previously shipped covered only by assertions about the workflow's text. | ||
| */ | ||
| import { execFileSync } from 'node:child_process'; | ||
| import { createHash } from 'node:crypto'; | ||
|
|
||
| /** Mirrors @changesets/read: a `.md` under `.changeset/` that is not README. */ | ||
| export const isChangesetPath = (f) => | ||
| /^\.changeset\/[^]+\.md$/.test(f) && !/README\.md$/.test(f); | ||
|
|
||
| export function signoffToken({ repoRoot, base, head, nextVersion }) { | ||
| const git = (...args) => | ||
| execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); | ||
| // `--raw -z`: `:<old mode> <new mode> <old blob> <new blob> <status>`, then one path, or two | ||
| // for a rename. `-z` keeps unusual paths unquoted, so they still pass the eligibility test. | ||
| const fields = git('diff', '--raw', '-z', '-M', '--no-abbrev', base, head, '--', '.changeset') | ||
| .split('\0') | ||
| .filter((f) => f !== ''); | ||
| const changes = []; | ||
| for (let i = 0; i < fields.length; ) { | ||
| const [, newMode, , newBlob, status] = fields[i].split(' '); | ||
| const pathCount = /^[RC]/.test(status) ? 2 : 1; | ||
| const paths = fields.slice(i + 1, i + 1 + pathCount); | ||
| i += 1 + pathCount; | ||
| if (!paths.some(isChangesetPath)) continue; | ||
| changes.push([status[0], newMode, newBlob, ...paths]); | ||
| } | ||
| changes.sort((a, b) => (a.join('\0') < b.join('\0') ? -1 : 1)); | ||
|
|
||
| let config = null; | ||
| try { | ||
| config = git('rev-parse', '--verify', '--quiet', `${head}:.changeset/config.json`).trim(); | ||
| } catch { | ||
| // Absent at head. Null still hashes distinctly from any blob id. | ||
| } | ||
|
|
||
| return createHash('sha256') | ||
| .update(JSON.stringify({ nextVersion: nextVersion ?? null, config, changes })) | ||
| .digest('hex') | ||
| .slice(0, 16); | ||
| } |
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.