Skip to content
Closed
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
77 changes: 77 additions & 0 deletions .github/workflows/package-release-tracking.yml
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions contributor-docs/versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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: <package-name>` 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 |
Expand Down
195 changes: 195 additions & 0 deletions script/release-package.mjs
Original file line number Diff line number Diff line change
@@ -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`)
}
Loading
Loading