diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f9b1fb5..d9484dd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -184,9 +184,17 @@ jobs: gh release upload "$GITHUB_REF_NAME" "${assets[@]}" --clobber fi else + # Start the generated notes at the previous tag of the same kind; + # GitHub's default is the latest release of any kind, which mixes + # lockstep and adapter pull requests. Empty for a first release. + notes=(--generate-notes) + start_tag="$(bun scripts/release-notes-start-tag.ts "$GITHUB_REF_NAME")" + if [ -n "$start_tag" ]; then + notes+=(--notes-start-tag "$start_tag") + fi if [ "$LOCKSTEP" = "true" ]; then - gh release create "$GITHUB_REF_NAME" --draft --verify-tag --generate-notes + gh release create "$GITHUB_REF_NAME" --draft --verify-tag "${notes[@]}" else - gh release create "$GITHUB_REF_NAME" --verify-tag --generate-notes "${assets[@]}" + gh release create "$GITHUB_REF_NAME" --verify-tag "${notes[@]}" "${assets[@]}" fi fi diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 7a49339..73563a6 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -52,6 +52,10 @@ temporary `NPM_TOKEN` is removed from the protected `npm` environment. exactly matches the local tarball. - A lockstep GitHub release draft is created only after every npm package succeeds. Adapter releases remain immediately published. +- Generated release notes start at the previous tag of the same kind + (`scripts/release-notes-start-tag.ts`): `v0.19.0` from the highest earlier + stable `v*` tag and `spec-kit-v0.1.6` from the highest earlier `spec-kit-v*` + tag, never from the latest release of the other kind or the moving `v0`. - A lockstep release cannot be published to Marketplace unless its tree contains the root entry point. - Publishing the stable lockstep draft triggers a finalization job that moves the @@ -429,6 +433,8 @@ bootstrap described below. 8. Confirm the workflow published all packages and created a **draft** lockstep GitHub release. It must not move the Action's `v0` tag or start `Publish container` yet. + Its generated notes should list the pull requests since the previous `v*` + release, including ones that shipped in an adapter release in between. 9. Open that draft, select **Publish this Action to the GitHub Marketplace**, choose **Code review** as the primary category and **Code quality** as the secondary category, then publish the release with 2FA. Do not perform this diff --git a/scripts/action-tag-recovery-contract.test.ts b/scripts/action-tag-recovery-contract.test.ts index 7780176..f0e7699 100644 --- a/scripts/action-tag-recovery-contract.test.ts +++ b/scripts/action-tag-recovery-contract.test.ts @@ -113,11 +113,21 @@ describe('major Action tag recovery contract', () => { test('publishes a lockstep draft before Marketplace finalization', () => { expect(RELEASE_WORKFLOW).toContain( - 'gh release create "$GITHUB_REF_NAME" --draft --verify-tag --generate-notes', + 'gh release create "$GITHUB_REF_NAME" --draft --verify-tag "${notes[@]}"', ); expect(RELEASE_WORKFLOW).toContain( - 'gh release create "$GITHUB_REF_NAME" --verify-tag --generate-notes "${assets[@]}"', + 'gh release create "$GITHUB_REF_NAME" --verify-tag "${notes[@]}" "${assets[@]}"', ); + // Generated notes start at the previous tag of the same kind, never at the + // latest release of either kind (release-notes-start-tag.test.ts). The + // selection reads `git tag --list`, so the checkout must carry every tag. + expect(RELEASE_WORKFLOW).toContain('notes=(--generate-notes)'); + expect(RELEASE_WORKFLOW).toContain( + 'start_tag="$(bun scripts/release-notes-start-tag.ts "$GITHUB_REF_NAME")"', + ); + expect(RELEASE_WORKFLOW).toContain('notes+=(--notes-start-tag "$start_tag")'); + expect(RELEASE_WORKFLOW).toContain('fetch-depth: 0'); + expect(RELEASE_WORKFLOW).not.toContain('--verify-tag --generate-notes'); expect(RELEASE_WORKFLOW).not.toContain('name: Update the major Action tag'); expect(CONTAINER_WORKFLOW).toContain('release:\n types: [published]'); expect(CONTAINER_WORKFLOW).not.toContain('workflow_run:'); diff --git a/scripts/release-notes-start-tag.test.ts b/scripts/release-notes-start-tag.test.ts new file mode 100644 index 0000000..cc15a94 --- /dev/null +++ b/scripts/release-notes-start-tag.test.ts @@ -0,0 +1,89 @@ +/** + * Start-tag selection for generated release notes. + * + * Measured failure this guards: `spec-kit-v0.1.5` was created with + * `--generate-notes` and no start tag, so its notes listed every pull request + * since `v0.18.0` (#274–#282), and `v0.19.0` would have started at + * `spec-kit-v0.1.5` and omitted them from both lockstep releases. + * + * Observed failing (ADR-0016), each plant reverted after the run: + * + * - Dropping the `parsed.kind !== target.kind` filter failed four tests: + * lockstep v0.19.0 received `spec-kit-v0.18.9`, adapter spec-kit-v0.2.0 + * received `v0.1.9`, the noise case received `spec-kit-v0.1.5`, and + * spec-kit-v0.1.4 received `v0.1.0`. (A first draft without the in-between + * versions passed the two named tests under this plant, because cross-kind + * SemVer happened to agree; the in-between cases were added for that.) + * - Comparing tags as strings (`tag > best.tag`) instead of by SemVer failed + * "lockstep picks v0.18.0" and "orders by SemVer" (received `v0.9.0`). + * - Replacing the `--notes-start-tag` wiring in release.yml with the old + * `--generate-notes`-only command lines made the workflow contract test in + * action-tag-recovery-contract.test.ts fail. + */ + +import { describe, expect, test } from 'bun:test'; +import { parseReleaseTag, previousTagOfSameKind } from './release-notes-start-tag'; + +const TAGS = [ + 'v0', + 'v0.1.0', + 'v0.9.0', + 'v0.10.0', + 'v0.17.0', + 'v0.18.0', + 'v0.19.0-rc.1', + 'spec-kit-v0.1.0', + 'spec-kit-v0.1.4', + 'spec-kit-v0.1.5', + 'action-recovery-block/0123456789abcdef0123456789abcdef01234567', +]; + +describe('previousTagOfSameKind', () => { + test('lockstep picks v0.18.0 for v0.19.0 even when spec-kit-v0.1.5 is newer', () => { + expect(previousTagOfSameKind('v0.19.0', TAGS)).toBe('v0.18.0'); + // An adapter version between them must still not win. + expect(previousTagOfSameKind('v0.19.0', ['v0.18.0', 'spec-kit-v0.18.9'])).toBe('v0.18.0'); + }); + + test('never picks the moving v0 tag, a pre-release, or a recovery marker', () => { + const noise = ['v0', 'v1', 'v0.19.0-rc.1', 'action-recovery-block/abc', 'spec-kit-v0.1.5']; + expect(previousTagOfSameKind('v0.19.0', noise)).toBeUndefined(); + expect(previousTagOfSameKind('v0.1.0', TAGS)).toBeUndefined(); + }); + + test('adapter picks the previous adapter tag', () => { + expect(previousTagOfSameKind('spec-kit-v0.1.6', TAGS)).toBe('spec-kit-v0.1.5'); + // A lockstep version between them must still not win. + expect(previousTagOfSameKind('spec-kit-v0.2.0', ['spec-kit-v0.1.5', 'v0.1.9'])).toBe('spec-kit-v0.1.5'); + }); + + test('first release of a kind returns none', () => { + expect(previousTagOfSameKind('spec-kit-v0.1.0', TAGS)).toBeUndefined(); + expect(previousTagOfSameKind('other-adapter-v0.1.0', TAGS)).toBeUndefined(); + expect(previousTagOfSameKind('v0.1.0', [])).toBeUndefined(); + }); + + test('orders by SemVer, not by string', () => { + expect(previousTagOfSameKind('v0.11.0', ['v0.9.0', 'v0.10.0', 'v0.2.0'])).toBe('v0.10.0'); + expect(previousTagOfSameKind('v1.0.0', ['v0.10.0', 'v0.9.0'])).toBe('v0.10.0'); + }); + + test('excludes the tag being released and any later tag', () => { + expect(previousTagOfSameKind('v0.10.0', TAGS)).toBe('v0.9.0'); + expect(previousTagOfSameKind('spec-kit-v0.1.4', TAGS)).toBe('spec-kit-v0.1.0'); + }); + + test('refuses a tag that is not a stable release tag', () => { + expect(() => previousTagOfSameKind('v0', TAGS)).toThrow(); + expect(() => previousTagOfSameKind('v0.19.0-rc.1', TAGS)).toThrow(); + expect(() => previousTagOfSameKind('action-recovery-block/abc', TAGS)).toThrow(); + }); +}); + +describe('parseReleaseTag', () => { + test('separates the adapter slug from the version', () => { + expect(parseReleaseTag('spec-kit-v0.1.5')).toEqual({ kind: 'spec-kit', major: 0, minor: 1, patch: 5 }); + expect(parseReleaseTag('v0.18.0')).toEqual({ kind: '', major: 0, minor: 18, patch: 0 }); + expect(parseReleaseTag('v01.0.0')).toBeUndefined(); + }); +}); diff --git a/scripts/release-notes-start-tag.ts b/scripts/release-notes-start-tag.ts new file mode 100644 index 0000000..bd284d9 --- /dev/null +++ b/scripts/release-notes-start-tag.ts @@ -0,0 +1,86 @@ +/** + * Choose the tag GitHub should start a release's generated notes from. + * + * `gh release create --generate-notes` with no start tag diffs against the + * latest release of **any** kind. Lockstep (`vX.Y.Z`) and adapter + * (`-vX.Y.Z`, ADR-0007) releases share one repository, so an adapter + * release listed every pull request since the last lockstep release, and the + * next lockstep release would have started at that adapter tag and dropped + * those pull requests from both lockstep releases. + * + * The start tag is the highest existing tag of the **same kind** below the one + * being released, compared by SemVer. Tags that are not stable releases are + * never chosen: the moving Action tag `v0`, pre-releases, and + * `action-recovery-block/` markers do not match the grammar. When no + * earlier tag of the kind exists, nothing is printed and the workflow omits + * `--notes-start-tag`, keeping GitHub's default. + * + * Usage: `bun scripts/release-notes-start-tag.ts `; reads `git tag --list`. + */ + +export interface ReleaseTag { + /** `''` for a lockstep tag, otherwise the adapter slug (`spec-kit`). */ + kind: string; + major: number; + minor: number; + patch: number; +} + +const RELEASE_TAG = /^(?:([a-z0-9]+(?:-[a-z0-9]+)*)-)?v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; + +export function parseReleaseTag(tag: string): ReleaseTag | undefined { + const match = RELEASE_TAG.exec(tag); + if (!match) return undefined; + return { + kind: match[1] ?? '', + major: Number(match[2]), + minor: Number(match[3]), + patch: Number(match[4]), + }; +} + +function compare(left: ReleaseTag, right: ReleaseTag): number { + if (left.major !== right.major) return left.major - right.major; + if (left.minor !== right.minor) return left.minor - right.minor; + return left.patch - right.patch; +} + +/** + * The highest tag in `tags` of the same kind as `current` and strictly below + * it, or `undefined` for the first release of that kind. Throws when `current` + * is not a stable release tag, so the workflow fails rather than silently + * falling back to GitHub's cross-kind default. + */ +export function previousTagOfSameKind(current: string, tags: readonly string[]): string | undefined { + const target = parseReleaseTag(current); + if (!target) throw new Error(`Release tag ${current} is not vX.Y.Z or -vX.Y.Z`); + let best: { tag: string; parsed: ReleaseTag } | undefined; + for (const tag of tags) { + const parsed = parseReleaseTag(tag); + if (!parsed || parsed.kind !== target.kind) continue; + if (compare(parsed, target) >= 0) continue; + if (!best || compare(parsed, best.parsed) > 0) best = { tag, parsed }; + } + return best?.tag; +} + +async function main(argv: readonly string[]): Promise { + const [current, ...rest] = argv; + if (!current || rest.length > 0) { + throw new Error('Usage: bun scripts/release-notes-start-tag.ts '); + } + const git = Bun.spawn(['git', 'tag', '--list'], { stdout: 'pipe', stderr: 'inherit' }); + const output = await new Response(git.stdout).text(); + const exitCode = await git.exited; + if (exitCode !== 0) throw new Error(`git tag --list failed with exit ${exitCode}`); + const tags = output.split('\n').map((line) => line.trim()).filter(Boolean); + const start = previousTagOfSameKind(current, tags); + if (start) process.stdout.write(`${start}\n`); +} + +if (import.meta.main) { + main(process.argv.slice(2)).catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); +}