From 3268d30134256520201c1da6ab68516988a9124d Mon Sep 17 00:00:00 2001 From: Josh Black Date: Tue, 20 Oct 2026 14:35:36 -0500 Subject: [PATCH] ci: add package-release-tracking workflow for publishing a single package --- .../workflows/package-release-tracking.yml | 77 ++++++ contributor-docs/versioning.md | 19 ++ script/release-package.mjs | 195 ++++++++++++++++ script/release-package.test.mjs | 220 ++++++++++++++++++ 4 files changed, 511 insertions(+) create mode 100644 .github/workflows/package-release-tracking.yml create mode 100644 script/release-package.mjs create mode 100644 script/release-package.test.mjs diff --git a/.github/workflows/package-release-tracking.yml b/.github/workflows/package-release-tracking.yml new file mode 100644 index 00000000000..6e34d0a6fbc --- /dev/null +++ b/.github/workflows/package-release-tracking.yml @@ -0,0 +1,77 @@ +name: Package Release Tracking + +on: + workflow_dispatch: + inputs: + package: + description: 'Package to version (for example, @primer/react)' + required: true + type: string + +concurrency: + group: package-release-tracking-${{ inputs.package }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + release-package: + name: Prepare release pull request + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: main + fetch-depth: 0 + persist-credentials: false + + - name: Set up Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 + id: app-token + with: + app-id: ${{ vars.PRIMER_APP_ID_SHARED }} + private-key: ${{ secrets.PRIMER_APP_PRIVATE_KEY_SHARED }} + + - name: Prepare package release + id: release + run: node script/release-package.mjs + env: + RELEASE_PACKAGE: ${{ inputs.package }} + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + + - name: Update lockfile + run: npm install --package-lock-only --ignore-scripts + + - name: Create or update release pull request + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + RELEASE_PACKAGE: ${{ inputs.package }} + RELEASE_BRANCH: ${{ steps.release.outputs.branch }} + RELEASE_BODY: ${{ steps.release.outputs.body }} + run: | + gh auth setup-git + remote_sha=$(git ls-remote --heads origin "refs/heads/$RELEASE_BRANCH" | cut -f1) + git switch -c "$RELEASE_BRANCH" + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add . + git commit -m "chore(release): version $RELEASE_PACKAGE" + git push --force-with-lease="refs/heads/$RELEASE_BRANCH:$remote_sha" origin "HEAD:refs/heads/$RELEASE_BRANCH" + + pr_number=$(gh pr list --base main --head "$RELEASE_BRANCH" --state open --json number --jq '.[0].number // empty') + if [[ -n "$pr_number" ]]; then + gh pr edit "$pr_number" --title "Release Tracking: $RELEASE_PACKAGE" --body-file "$RELEASE_BODY" + else + gh pr create --base main --head "$RELEASE_BRANCH" --title "Release Tracking: $RELEASE_PACKAGE" --body-file "$RELEASE_BODY" + fi diff --git a/contributor-docs/versioning.md b/contributor-docs/versioning.md index ae61443768a..8878f4f1b4e 100644 --- a/contributor-docs/versioning.md +++ b/contributor-docs/versioning.md @@ -10,6 +10,7 @@ - [Versioning](#versioning) - [Table of Contents](#table-of-contents) - [Overview](#overview) + - [Preparing a package release](#preparing-a-package-release) - [Changes](#changes) - [Reference](#reference) - [The type of a prop is broadened](#the-type-of-a-prop-is-broadened) @@ -47,6 +48,24 @@ corresponding semver bumps, check out the [changes](#changes) table below. For a full list of releases, visit our [releases](https://github.com/primer/react/releases) page. +## Preparing a package release + +Run the **Package Release Tracking** workflow (`package-release-tracking.yml`) from `main` and enter +the full workspace package name, such as `@primer/react`. The workflow creates or +updates a PR titled `Release Tracking: ` on a package-specific branch. +Rerun the workflow to refresh the PR with the latest changes from `main`. + +The PR applies only that package's pending changesets, along with any downstream +version and dependency updates required by Changesets. Unrelated changesets remain +pending. If a changeset covers multiple packages, only the selected package's entry +is consumed; the other entries and description remain available for a later release. +The workflow fails if the package is unknown, has no pending changesets, is excluded +from versioning, or the repository is in prerelease mode. + +This workflow only prepares a PR. Publishing remains unchanged: merging the PR +does not publish immediately while unrelated changesets remain. The existing +Release workflow publishes once all pending changesets have been applied. + ## Changes | Category | Type of change | semver bump | diff --git a/script/release-package.mjs b/script/release-package.mjs new file mode 100644 index 00000000000..c8e4d2333cf --- /dev/null +++ b/script/release-package.mjs @@ -0,0 +1,195 @@ +import {execFileSync} from 'node:child_process' +import {existsSync} from 'node:fs' +import {appendFile, mkdtemp, readFile, rm, writeFile} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import path from 'node:path' +import {fileURLToPath, pathToFileURL} from 'node:url' +import glob from 'fast-glob' + +const changesetCli = fileURLToPath(import.meta.resolve('@changesets/cli/bin.js')) + +function formatChangeset(releases, summary) { + const frontmatter = releases.map(release => { + return `${JSON.stringify(release.name)}: ${release.type}` + }) + return `---\n${frontmatter.join('\n')}\n---\n\n${summary}\n` +} + +async function workspacePackages(cwd) { + const {workspaces} = JSON.parse(await readFile(path.join(cwd, 'package.json'), 'utf8')) + const manifests = await glob( + workspaces.map(workspace => { + return `${workspace}/package.json` + }), + { + cwd, + ignore: ['**/node_modules/**'], + }, + ) + return Promise.all( + manifests.map(async manifest => { + const packageJson = JSON.parse(await readFile(path.join(cwd, manifest), 'utf8')) + return { + ...packageJson, + directory: path.dirname(manifest), + } + }), + ) +} + +export async function prepareRelease(cwd, packageName) { + if (!packageName) { + throw new Error('A package name is required.') + } + if (existsSync(path.join(cwd, '.changeset/pre.json'))) { + throw new Error('Package releases are not supported in prerelease mode. Exit prerelease mode first.') + } + const packages = await workspacePackages(cwd) + if ( + !packages.some(pkg => { + return pkg.name === packageName + }) + ) { + throw new Error(`Unknown workspace package: ${packageName}.`) + } + + const temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'primer-release-package-')) + const statusPath = path.join(temporaryDirectory, 'status.json') + const originals = new Map() + let versioned = false + + function runChangeset(...args) { + execFileSync(process.execPath, [changesetCli, ...args], { + cwd, + stdio: 'inherit', + }) + } + + try { + runChangeset('status', '--output', statusPath) + const {changesets} = JSON.parse(await readFile(statusPath, 'utf8')) + const selected = changesets.filter(changeset => { + return changeset.releases.some(release => { + return release.name === packageName + }) + }) + if (selected.length === 0) { + throw new Error(`No pending changesets found for ${packageName}. Use a workspace package name.`) + } + + for (const changeset of changesets) { + const filename = path.join(cwd, '.changeset', `${changeset.id}.md`) + const content = await readFile(filename, 'utf8') + const releases = changeset.releases.filter(release => { + return release.name === packageName + }) + const remaining = changeset.releases.filter(release => { + return release.name !== packageName + }) + originals.set(filename, { + content, + remaining: + releases.length === 0 ? content : remaining.length > 0 ? formatChangeset(remaining, changeset.summary) : null, + }) + if (releases.length > 0) { + await writeFile(filename, formatChangeset(releases, changeset.summary)) + } else { + await rm(filename) + } + } + + runChangeset('status', '--output', statusPath) + const plan = JSON.parse(await readFile(statusPath, 'utf8')) + if ( + !plan.releases.some(release => { + return release.name === packageName && release.type !== 'none' + }) + ) { + throw new Error(`${packageName} is excluded from versioning by the Changesets configuration.`) + } + runChangeset('version') + versioned = true + return plan + } finally { + for (const [filename, original] of originals) { + const content = versioned ? original.remaining : original.content + if (content !== null) { + await writeFile(filename, content) + } + } + await rm(temporaryDirectory, { + recursive: true, + }) + } +} + +export async function releaseBody(cwd, packageName, plan) { + const template = await readFile(path.join(cwd, '.github/pull_request_template.md'), 'utf8') + const packages = await workspacePackages(cwd) + const releases = [] + for (const release of plan.releases) { + if (release.type === 'none') { + continue + } + const pkg = packages.find(candidate => { + return candidate.name === release.name + }) + if (!pkg) { + throw new Error(`Cannot find workspace package ${release.name}.`) + } + const changelog = await readFile(path.join(cwd, pkg.directory, 'CHANGELOG.md'), 'utf8') + const lines = changelog.split('\n') + const start = lines.findIndex(line => { + return line.trim() === `## ${release.newVersion}` + }) + if (start === -1) { + throw new Error(`Cannot find changelog entry for ${release.name}@${release.newVersion}.`) + } + const next = lines.findIndex((line, index) => { + return index > start && line.startsWith('## ') + }) + releases.push({ + heading: `## ${release.name}@${release.newVersion}`, + content: lines + .slice(start + 1, next === -1 ? undefined : next) + .join('\n') + .trim(), + }) + } + const introduction = `This PR prepares a release for \`${packageName}\` and any required downstream dependency updates. Rerun the Package Release Tracking workflow to refresh this PR from \`main\`. + +Publishing is unchanged: merging this PR does not publish immediately while unrelated changesets remain. The existing Release workflow publishes once all pending changesets have been applied.` + const updatedTemplate = template + .replace('Closes #', 'Closes') + .replace( + '#### Changed\n', + `#### Changed\n\n- Update versions and changelogs for \`${packageName}\` and required downstream packages.\n`, + ) + .replace('- [ ] Not sure', '- [x] Not sure') + const header = `${introduction}\n\n${updatedTemplate.trim()}\n\n# Releases\n\n` + const body = + header + + releases + .map(release => { + return `${release.heading}\n\n${release.content}` + }) + .join('\n\n') + if (body.length <= 60000) { + return body + } + return `${header}> Changelog details are omitted because they exceed the PR body limit. See the changed CHANGELOG.md files.\n\n${releases + .map(release => { + return release.heading + }) + .join('\n\n')}` +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + const cwd = process.cwd() + const packageName = process.env.RELEASE_PACKAGE + const plan = await prepareRelease(cwd, packageName) + const body = await releaseBody(cwd, packageName, plan) + const bodyPath = path.join(process.env.RUNNER_TEMP, 'release-package-body.md') + await writeFile(bodyPath, body) + await appendFile(process.env.GITHUB_OUTPUT, `branch=changeset-release/package/${packageName}\nbody=${bodyPath}\n`) +} diff --git a/script/release-package.test.mjs b/script/release-package.test.mjs new file mode 100644 index 00000000000..a9f2fd55e66 --- /dev/null +++ b/script/release-package.test.mjs @@ -0,0 +1,220 @@ +import assert from 'node:assert/strict' +import {execFileSync} from 'node:child_process' +import {existsSync} from 'node:fs' +import {mkdir, mkdtemp, readFile, rm, writeFile} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import path from 'node:path' +import {test} from 'node:test' +import {fileURLToPath} from 'node:url' +import {prepareRelease, releaseBody} from './release-package.mjs' + +async function fixture(t, changesets) { + const cwd = await mkdtemp(path.join(tmpdir(), 'primer-release-package-test-')) + t.after(async () => { + await rm(cwd, { + recursive: true, + }) + }) + async function write(filename, content) { + await mkdir(path.dirname(path.join(cwd, filename)), { + recursive: true, + }) + await writeFile(path.join(cwd, filename), content) + } + await write( + 'package.json', + JSON.stringify({ + name: 'release-test', + private: true, + workspaces: ['packages/*'], + }), + ) + await write('package-lock.json', '{"lockfileVersion":3}') + await write( + '.changeset/config.json', + JSON.stringify({ + changelog: ['./changelog.cjs', null], + baseBranch: 'main', + access: 'public', + updateInternalDependencies: 'patch', + }), + ) + await write( + '.changeset/changelog.cjs', + `module.exports = { + getReleaseLine: async (changeset) => { + return '- ' + changeset.summary + }, + getDependencyReleaseLine: async () => { + return '- Update dependencies' + }, + }`, + ) + await write( + '.github/pull_request_template.md', + 'Closes #\n\n\n\n### Changelog\n\n#### New\n\n#### Changed\n\n#### Removed\n\n### Rollout strategy\n\n- [ ] Not sure\n\n### Testing & Reviewing\n', + ) + for (const name of ['alpha', 'bravo', 'consumer']) { + await write( + `packages/${name}/package.json`, + JSON.stringify({ + name: `@test/${name}`, + version: '1.0.0', + ...(name === 'consumer' + ? { + dependencies: { + '@test/alpha': '^1.0.0', + }, + } + : {}), + }), + ) + } + for (const [name, content] of Object.entries(changesets)) { + await write(`.changeset/${name}.md`, content) + } + const gitOptions = { + cwd, + stdio: 'pipe', + } + execFileSync('git', ['init', '-b', 'main'], gitOptions) + execFileSync('git', ['add', '.'], gitOptions) + execFileSync( + 'git', + ['-c', 'user.name=Test', '-c', 'user.email=test@example.com', 'commit', '-m', 'test: initialize release fixture'], + gitOptions, + ) + return { + cwd, + write, + read: async filename => { + return readFile(path.join(cwd, filename), 'utf8') + }, + manifest: async name => { + return JSON.parse(await readFile(path.join(cwd, `packages/${name}/package.json`), 'utf8')) + }, + } +} + +test('versions only the selected package and preserves unrelated and shared changesets', async t => { + const unrelated = '---\n"@test/bravo": major\n---\n\nUnrelated release.\n' + const project = await fixture(t, { + alpha: '---\n"@test/alpha": minor\n---\n\nAdd alpha feature.\n', + shared: '---\n"@test/alpha": patch\n"@test/bravo": minor\n---\n\nShared fix.\n', + unrelated, + }) + const plan = await prepareRelease(project.cwd, '@test/alpha') + assert.equal((await project.manifest('alpha')).version, '1.1.0') + assert.equal((await project.manifest('bravo')).version, '1.0.0') + assert.equal((await project.manifest('consumer')).version, '1.0.0') + assert.equal(await project.read('.changeset/unrelated.md'), unrelated) + assert.equal(await project.read('.changeset/shared.md'), '---\n"@test/bravo": minor\n---\n\nShared fix.\n') + assert.equal(existsSync(path.join(project.cwd, '.changeset/alpha.md')), false) + const body = await releaseBody(project.cwd, '@test/alpha', plan) + assert.match(body, /## @test\/alpha@1\.1\.0/) + assert.match(body, /Add alpha feature\./) + assert.match(body, /Shared fix\./) + assert.doesNotMatch(body, /Unrelated release|## @test\/bravo/) + assert.match(body, /Publishing is unchanged/) + assert.match(body, //) + assert.match(body, /### Testing & Reviewing/) + + await project.write('packages/alpha/CHANGELOG.md', `## 1.1.0\n\n${'Long release notes.\n'.repeat(4000)}`) + const abbreviatedBody = await releaseBody(project.cwd, '@test/alpha', plan) + assert.ok(abbreviatedBody.length < 60000) + assert.match(abbreviatedBody, /Changelog details are omitted/) + assert.match(abbreviatedBody, /## @test\/alpha@1\.1\.0/) +}) + +test('includes required dependent bumps without consuming their pending changesets', async t => { + const pending = '---\n"@test/consumer": minor\n---\n\nUnreleased consumer feature.\n' + const project = await fixture(t, { + alpha: '---\n"@test/alpha": major\n---\n\nBreaking alpha change.\n', + consumer: pending, + }) + const plan = await prepareRelease(project.cwd, '@test/alpha') + assert.equal((await project.manifest('alpha')).version, '2.0.0') + const consumer = await project.manifest('consumer') + assert.equal(consumer.version, '1.0.1') + assert.equal(consumer.dependencies['@test/alpha'], '^2.0.0') + assert.equal(await project.read('.changeset/consumer.md'), pending) + const body = await releaseBody(project.cwd, '@test/alpha', plan) + assert.match(body, /## @test\/consumer@1\.0\.1/) + assert.doesNotMatch(body, /Unreleased consumer feature/) +}) + +test('rejects unknown packages and packages without changesets without changing files', async t => { + const content = '---\n"@test/bravo": patch\n---\n\nBravo fix.\n' + const project = await fixture(t, { + bravo: content, + }) + await assert.rejects(prepareRelease(project.cwd, '@test/missing'), /Unknown workspace package/) + await assert.rejects(prepareRelease(project.cwd, '@test/alpha'), /No pending changesets/) + assert.equal(await project.read('.changeset/bravo.md'), content) + assert.equal((await project.manifest('bravo')).version, '1.0.0') +}) + +test('rejects prerelease mode before modifying changesets', async t => { + const content = '---\n"@test/alpha": patch\n---\n\nAlpha fix.\n' + const project = await fixture(t, { + alpha: content, + }) + await project.write('.changeset/pre.json', '{}') + await assert.rejects(prepareRelease(project.cwd, '@test/alpha'), /prerelease mode/) + assert.equal(await project.read('.changeset/alpha.md'), content) +}) + +test('rejects excluded packages and restores their changesets', async t => { + const content = '---\n"@test/bravo": patch\n---\n\nBravo fix.\n' + const project = await fixture(t, { + bravo: content, + }) + const config = JSON.parse(await project.read('.changeset/config.json')) + await project.write( + '.changeset/config.json', + JSON.stringify({ + ...config, + ignore: ['@test/bravo'], + }), + ) + await assert.rejects(prepareRelease(project.cwd, '@test/bravo'), /excluded from versioning/) + assert.equal(await project.read('.changeset/bravo.md'), content) + assert.equal((await project.manifest('bravo')).version, '1.0.0') +}) + +test('restores original changesets when versioning fails', async t => { + const shared = '---\n"@test/alpha": patch\n"@test/bravo": minor\n---\n\nShared fix.\n' + const unrelated = '---\n"@test/bravo": major\n---\n\nBravo feature.\n' + const project = await fixture(t, { + shared, + unrelated, + }) + await project.write('.changeset/changelog.cjs', 'throw new Error("Changelog failure")') + await assert.rejects(prepareRelease(project.cwd, '@test/alpha'), /Command failed/) + assert.equal(await project.read('.changeset/shared.md'), shared) + assert.equal(await project.read('.changeset/unrelated.md'), unrelated) +}) + +test('writes the package branch and release body for the workflow', async t => { + const project = await fixture(t, { + alpha: '---\n"@test/alpha": patch\n---\n\nAlpha fix.\n', + }) + const outputPath = path.join(project.cwd, 'github-output') + execFileSync(process.execPath, [fileURLToPath(new URL('./release-package.mjs', import.meta.url))], { + cwd: project.cwd, + env: { + ...process.env, + RELEASE_PACKAGE: '@test/alpha', + RUNNER_TEMP: project.cwd, + GITHUB_OUTPUT: outputPath, + }, + stdio: 'pipe', + }) + const output = await readFile(outputPath, 'utf8') + assert.equal( + output, + `branch=changeset-release/package/@test/alpha\nbody=${path.join(project.cwd, 'release-package-body.md')}\n`, + ) + assert.match(await project.read('release-package-body.md'), /## @test\/alpha@1\.0\.1/) + assert.equal((await project.manifest('alpha')).version, '1.0.1') +})