diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 0000000..acfe5ed --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -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." diff --git a/.github/workflows/verify-pr.yml b/.github/workflows/verify-pr.yml index 887f320..c372093 100644 --- a/.github/workflows/verify-pr.yml +++ b/.github/workflows/verify-pr.yml @@ -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 diff --git a/README.md b/README.md index 734de6b..cdf892a 100644 --- a/README.md +++ b/README.md @@ -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` 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` to start the existing **Publish** workflow. +Confirm that the published archive installs and its intended consumer flow works before reporting availability. + ## License MIT diff --git a/scripts/check-feature-release-files.mjs b/scripts/check-feature-release-files.mjs new file mode 100644 index 0000000..f4ce94e --- /dev/null +++ b/scripts/check-feature-release-files.mjs @@ -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 +} diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs new file mode 100644 index 0000000..a8cf195 --- /dev/null +++ b/scripts/prepare-release.mjs @@ -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 ') +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}`)