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
49 changes: 49 additions & 0 deletions .github/workflows/prepare-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: Prepare Release
on:
workflow_dispatch:
inputs:
release_type:
description: Semver bump
required: true
type: choice
options: [patch, minor, major]
default: patch
permissions:
contents: write
pull-requests: write
concurrency:
group: prepare-release
cancel-in-progress: false
jobs:
prepare:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
- name: Prepare release metadata
id: release
env:
TYPE: ${{ inputs.release_type }}
run: |
current=$(node -p "require('./package.json').version")
version=$(CURRENT="$current" TYPE="$TYPE" node -e "const [a,b,c]=process.env.CURRENT.split('.').map(Number);const t=process.env.TYPE;console.log(t==='major'?(a+1)+'.0.0':t==='minor'?a+'.'+(b+1)+'.0':a+'.'+b+'.'+(c+1))")
node scripts/prepare-release.mjs "$version"
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Open release PR
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.release.outputs.version }}
run: |
branch="release/v$VERSION"
git config user.name github-actions[bot]
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
git checkout -b "$branch"
git add package.json CHANGELOG.md
git commit -m "chore(release): $VERSION"
git push origin "$branch"
gh pr create --base main --head "$branch" --title "chore(release): $VERSION" --body "Release-time preparation from merged main. Feature PRs do not carry version or changelog edits."
3 changes: 3 additions & 0 deletions .github/workflows/verify-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
fetch-depth: 0
- name: Keep release prep out of feature PRs
run: node scripts/check-feature-release-files.mjs
- name: Retain exact source
run: |
mkdir -p verification
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,25 @@ Packaging rebuilds the distribution and rejects stale bundled adapter metadata.
The test suite packs the npm artifact and bundles `/worker`, `/specs`, and
`/catalog` for a browser Worker runtime.

## Prepare a release

Feature PRs must leave the package version and `CHANGELOG.md` unchanged.
Feature checks compare against the common ancestor, so releases merged into `main` do not invalidate unchanged feature metadata.
After features merge, run the **Prepare Release** workflow and choose a patch, minor, or major bump.
The workflow uses Node and Git to open a `release/v<version>` PR from current `main`.
It does not install dependencies or publish a package.

Verification requires the branch version to match the next package version.
Only the package version and generated changelog entry may change in that PR.
Existing changelog history must remain unchanged.
The generated notes describe the `main` snapshot used by the preparation workflow.
Before merging, compare the notes with the final release commits and update them if `main` advanced.
Approve the generated PR's workflow runs if GitHub requests approval.
Merge the release PR after its checks pass.

After publication approval, tag the merged commit with `v<version>` to start the existing **Publish** workflow.
Confirm that the published archive installs and its intended consumer flow works before reporting availability.

## License

MIT
78 changes: 78 additions & 0 deletions scripts/check-feature-release-files.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { isDeepStrictEqual } from 'node:util'

const baseRef = `origin/${process.env.GITHUB_BASE_REF || 'main'}`
const headPackage = JSON.parse(readFileSync('package.json', 'utf8'))
const changed = execFileSync('git', ['diff', '--name-only', `${baseRef}...HEAD`], { encoding: 'utf8' }).trim().split('\n').filter(Boolean)
const headBranch = process.env.GITHUB_HEAD_REF || execFileSync('git', ['branch', '--show-current'], { encoding: 'utf8' }).trim()
const releaseBranch = /^release\/v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(headBranch)
const comparisonRef = releaseBranch ? baseRef : execFileSync('git', ['merge-base', baseRef, 'HEAD'], { encoding: 'utf8' }).trim()
const basePackage = JSON.parse(execFileSync('git', ['show', `${comparisonRef}:package.json`], { encoding: 'utf8' }))

if (!releaseBranch) {
if (basePackage.version !== headPackage.version || changed.includes('CHANGELOG.md')) {
throw new Error('feature PRs must not change package version or CHANGELOG.md; run Prepare Release after merge')
}
console.log(`feature release files clean at ${headPackage.version}`)
} else {
const branchVersion = releaseBranch.slice(1).join('.')
if (headPackage.version !== branchVersion) {
throw new Error(`release branch ${headBranch} must match package version ${headPackage.version}`)
}
if (!isNextVersion(basePackage.version, headPackage.version)) {
throw new Error(`release version ${headPackage.version} must be the next patch, minor, or major after ${basePackage.version}`)
}
if (changed.length !== 2 || !changed.includes('package.json') || !changed.includes('CHANGELOG.md')) {
throw new Error('release PRs may change only package.json and CHANGELOG.md')
}

const baseManifest = { ...basePackage }
const headManifest = { ...headPackage }
delete baseManifest.version
delete headManifest.version
if (!isDeepStrictEqual(baseManifest, headManifest)) {
throw new Error('release PRs may change only the package version in package.json')
}

const baseChangelog = execFileSync('git', ['show', `${baseRef}:CHANGELOG.md`], { encoding: 'utf8' })
const headChangelog = readFileSync('CHANGELOG.md', 'utf8')
const previousHistory = baseChangelog.replace(/^# Changelog\s*/, '')
const releasePrefix = `# Changelog\n\n## ${headPackage.version}\n\n`
if (!headChangelog.startsWith(releasePrefix)) {
throw new Error(`release changelog must start with ## ${headPackage.version}`)
}
if (!headChangelog.endsWith(previousHistory)) {
throw new Error('release changelog must preserve existing history unchanged')
}
const inserted = headChangelog.slice(releasePrefix.length, headChangelog.length - previousHistory.length)
if (!inserted.endsWith('\n\n')) {
throw new Error('release changelog must separate the new entry from existing history')
}
const releaseNotes = inserted.slice(0, -2).split('\n')
if (releaseNotes.length === 0 || releaseNotes.some(line => !line.startsWith('- '))) {
throw new Error('release changelog must contain generated bullet entries under the new heading')
}
console.log(`release metadata valid at ${headPackage.version} (${headBranch})`)
}

function isNextVersion(baseVersion, candidateVersion) {
const base = parseVersion(baseVersion)
const candidate = parseVersion(candidateVersion)
if (!base || !candidate) return false

const [baseMajor, baseMinor, basePatch] = base
const [major, minor, patch] = candidate
return (major === baseMajor && minor === baseMinor && patch === basePatch + 1)
|| (major === baseMajor && minor === baseMinor + 1 && patch === 0)
|| (major === baseMajor + 1 && minor === 0 && patch === 0)
}

function parseVersion(version) {
if (typeof version !== 'string') return null
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(version)
if (!match) return null
const parts = match.slice(1).map(Number)
return parts.every(Number.isSafeInteger) ? parts : null
}
16 changes: 16 additions & 0 deletions scripts/prepare-release.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process'
import { readFileSync, writeFileSync } from 'node:fs'
const version=process.argv[2]
if(!version||!/^\d+\.\d+\.\d+$/.test(version)) throw new Error('usage: node scripts/prepare-release.mjs <x.y.z>')
const pkg=readFileSync('package.json','utf8')
const current=JSON.parse(pkg).version
if(current===version)throw new Error(`already ${version}`)
writeFileSync('package.json',pkg.replace(/^(\s*"version":\s*)"[^"]+"/m,(_m,p)=>`${p}"${version}"`))
const git=(...a)=>execFileSync('git',a,{encoding:'utf8'}).trim()
const tag=git('describe','--tags','--abbrev=0','--match','v*')
const subjects=git('log','--no-merges','--format=%s',`${tag}..HEAD`).split('\n').filter(Boolean)
if(!subjects.length)throw new Error(`no commits since ${tag}`)
const old=readFileSync('CHANGELOG.md','utf8')
writeFileSync('CHANGELOG.md',`# Changelog\n\n## ${version}\n\n${subjects.map(s=>`- ${s}`).join('\n')}\n\n${old.replace(/^# Changelog\s*/,'')}`)
console.log(`Prepared ${version} from ${subjects.length} commits since ${tag}`)
Loading