Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions scripts/action-tag-recovery-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:');
Expand Down
89 changes: 89 additions & 0 deletions scripts/release-notes-start-tag.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
86 changes: 86 additions & 0 deletions scripts/release-notes-start-tag.ts
Original file line number Diff line number Diff line change
@@ -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
* (`<slug>-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/<sha>` 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 <tag>`; 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 <slug>-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<void> {
const [current, ...rest] = argv;
if (!current || rest.length > 0) {
throw new Error('Usage: bun scripts/release-notes-start-tag.ts <tag>');
}
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);
});
}
Loading