From b1ff7177e75d4529c2624df9abb4c7ea18856699 Mon Sep 17 00:00:00 2001 From: jazelly Date: Mon, 27 Jul 2026 10:38:25 +0930 Subject: [PATCH] feat(release): aggregate package family releases --- .github/workflows/release.yml | 57 ++++++--- scripts/github-release-metadata.mjs | 163 +++++++++++++++++++++++++ scripts/release-smoke-test.mjs | 182 +++++++++++++++++++++------- 3 files changed, 346 insertions(+), 56 deletions(-) create mode 100644 scripts/github-release-metadata.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 34bb6c3..68fa28a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -256,28 +256,57 @@ jobs: } NODE + - name: Prepare GitHub family release + id: github-release + run: | + node scripts/github-release-metadata.mjs \ + .changeset/release-plan.json \ + "$(git rev-parse HEAD)" \ + "$GITHUB_REPOSITORY" \ + "$RUNNER_TEMP/package-release-notes.md" \ + "$GITHUB_OUTPUT" + + - name: Reconcile family release tag + if: steps.github-release.outputs.has-release == 'true' + env: + FAMILY_RELEASE_TAG: ${{ steps.github-release.outputs.tag }} + run: | + if git rev-parse --verify "refs/tags/$FAMILY_RELEASE_TAG" \ + >/dev/null 2>&1; then + existing_commit="$(git rev-list -n 1 "$FAMILY_RELEASE_TAG")" + release_commit="$(git rev-parse HEAD)" + + if [ "$existing_commit" != "$release_commit" ]; then + echo "::error::Family tag $FAMILY_RELEASE_TAG points at $existing_commit, not $release_commit." + exit 1 + fi + + echo "Existing family tag: $FAMILY_RELEASE_TAG" + else + git tag "$FAMILY_RELEASE_TAG" + echo "New family tag: $FAMILY_RELEASE_TAG" + fi + - name: Push release tags run: git push origin --tags - - name: Create GitHub releases + - name: Create GitHub family release + if: steps.github-release.outputs.has-release == 'true' env: + FAMILY_RELEASE_NOTES: ${{ steps.github-release.outputs.notes }} + FAMILY_RELEASE_TAG: ${{ steps.github-release.outputs.tag }} + FAMILY_RELEASE_TITLE: ${{ steps.github-release.outputs.title }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - mapfile -t release_tags < <(git tag --points-at HEAD | grep "^@agentic-react/" || true) - - if [ "${#release_tags[@]}" -eq 0 ]; then - echo "No package release tags point at HEAD." - exit 0 + if gh release view "$FAMILY_RELEASE_TAG" >/dev/null 2>&1; then + echo "GitHub family release already exists for $FAMILY_RELEASE_TAG." + else + gh release create "$FAMILY_RELEASE_TAG" \ + --title "$FAMILY_RELEASE_TITLE" \ + --notes-file "$FAMILY_RELEASE_NOTES" \ + --latest fi - for tag in "${release_tags[@]}"; do - if gh release view "$tag" >/dev/null 2>&1; then - echo "GitHub release already exists for $tag." - else - gh release create "$tag" --title "$tag" --generate-notes --latest - fi - done - - name: Clear recovery state env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/scripts/github-release-metadata.mjs b/scripts/github-release-metadata.mjs new file mode 100644 index 0000000..1bc2967 --- /dev/null +++ b/scripts/github-release-metadata.mjs @@ -0,0 +1,163 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const publishableReleases = (plan) => + plan.releases + .filter((release) => release.type !== 'none' && release.newVersion) + .toSorted((left, right) => left.name.localeCompare(right.name)); + +const packageDirectory = (packageName) => packageName.split('/').at(-1); + +const changelogSection = (rootDir, release) => { + const changelogPath = path.join( + rootDir, + 'packages', + packageDirectory(release.name), + 'CHANGELOG.md', + ); + + if (!fs.existsSync(changelogPath)) { + return undefined; + } + + const changelog = fs.readFileSync(changelogPath, 'utf8'); + const heading = `## ${release.newVersion}`; + const headingIndex = changelog.indexOf(heading); + + if (headingIndex === -1) { + return undefined; + } + + const sectionStart = headingIndex + heading.length; + const remaining = changelog.slice(sectionStart); + const nextHeading = remaining.search(/\n## \S/); + const section = + nextHeading === -1 ? remaining : remaining.slice(0, nextHeading); + + return section.trim() || undefined; +}; + +const releaseTitle = (releases) => { + if (releases.length === 1) { + return `${releases[0].name} v${releases[0].newVersion}`; + } + + const versions = [...new Set(releases.map((release) => release.newVersion))]; + + if (versions.length === 1) { + return `Agentic React packages v${versions[0]}`; + } + + return `Agentic React packages ยท ${versions.map((version) => `v${version}`).join(' / ')}`; +}; + +export const createGitHubReleaseMetadata = ({ + plan, + repository, + rootDir, + sha, +}) => { + if (!/^[\w.-]+\/[\w.-]+$/.test(repository)) { + throw new Error(`Invalid GitHub repository: ${repository}`); + } + + if (!/^[0-9a-f]{12,40}$/.test(sha)) { + throw new Error(`Invalid release commit SHA: ${sha}`); + } + + const releases = publishableReleases(plan); + + if (releases.length === 0) { + return undefined; + } + + const shortSha = sha.slice(0, 12); + const tag = `packages@${shortSha}`; + const encodedTag = encodeURIComponent(tag); + const lines = [ + '## Published packages', + '', + 'These packages were built, tested, and published together from ' + + `release commit [\`${shortSha}\`](https://github.com/${repository}/commit/${sha}).`, + '', + '| Package | Version | npm | Changelog |', + '| --- | --- | --- | --- |', + ]; + + for (const release of releases) { + const directory = packageDirectory(release.name); + const npmUrl = + `https://www.npmjs.com/package/${release.name}/v/` + + release.newVersion; + const changelogUrl = + `https://github.com/${repository}/blob/${encodedTag}/packages/` + + `${directory}/CHANGELOG.md`; + + lines.push( + `| \`${release.name}\` | \`${release.newVersion}\` | ` + + `[npm](${npmUrl}) | [changelog](${changelogUrl}) |`, + ); + } + + const sections = releases + .map((release) => ({ + name: release.name, + section: changelogSection(rootDir, release), + })) + .filter(({ section }) => section); + + if (sections.length > 0) { + lines.push('', '## Package changes'); + + for (const { name, section } of sections) { + lines.push('', `### \`${name}\``, '', section); + } + } + + return { + notes: `${lines.join('\n')}\n`, + tag, + title: releaseTitle(releases), + }; +}; + +const isMain = + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isMain) { + const [planPath, sha, repository, notesPath, outputPath] = + process.argv.slice(2); + + if (!planPath || !sha || !repository || !notesPath || !outputPath) { + throw new Error( + 'Usage: github-release-metadata.mjs ' + + ' ', + ); + } + + const plan = JSON.parse(fs.readFileSync(planPath, 'utf8')); + const metadata = createGitHubReleaseMetadata({ + plan, + repository, + rootDir: process.cwd(), + sha, + }); + + if (!metadata) { + fs.appendFileSync(outputPath, 'has-release=false\n'); + } else { + fs.writeFileSync(notesPath, metadata.notes); + fs.appendFileSync( + outputPath, + [ + 'has-release=true', + `tag=${metadata.tag}`, + `title=${metadata.title}`, + `notes=${notesPath}`, + '', + ].join('\n'), + ); + } +} diff --git a/scripts/release-smoke-test.mjs b/scripts/release-smoke-test.mjs index 43c53fc..c580ae7 100644 --- a/scripts/release-smoke-test.mjs +++ b/scripts/release-smoke-test.mjs @@ -5,6 +5,8 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { createGitHubReleaseMetadata } from './github-release-metadata.mjs'; + const __filename = fileURLToPath(import.meta.url); const rootDir = path.resolve(path.dirname(__filename), '..'); const workflowPath = path.join(rootDir, '.github/workflows/release.yml'); @@ -295,6 +297,7 @@ const runGitHubReleaseBlock = ( command, fakeGh, existingReleases = [], + env = {}, ) => { run('bash', ['-c', command], { cwd: repo, @@ -303,6 +306,7 @@ const runGitHubReleaseBlock = ( GH_EXISTING_RELEASES: existingReleases.join(' '), GH_LOG: fakeGh.logPath, GH_TOKEN: 'fake-token', + ...env, }, }); }; @@ -335,8 +339,10 @@ const testWorkflowContract = () => { 'Publish packages', 'Ensure package publication succeeded', 'Reconcile release tags', + 'Prepare GitHub family release', + 'Reconcile family release tag', 'Push release tags', - 'Create GitHub releases', + 'Create GitHub family release', 'Clear recovery state', ]; let previous = -1; @@ -418,14 +424,33 @@ const testWorkflowContract = () => { 'git push origin --tags', ); - const releaseBlock = extractRunCommand('Create GitHub releases'); + const metadataBlock = extractRunCommand('Prepare GitHub family release'); + assertContains( + metadataBlock, + 'scripts/github-release-metadata.mjs', + 'family release metadata', + ); + const familyTagBlock = extractRunCommand('Reconcile family release tag'); + assertContains( + familyTagBlock, + 'git rev-list -n 1 "$FAMILY_RELEASE_TAG"', + 'family tag target verification', + ); + const releaseBlock = extractRunCommand('Create GitHub family release'); + assertContains( + releaseBlock, + 'gh release view "$FAMILY_RELEASE_TAG"', + 'release idempotency', + ); assertContains( releaseBlock, - 'git tag --points-at HEAD | grep "^@agentic-react/"', - 'release tag selection', + 'gh release create "$FAMILY_RELEASE_TAG"', + 'family release creation', + ); + assert.ok( + !releaseBlock.includes('^@agentic-react/'), + 'package tags should not create individual GitHub releases', ); - assertContains(releaseBlock, 'gh release view "$tag"', 'release idempotency'); - assertContains(releaseBlock, 'gh release create "$tag"', 'release creation'); assertContains( extractRunCommand('Clear recovery state'), 'git rm .changeset/release-pending.json', @@ -721,67 +746,140 @@ const testRecoveryTreeVerification = (workspace) => { const testTagPush = (workspace) => { const tagFixture = makeGitFixture(workspace, 'tag-push'); + const familyTagCommand = extractRunCommand('Reconcile family release tag'); const tagPushCommand = extractRunCommand('Push release tags'); + const familyTag = 'packages@0123456789ab'; run('git', ['tag', '@agentic-react/core@1.0.0'], { cwd: tagFixture.repo, }); run('git', ['tag', 'v1.0.0'], { cwd: tagFixture.repo }); + runShellBlock(tagFixture.repo, familyTagCommand, { + FAMILY_RELEASE_TAG: familyTag, + }); + runShellBlock(tagFixture.repo, familyTagCommand, { + FAMILY_RELEASE_TAG: familyTag, + }); run('/bin/sh', ['-c', tagPushCommand], { cwd: tagFixture.repo }); assert.deepEqual( list(run('git', ['--git-dir', tagFixture.origin, 'tag', '--list'])), - ['@agentic-react/core@1.0.0', 'v1.0.0'], + ['@agentic-react/core@1.0.0', familyTag, 'v1.0.0'], 'release tags should be pushed to origin', ); + + const mismatchedFixture = makeGitFixture( + workspace, + 'mismatched-family-tag', + ); + run('git', ['tag', familyTag], { cwd: mismatchedFixture.repo }); + fs.appendFileSync(path.join(mismatchedFixture.repo, 'README.md'), '\nnext\n'); + run('git', ['add', 'README.md'], { cwd: mismatchedFixture.repo }); + run('git', ['commit', '-m', 'move release head'], { + cwd: mismatchedFixture.repo, + }); + + assert.throws( + () => + runShellBlock(mismatchedFixture.repo, familyTagCommand, { + FAMILY_RELEASE_TAG: familyTag, + }), + /failed with exit code 1/, + 'an existing family tag must point at the release commit', + ); }; const testGitHubReleaseCreation = (workspace) => { - const releaseFixture = makeGitFixture(workspace, 'github-release-selection'); - const fakeGh = makeFakeGh(workspace, 'github-release-selection'); - const releaseBlock = extractRunCommand('Create GitHub releases'); - const oldHead = run('git', ['rev-parse', 'HEAD'], { + const releaseFixture = makeGitFixture(workspace, 'github-family-release'); + const fakeGh = makeFakeGh(workspace, 'github-family-release'); + const releaseBlock = extractRunCommand('Create GitHub family release'); + const sha = run('git', ['rev-parse', 'HEAD'], { cwd: releaseFixture.repo, }); + const rootDir = path.join(workspace, 'github-family-release-metadata'); + const coreDir = path.join(rootDir, 'packages/core'); + const viteDir = path.join(rootDir, 'packages/vite'); - run('git', ['tag', '@agentic-react/core@0.9.0', oldHead], { - cwd: releaseFixture.repo, - }); - fs.appendFileSync(path.join(releaseFixture.repo, 'README.md'), '\nnext\n'); - run('git', ['add', 'README.md'], { cwd: releaseFixture.repo }); - run('git', ['commit', '-m', 'second fixture commit'], { - cwd: releaseFixture.repo, - }); - run('git', ['tag', '@agentic-react/core@1.0.0'], { - cwd: releaseFixture.repo, - }); - run('git', ['tag', '@agentic-react/vite@1.0.0'], { - cwd: releaseFixture.repo, + fs.mkdirSync(coreDir, { recursive: true }); + fs.mkdirSync(viteDir, { recursive: true }); + fs.writeFileSync( + path.join(coreDir, 'CHANGELOG.md'), + '# Core\n\n## 1.0.0\n\n### Major Changes\n\n- Stable API.\n', + ); + fs.writeFileSync( + path.join(viteDir, 'CHANGELOG.md'), + '# Vite\n\n## 1.0.0\n\n### Major Changes\n\n- Stable adapter.\n', + ); + + const metadata = createGitHubReleaseMetadata({ + plan: { + releases: [ + { + name: '@agentic-react/vite', + newVersion: '1.0.0', + type: 'major', + }, + { + name: 'ignored-playground', + newVersion: '0.0.0', + type: 'none', + }, + { + name: '@agentic-react/core', + newVersion: '1.0.0', + type: 'major', + }, + ], + }, + repository: 'example/agentic-react', + rootDir, + sha, }); - run('git', ['tag', 'v1.0.0'], { cwd: releaseFixture.repo }); - runGitHubReleaseBlock(releaseFixture.repo, releaseBlock, fakeGh, [ - '@agentic-react/vite@1.0.0', - ]); + assert.equal(metadata.tag, `packages@${sha.slice(0, 12)}`); + assert.equal(metadata.title, 'Agentic React packages v1.0.0'); + assertContains(metadata.notes, '| `@agentic-react/core` | `1.0.0` |'); + assertContains(metadata.notes, '### `@agentic-react/vite`'); + assertContains(metadata.notes, '- Stable adapter.'); + assert.ok(!metadata.notes.includes('ignored-playground')); + + const notesPath = path.join(workspace, 'github-family-release-notes.md'); + fs.writeFileSync(notesPath, metadata.notes); + const releaseEnv = { + FAMILY_RELEASE_NOTES: notesPath, + FAMILY_RELEASE_TAG: metadata.tag, + FAMILY_RELEASE_TITLE: metadata.title, + }; - const ghCalls = fs.readFileSync(fakeGh.logPath, 'utf8').trim().split('\n'); + runGitHubReleaseBlock( + releaseFixture.repo, + releaseBlock, + fakeGh, + [], + releaseEnv, + ); - assert.deepEqual( - ghCalls, + let ghCalls = fs.readFileSync(fakeGh.logPath, 'utf8').trim().split('\n'); + assert.deepEqual(ghCalls, [ + `release view ${metadata.tag}`, [ - 'release view @agentic-react/core@1.0.0', - [ - 'release create @agentic-react/core@1.0.0', - '--title @agentic-react/core@1.0.0', - '--generate-notes --latest', - ].join(' '), - 'release view @agentic-react/vite@1.0.0', - ], - [ - 'GitHub release workflow should create missing package releases', - 'at HEAD only', + `release create ${metadata.tag}`, + '--title Agentic React packages v1.0.0', + `--notes-file ${notesPath}`, + '--latest', ].join(' '), + ]); + + fs.writeFileSync(fakeGh.logPath, ''); + runGitHubReleaseBlock( + releaseFixture.repo, + releaseBlock, + fakeGh, + [metadata.tag], + releaseEnv, ); + ghCalls = fs.readFileSync(fakeGh.logPath, 'utf8').trim().split('\n'); + assert.deepEqual(ghCalls, [`release view ${metadata.tag}`]); }; const workspace = fs.mkdtempSync(