diff --git a/.github/canary.sh b/.github/canary.sh deleted file mode 100755 index e0baffe6d..000000000 --- a/.github/canary.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash -npm --no-git-tag-version version minor || true - -version=$(node -p "require('./package.json').version") -canary_date=$(date +'%Y%m%dT%H%M%S') -canary_version=$(echo $version'-canary.'$canary_date) - -echo "Version that will be published: $canary_version" -npm --no-git-tag-version version $canary_version || true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c348e846a..0b71dc853 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,12 +1,6 @@ name: Release on: workflow_dispatch: - inputs: - mode: - description: 'Release mode (version = open the Version Packages PR only, no npm publish)' - type: choice - options: [version, staged, publish] - default: version push: branches: ['main', 'alpha', 'beta', 'rc'] paths: @@ -34,7 +28,8 @@ jobs: pull-requests: write issues: write outputs: - released: ${{ steps.changesets.outputs.staged == 'true' || steps.changesets.outputs.published == 'true' }} + staged: ${{ steps.changesets.outputs.staged }} + staged_packages: ${{ steps.changesets.outputs.staged_packages }} steps: - name: Check out code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -48,49 +43,38 @@ jobs: cache: 'false' force-install: 'true' - - name: Resolve release command - id: cmd - shell: bash - # MODE comes from env to avoid script injection from workflow inputs. - env: - MODE: ${{ inputs.mode || 'publish' }} - run: | - case "$MODE" in - publish) echo "publish=pnpm exec changeset publish" >> "$GITHUB_OUTPUT" ;; - staged) echo "publish=pnpm release:stage:ci" >> "$GITHUB_OUTPUT" ;; - version) echo "publish=" >> "$GITHUB_OUTPUT" ;; - *) echo "Unknown mode: $MODE" >&2; exit 1 ;; - esac - - name: Build - if: ${{ steps.cmd.outputs.publish != '' }} run: pnpm run build - name: Test - if: ${{ steps.cmd.outputs.publish != '' }} run: pnpm run test --coverage - name: Upload coverage reports to Codecov - if: ${{ steps.cmd.outputs.publish != '' }} uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + # createGithubReleases stays off: a staged version isn't public until + # `promote` confirms npm approval, so nothing should announce it yet. - name: Release id: changesets uses: changesets/action@63a615b9cd06ba9a3e6d13796c7fbcb080a60a0b # v1 with: version: pnpm exec changeset version - publish: ${{ steps.cmd.outputs.publish }} + publish: 'node scripts/release.mjs' commit: 'ci(changesets): version packages' title: 'ci(changesets): version packages' setupGitUser: false + createGithubReleases: false env: NPM_CONFIG_PROVENANCE: true GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Canary skips staging on purpose, so it stays automatic on every push. + # If the npm Trusted Publisher for these packages is ever locked to + # stage-only, this step will need its own non-stage-restricted entry. - name: Publish canary - if: ${{ steps.changesets.outputs.published != 'true' && steps.changesets.outputs.staged != 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main' }} + if: ${{ steps.changesets.outputs.staged != 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main' }} continue-on-error: true shell: bash env: @@ -108,10 +92,67 @@ jobs: pnpm run build pnpm exec changeset publish --tag canary + promote: + name: Promote staged release + needs: [release] + if: ${{ needs.release.outputs.staged == 'true' }} + environment: + name: npm-release-approval + timeout-minutes: 60 + runs-on: ubuntu-latest + permissions: + contents: write # gh release create + outputs: + approved: ${{ steps.verify.outputs.approved }} + steps: + - name: Check out code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + + - name: Setup Tools + uses: kubb-labs/config/.github/setup@d2daeb58eb7dc8c19d595c926b4788b7f682fb51 # main + with: + node-version: '22.22.3' + cache: 'false' + + # A green environment review only means a maintainer clicked approve, + # not that they actually ran `npm stage approve`. This confirms the + # staged versions are really live on the registry before anything + # public fires. + - name: Verify staged versions are live on npm + id: verify + run: node scripts/verifyRegistry.mjs + env: + STAGED_PACKAGES: ${{ needs.release.outputs.staged_packages }} + # How often to re-poll npm while waiting for a staged version to + # propagate, and how many times to try before giving up. + VERIFY_INTERVAL_SECONDS: '300' # in seconds + VERIFY_RETRY_ATTEMPTS: '10' + + # Tags are created here, not at stage time, so a rejected or expired + # staged package never leaves a dangling tag behind (see + # changesets/changesets#2025). The versions are already confirmed live + # above, so tagging now always points at something real. + - name: Tag released versions + run: | + pnpm exec changeset tag + git push --tags + + # Every staged package gets its own release here, since packages in + # this repo version and changelog independently (see the empty `fixed` + # and `linked` groups in .changeset/config.json) — unlike kubb-labs/kubb, + # where every package shares one fixed version and one combined release. + - name: Create GitHub Releases + run: node scripts/createReleases.mjs + env: + STAGED_PACKAGES: ${{ needs.release.outputs.staged_packages }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + deploy-platform: name: Dispatch content-updated to platform - needs: [release] - if: ${{ needs.release.outputs.released == 'true' }} + needs: [release, promote] + if: ${{ needs.promote.outputs.approved == 'true' }} runs-on: ubuntu-latest permissions: {} steps: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a2610cb56..97e52c2e2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -162,3 +162,23 @@ pnpm changeset ``` Pick the packages you changed, choose the bump (patch for fixes, minor for features, major for breaking changes), and write a short summary aimed at users. Commit the generated file under `.changeset/`. Docs-only or internal changes that touch no published package do not need one. + +## Releasing + +This section covers what happens after a PR with a changeset merges. Contributors don't need it, but maintainers do. + +Merging a changeset into `main` queues or updates the "Version Packages" PR, opened automatically by the release workflow (`.github/workflows/release.yml`). Merging that PR triggers the release job, which stages every changed package with `pnpm stage publish` (npm's staged publishing). A staged package is not installable yet. Nothing becomes public until a maintainer approves it. + +To approve a release: + +1. A maintainer with npm publish access and two-factor authentication runs `npm stage approve` (or approves from npmjs.com) for each staged package. +2. The same maintainer approves the `promote` job's environment review on the workflow run in the Actions tab. +3. The `promote` job then verifies the versions are actually live on npm, tags the released versions, and creates a GitHub Release, and only then dispatches the content refresh to [kubb-labs/platform](https://github.com/kubb-labs/platform). + +Packages in this repo version and changelog independently (see the empty `fixed` and `linked` groups in `.changeset/config.json`), so a release here creates one GitHub Release per staged package, tagged `@`, with notes taken from that package's own `CHANGELOG.md`. This differs from [kubb-labs/kubb](https://github.com/kubb-labs/kubb), where every package shares one fixed version and a release covers all of them combined. `scripts/createReleases.mjs` is shared verbatim between the two repos; the `RELEASE_MODE` environment variable picks the mode, and this repo simply never sets it to `combined`. + +If a staged version turns out to be wrong, reject it with `npm stage reject` instead of approving it. Nothing downstream fires for a rejected version. + +Reviewers for the `promote` job's environment are managed on GitHub, under the repository's Settings > Environments > `npm-release-approval` (this is separate from npm's own settings on npmjs.com). + +Canary releases are the one exception to this flow. Every push to `main` stamps each package to its own unique `-canary.` version and publishes it under the `canary` dist-tag directly, without staging, so canary installs stay immediate and automatic. See the comment above the `Publish canary` step in `release.yml` for why this is safe to leave unstaged. diff --git a/internals/changelog/format.mjs b/internals/changelog/format.mjs new file mode 100644 index 000000000..5e17fc5cf --- /dev/null +++ b/internals/changelog/format.mjs @@ -0,0 +1,20 @@ +// The heading grammar shared by the changelog writer (index.mjs) and anything +// that reads CHANGELOG.md back out (scripts/createReleases.mjs). Kept in one +// place so the two can't drift into two independently-escaped, independently +// anchored regexes for the same headings. + +function escapeForRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +// The lookahead after the version rejects a longer, hyphen-suffixed version +// sharing the same numeric prefix (e.g. searching for "5.0.0" must not match +// a "## v5.0.0-rc.1" heading), while still matching the exact version +// followed by whitespace, a separator, or end of line. +export function versionHeadingPattern(version) { + return new RegExp(`^##\\s+v?${escapeForRegExp(version)}(?![\\w.-]).*$`, 'm') +} + +export function packageHeadingPattern(name) { + return new RegExp(`^###\\s+${escapeForRegExp(name)}\\s*$`, 'm') +} diff --git a/internals/changelog/format.test.mjs b/internals/changelog/format.test.mjs new file mode 100644 index 000000000..cecb12006 --- /dev/null +++ b/internals/changelog/format.test.mjs @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { packageHeadingPattern, versionHeadingPattern } from './format.mjs' + +describe('versionHeadingPattern', () => { + it('matches the exact version heading', () => { + expect(versionHeadingPattern('5.0.0-beta.99').test('## 5.0.0-beta.99')).toBe(true) + }) + + it('does not match a longer version sharing the same numeric prefix', () => { + expect(versionHeadingPattern('5.0.0').test('## v5.0.0-rc.1')).toBe(false) + }) + + it('matches a bare version with no prerelease suffix', () => { + expect(versionHeadingPattern('5.0.0').test('## 5.0.0')).toBe(true) + }) +}) + +describe('packageHeadingPattern', () => { + it('matches an exact package heading', () => { + expect(packageHeadingPattern('@kubb/plugin-axios').test('### @kubb/plugin-axios')).toBe(true) + }) + + it('does not match a different package heading with a shared prefix', () => { + expect(packageHeadingPattern('@kubb/plugin-ts').test('### @kubb/plugin-ts-extra')).toBe(false) + }) +}) diff --git a/package.json b/package.json index 873e2b028..889ea90d2 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,6 @@ "lint": "oxlint -c oxlint.config.ts ./packages ./examples", "lint:fix": "oxlint -c oxlint.config.ts --fix ./packages ./examples", "postgenerate": "pnpm run lint:fix && pnpm run format", - "release:stage": "turbo run release:stage --filter=./packages/* --continue --concurrency=1 --", - "release:stage:ci": "pnpm release:stage && pnpm exec changeset tag && echo \"staged=true\" >> \"${GITHUB_OUTPUT:-/dev/null}\"", "start": "turbo run start --filter=./packages/*", "test": "vitest run --config ./configs/vitest.config.ts", "test:bench": "vitest bench --config ./configs/vitest.bench.config.ts", diff --git a/packages/plugin-axios/package.json b/packages/plugin-axios/package.json index c3a282f4e..789152beb 100644 --- a/packages/plugin-axios/package.json +++ b/packages/plugin-axios/package.json @@ -53,9 +53,6 @@ "clean": "node -e \"require('node:fs').rmSync('./dist', {recursive:true,force:true})\"", "lint": "oxlint .", "lint:fix": "oxlint --fix .", - "release": "pnpm publish --no-git-check", - "release:canary": "bash ../../.github/canary.sh && node ../../scripts/build.js canary && pnpm publish --no-git-check", - "release:stage": "pnpm stage publish --no-git-check", "start": "tsdown --watch", "test": "vitest --passWithNoTests", "typecheck": "tsc -p ./tsconfig.json --noEmit --emitDeclarationOnly false" diff --git a/packages/plugin-cypress/package.json b/packages/plugin-cypress/package.json index 59d12d834..47336406a 100644 --- a/packages/plugin-cypress/package.json +++ b/packages/plugin-cypress/package.json @@ -48,9 +48,6 @@ "clean": "node -e \"require('node:fs').rmSync('./dist', {recursive:true,force:true})\"", "lint": "oxlint .", "lint:fix": "oxlint --fix .", - "release": "pnpm publish --no-git-check", - "release:canary": "bash ../../.github/canary.sh && node ../../scripts/build.js canary && pnpm publish --no-git-check", - "release:stage": "pnpm stage publish --no-git-check", "start": "tsdown --watch", "test": "vitest --passWithNoTests", "typecheck": "tsc -p ./tsconfig.json --noEmit --emitDeclarationOnly false" diff --git a/packages/plugin-faker/package.json b/packages/plugin-faker/package.json index 107a86a7c..82606050c 100644 --- a/packages/plugin-faker/package.json +++ b/packages/plugin-faker/package.json @@ -59,9 +59,6 @@ "clean": "node -e \"require('node:fs').rmSync('./dist', {recursive:true,force:true})\"", "lint": "oxlint .", "lint:fix": "oxlint --fix .", - "release": "pnpm publish --no-git-check", - "release:canary": "bash ../../.github/canary.sh && node ../../scripts/build.js canary && pnpm publish --no-git-check", - "release:stage": "pnpm stage publish --no-git-check", "start": "tsdown --watch", "test": "vitest --passWithNoTests", "typecheck": "tsc -p ./tsconfig.json --noEmit --emitDeclarationOnly false" diff --git a/packages/plugin-fetch/package.json b/packages/plugin-fetch/package.json index 42d0dd149..909829685 100644 --- a/packages/plugin-fetch/package.json +++ b/packages/plugin-fetch/package.json @@ -53,9 +53,6 @@ "clean": "node -e \"require('node:fs').rmSync('./dist', {recursive:true,force:true})\"", "lint": "oxlint .", "lint:fix": "oxlint --fix .", - "release": "pnpm publish --no-git-check", - "release:canary": "bash ../../.github/canary.sh && node ../../scripts/build.js canary && pnpm publish --no-git-check", - "release:stage": "pnpm stage publish --no-git-check", "start": "tsdown --watch", "test": "vitest --passWithNoTests", "typecheck": "tsc -p ./tsconfig.json --noEmit --emitDeclarationOnly false" diff --git a/packages/plugin-mcp/package.json b/packages/plugin-mcp/package.json index 47ab3a3de..a5c89bebd 100644 --- a/packages/plugin-mcp/package.json +++ b/packages/plugin-mcp/package.json @@ -48,9 +48,6 @@ "clean": "node -e \"require('node:fs').rmSync('./dist', {recursive:true,force:true})\"", "lint": "oxlint .", "lint:fix": "oxlint --fix .", - "release": "pnpm publish --no-git-check", - "release:canary": "bash ../../.github/canary.sh && node ../../scripts/build.js canary && pnpm publish --no-git-check", - "release:stage": "pnpm stage publish --no-git-check", "start": "tsdown --watch", "test": "vitest --passWithNoTests", "typecheck": "tsc -p ./tsconfig.json --noEmit --emitDeclarationOnly false" diff --git a/packages/plugin-msw/package.json b/packages/plugin-msw/package.json index 8bfff92a1..b70507de5 100644 --- a/packages/plugin-msw/package.json +++ b/packages/plugin-msw/package.json @@ -65,9 +65,6 @@ "clean": "node -e \"require('node:fs').rmSync('./dist', {recursive:true,force:true})\"", "lint": "oxlint .", "lint:fix": "oxlint --fix .", - "release": "pnpm publish --no-git-check", - "release:canary": "bash ../../.github/canary.sh && node ../../scripts/build.js canary && pnpm publish --no-git-check", - "release:stage": "pnpm stage publish --no-git-check", "start": "tsdown --watch", "test": "vitest --passWithNoTests", "typecheck": "tsc -p ./tsconfig.json --noEmit --emitDeclarationOnly false" diff --git a/packages/plugin-react-query/package.json b/packages/plugin-react-query/package.json index ac0aa7a56..14eef409d 100644 --- a/packages/plugin-react-query/package.json +++ b/packages/plugin-react-query/package.json @@ -61,9 +61,6 @@ "clean": "node -e \"require('node:fs').rmSync('./dist', {recursive:true,force:true})\"", "lint": "oxlint .", "lint:fix": "oxlint --fix .", - "release": "pnpm publish --no-git-check", - "release:canary": "bash ../../.github/canary.sh && node ../../scripts/build.js canary && pnpm publish --no-git-check", - "release:stage": "pnpm stage publish --no-git-check", "start": "tsdown --watch", "test": "vitest --passWithNoTests", "typecheck": "tsc -p ./tsconfig.json --noEmit --emitDeclarationOnly false" diff --git a/packages/plugin-redoc/package.json b/packages/plugin-redoc/package.json index 78d89aca5..fc7f339cb 100644 --- a/packages/plugin-redoc/package.json +++ b/packages/plugin-redoc/package.json @@ -50,9 +50,6 @@ "clean": "node -e \"require('node:fs').rmSync('./dist', {recursive:true,force:true})\"", "lint": "oxlint .", "lint:fix": "oxlint --fix .", - "release": "pnpm publish --no-git-check", - "release:canary": "bash ../../.github/canary.sh && node ../../scripts/build.js canary && pnpm publish --no-git-check", - "release:stage": "pnpm stage publish --no-git-check", "start": "tsdown --watch", "test": "vitest --passWithNoTests", "typecheck": "tsc -p ./tsconfig.json --noEmit --emitDeclarationOnly false" diff --git a/packages/plugin-swr/package.json b/packages/plugin-swr/package.json index ab8e7ec44..ac36fa826 100644 --- a/packages/plugin-swr/package.json +++ b/packages/plugin-swr/package.json @@ -59,9 +59,6 @@ "clean": "node -e \"require('node:fs').rmSync('./dist', {recursive:true,force:true})\"", "lint": "oxlint .", "lint:fix": "oxlint --fix .", - "release": "pnpm publish --no-git-check", - "release:canary": "bash ../../.github/canary.sh && node ../../scripts/build.js canary && pnpm publish --no-git-check", - "release:stage": "pnpm stage publish --no-git-check", "start": "tsdown --watch", "test": "vitest --passWithNoTests", "typecheck": "tsc -p ./tsconfig.json --noEmit --emitDeclarationOnly false" diff --git a/packages/plugin-ts/package.json b/packages/plugin-ts/package.json index 1a5abbf8b..71cf971cf 100644 --- a/packages/plugin-ts/package.json +++ b/packages/plugin-ts/package.json @@ -47,9 +47,6 @@ "clean": "node -e \"require('node:fs').rmSync('./dist', {recursive:true,force:true})\"", "lint": "oxlint .", "lint:fix": "oxlint --fix .", - "release": "pnpm publish --no-git-check", - "release:canary": "bash ../../.github/canary.sh && node ../../scripts/build.js canary && pnpm publish --no-git-check", - "release:stage": "pnpm stage publish --no-git-check", "start": "tsdown --watch", "test": "vitest --passWithNoTests", "typecheck": "tsc -p ./tsconfig.json --noEmit --emitDeclarationOnly false" diff --git a/packages/plugin-vue-query/package.json b/packages/plugin-vue-query/package.json index 15dc0fd3e..2bf41067c 100644 --- a/packages/plugin-vue-query/package.json +++ b/packages/plugin-vue-query/package.json @@ -63,9 +63,6 @@ "clean": "node -e \"require('node:fs').rmSync('./dist', {recursive:true,force:true})\"", "lint": "oxlint .", "lint:fix": "oxlint --fix .", - "release": "pnpm publish --no-git-check", - "release:canary": "bash ../../.github/canary.sh && node ../../scripts/build.js canary && pnpm publish --no-git-check", - "release:stage": "pnpm stage publish --no-git-check", "start": "tsdown --watch", "test": "vitest --passWithNoTests", "typecheck": "tsc -p ./tsconfig.json --noEmit --emitDeclarationOnly false" diff --git a/packages/plugin-zod/package.json b/packages/plugin-zod/package.json index 5d2b83a57..ea306482f 100644 --- a/packages/plugin-zod/package.json +++ b/packages/plugin-zod/package.json @@ -49,9 +49,6 @@ "clean": "node -e \"require('node:fs').rmSync('./dist', {recursive:true,force:true})\"", "lint": "oxlint .", "lint:fix": "oxlint --fix .", - "release": "pnpm publish --no-git-check", - "release:canary": "bash ../../.github/canary.sh && node ../../scripts/build.js canary && pnpm publish --no-git-check", - "release:stage": "pnpm stage publish --no-git-check", "start": "tsdown --watch", "test": "vitest --passWithNoTests", "typecheck": "tsc -p ./tsconfig.json --noEmit --emitDeclarationOnly false" diff --git a/scripts/createReleases.mjs b/scripts/createReleases.mjs new file mode 100644 index 000000000..e6ce05b99 --- /dev/null +++ b/scripts/createReleases.mjs @@ -0,0 +1,111 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { packageHeadingPattern, versionHeadingPattern } from '../internals/changelog/format.mjs' + +// The custom changelog hook (internals/changelog/index.mjs) writes one root +// CHANGELOG.md with a `## v` heading per release and a `### ` +// subsection per package inside it. This finds that whole version block, the +// shared starting point for both a combined, whole-version release and a +// single package's own subsection within it. +export function extractVersionNotes({ changelog, version }) { + const versionMatch = versionHeadingPattern(version).exec(changelog) + if (!versionMatch) return null + + const afterVersion = changelog.slice(versionMatch.index + versionMatch[0].length) + const nextVersionHeading = /^##\s+/m.exec(afterVersion) + return (nextVersionHeading ? afterVersion.slice(0, nextVersionHeading.index) : afterVersion).trim() +} + +// Reproduces what changesets/action would normally show for one package, +// sourced from this repo's aggregated CHANGELOG.md instead of a per-package +// changelog. +export function extractPackageNotes({ changelog, name, version }) { + const versionBlock = extractVersionNotes({ changelog, version }) + if (versionBlock === null) return null + + const packageMatch = packageHeadingPattern(name).exec(versionBlock) + if (!packageMatch) return null + + const afterPackage = versionBlock.slice(packageMatch.index + packageMatch[0].length) + const nextHeading = /^#{2,3}\s+/m.exec(afterPackage) + const packageBlock = nextHeading ? afterPackage.slice(0, nextHeading.index) : afterPackage + + return packageBlock.trim() || null +} + +function releaseExists(tag) { + return spawnSync('gh', ['release', 'view', tag], { encoding: 'utf8' }).status === 0 +} + +// A re-run of `promote` (e.g. a manual `promote: true` dispatch against a +// version that was already tagged and released) would otherwise fail here: +// `gh release create` errors on a tag that already has a release. Skipping +// cleanly makes promote safe to re-run instead of going red on a re-dispatch. +function createRelease({ tag, title, notes }) { + if (releaseExists(tag)) { + console.log(`Release ${tag} already exists, skipping.`) + return + } + + const result = spawnSync('gh', ['release', 'create', tag, '--title', title, '--notes', notes], { stdio: 'inherit' }) + if (result.status !== 0) process.exit(result.status ?? 1) +} + +function getWorkspacePaths() { + const result = spawnSync('pnpm', ['ls', '-r', '--json', '--depth', '0'], { encoding: 'utf8' }) + if (result.status !== 0) return {} + try { + return Object.fromEntries(JSON.parse(result.stdout).map((pkg) => [pkg.name, pkg.path])) + } catch { + return {} + } +} + +function createPerPackageReleases({ staged, repo }) { + const pkgPaths = getWorkspacePaths() + for (const pkg of staged) { + let notes = null + const pkgPath = pkgPaths[pkg.name] + if (pkgPath) { + try { + notes = extractVersionNotes({ changelog: readFileSync(`${pkgPath}/CHANGELOG.md`, 'utf8'), version: pkg.version }) + } catch {} + } + notes ??= `Dependency update only, no direct changes for this package. See [CHANGELOG.md](https://github.com/${repo}/blob/main/CHANGELOG.md) for the full release notes.` + createRelease({ tag: `${pkg.name}@${pkg.version}`, title: `${pkg.name}@${pkg.version}`, notes }) + } +} + +// One release for the whole version, tagged with the flagship package's own +// tag (already created by the `changeset tag` step) rather than inventing a +// second, parallel tagging scheme. +function createCombinedRelease({ staged, changelog, repo }) { + const flagship = staged.find((pkg) => pkg.name === 'kubb') ?? staged[0] + if (!flagship) return + + const notes = + extractVersionNotes({ changelog, version: flagship.version }) ?? + `Dependency update only, no direct changes for this package. See [CHANGELOG.md](https://github.com/${repo}/blob/main/CHANGELOG.md) for the full release notes.` + createRelease({ tag: `${flagship.name}@${flagship.version}`, title: `v${flagship.version}`, notes }) +} + +function main() { + const staged = JSON.parse(process.env.STAGED_PACKAGES || '[]') + if (staged.length === 0) { + console.error('STAGED_PACKAGES is empty. The promote job only runs when release staged something, so this should never happen.') + process.exit(1) + } + + const repo = process.env.GITHUB_REPOSITORY + + if (process.env.RELEASE_MODE === 'combined') { + createCombinedRelease({ staged, changelog: readFileSync('CHANGELOG.md', 'utf8'), repo }) + } else { + createPerPackageReleases({ staged, repo }) + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main() +} diff --git a/scripts/createReleases.test.mjs b/scripts/createReleases.test.mjs new file mode 100644 index 000000000..d27584571 --- /dev/null +++ b/scripts/createReleases.test.mjs @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { extractPackageNotes, extractVersionNotes } from './createReleases.mjs' + +const changelog = `# Changelog + +## v5.0.0-beta.80 — Jul 2, 2026 + +### @kubb/ast + +#### Features + +- Let a printer node override reuse the handler it replaces. + +### @kubb/parser-md + +#### Bug Fixes + +- Correct the \`parserMd\` JSDoc. + +### Contributors + +Thanks to everyone who contributed to this release: + +[@stijnvanhulle](https://github.com/stijnvanhulle) + +## v5.0.0-beta.79 — Jun 30, 2026 + +### @kubb/cli + +#### Bug Fixes + +- Cleanup defineLogger +` + +describe('extractPackageNotes', () => { + it('extracts a package section from its version block', () => { + expect(extractPackageNotes({ changelog, name: '@kubb/ast', version: '5.0.0-beta.80' })).toBe( + '#### Features\n\n- Let a printer node override reuse the handler it replaces.', + ) + }) + + it('stops at the next package heading in the same version block', () => { + expect(extractPackageNotes({ changelog, name: '@kubb/parser-md', version: '5.0.0-beta.80' })).toBe('#### Bug Fixes\n\n- Correct the `parserMd` JSDoc.') + }) + + it('does not reach into an older version block for the same package', () => { + expect(extractPackageNotes({ changelog, name: '@kubb/cli', version: '5.0.0-beta.80' })).toBeNull() + }) + + it('finds a package in an older version block by its own version', () => { + expect(extractPackageNotes({ changelog, name: '@kubb/cli', version: '5.0.0-beta.79' })).toBe('#### Bug Fixes\n\n- Cleanup defineLogger') + }) + + it('returns null when the version heading does not exist', () => { + expect(extractPackageNotes({ changelog, name: '@kubb/ast', version: '9.9.9' })).toBeNull() + }) + + it('returns null when the package has no section in that version', () => { + expect(extractPackageNotes({ changelog, name: '@kubb/core', version: '5.0.0-beta.80' })).toBeNull() + }) + + it('does not match a longer prerelease version sharing the same numeric prefix', () => { + const withPrerelease = `# Changelog\n\n## v5.0.0-rc.1 — Jul 3, 2026\n\n### @kubb/ast\n\n#### Features\n\n- Something.\n` + expect(extractPackageNotes({ changelog: withPrerelease, name: '@kubb/ast', version: '5.0.0' })).toBeNull() + }) +}) + +describe('extractVersionNotes', () => { + it('returns the whole version block, covering every package in it', () => { + const notes = extractVersionNotes({ changelog, version: '5.0.0-beta.80' }) + + expect(notes).toContain('### @kubb/ast') + expect(notes).toContain('### @kubb/parser-md') + expect(notes).toContain('### Contributors') + expect(notes).not.toContain('v5.0.0-beta.79') + }) + + it('returns null when the version heading does not exist', () => { + expect(extractVersionNotes({ changelog, version: '9.9.9' })).toBeNull() + }) +}) diff --git a/scripts/release.mjs b/scripts/release.mjs new file mode 100644 index 000000000..d39ba56fa --- /dev/null +++ b/scripts/release.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process' +import { appendFileSync, readFileSync } from 'node:fs' + +function readPreTag() { + try { + return JSON.parse(readFileSync('.changeset/pre.json', 'utf8')).tag ?? null + } catch { + return null + } +} + +// Parse pnpm stage publish output into [{ name, version }]. Prefers the --json +// payload, in whichever shape it comes in (a bare array, or an object keyed by +// package name, both observed across pnpm/npm staged-publish responses), then +// falls back to scanning text output for `+ @` lines. +export function parseStaged(output) { + try { + const parsed = JSON.parse(output) + const entries = Array.isArray(parsed) + ? parsed + : parsed && typeof parsed === 'object' + ? Object.entries(parsed).map(([key, value]) => ({ name: value?.name ?? value?.packageName ?? key, version: value?.version })) + : null + + if (entries) { + return entries.filter((entry) => entry?.name && entry?.version).map((entry) => ({ name: entry.name, version: entry.version })) + } + } catch {} + + const found = [] + for (const line of output.split('\n')) { + const match = line.match(/^\+\s+(@?[^\s@]+)@(\S+)/) + if (match) found.push({ name: match[1], version: match[2] }) + } + return found +} + +function main() { + const tag = readPreTag() + const stageArgs = ['stage', 'publish', '-r', '--no-git-check', '--access', 'public', '--json'] + if (tag) stageArgs.push('--tag', tag) + + const result = spawnSync('pnpm', stageArgs, { encoding: 'utf8' }) + if (result.stdout) process.stdout.write(result.stdout) + if (result.stderr) process.stderr.write(result.stderr) + if (result.status !== 0) process.exit(result.status ?? 1) + const output = result.stdout ?? '' + const staged = parseStaged(output) + + // Tags aren't created here. A staged package may still be rejected on npm, + // and changesets/changesets#2025 specifically warns against tagging at + // stage time for that reason. The `promote` job creates and pushes tags + // itself, after confirming the versions are actually live. + // + // Only signal a stage to the workflow if pnpm actually published something. + // When everything was skipped (versions already on npm), let the canary step + // fire instead — that's the intent for ordinary main pushes. + if (staged.length > 0 && process.env.GITHUB_OUTPUT) { + appendFileSync(process.env.GITHUB_OUTPUT, 'staged=true\n') + appendFileSync(process.env.GITHUB_OUTPUT, `staged_packages=${JSON.stringify(staged)}\n`) + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main() +} diff --git a/scripts/release.test.mjs b/scripts/release.test.mjs new file mode 100644 index 000000000..68dc7f906 --- /dev/null +++ b/scripts/release.test.mjs @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { parseStaged } from './release.mjs' + +describe('parseStaged', () => { + it('returns name/version pairs from a JSON array payload', () => { + const output = JSON.stringify([ + { name: '@kubb/plugin-axios', version: '5.0.0-beta.99' }, + { name: '@kubb/plugin-ts', version: '5.0.0-beta.99' }, + ]) + + expect(parseStaged(output)).toStrictEqual([ + { name: '@kubb/plugin-axios', version: '5.0.0-beta.99' }, + { name: '@kubb/plugin-ts', version: '5.0.0-beta.99' }, + ]) + }) + + it('drops JSON entries missing a name or version', () => { + const output = JSON.stringify([{ name: '@kubb/plugin-axios', version: '5.0.0-beta.99' }, { name: '@kubb/plugin-ts' }, { version: '1.0.0' }]) + + expect(parseStaged(output)).toStrictEqual([{ name: '@kubb/plugin-axios', version: '5.0.0-beta.99' }]) + }) + + it('returns name/version pairs from a JSON object keyed by package name', () => { + const output = JSON.stringify({ + '@kubb/plugin-axios': { name: '@kubb/plugin-axios', version: '5.0.0-beta.99' }, + '@kubb/plugin-ts': { name: '@kubb/plugin-ts', version: '5.0.0-beta.99' }, + }) + + expect(parseStaged(output)).toStrictEqual([ + { name: '@kubb/plugin-axios', version: '5.0.0-beta.99' }, + { name: '@kubb/plugin-ts', version: '5.0.0-beta.99' }, + ]) + }) + + it('falls back to the object key as the name when an entry has neither a name nor a packageName field', () => { + const output = JSON.stringify({ '@kubb/plugin-axios': { version: '5.0.0-beta.99' } }) + + expect(parseStaged(output)).toStrictEqual([{ name: '@kubb/plugin-axios', version: '5.0.0-beta.99' }]) + }) + + it('falls back to scanning `+ @` lines when the payload is not JSON', () => { + const output = ['Packages: +2', '+ @kubb/plugin-axios@5.0.0-beta.99', '+ @kubb/plugin-ts@5.0.0-beta.99', 'Done'].join('\n') + + expect(parseStaged(output)).toStrictEqual([ + { name: '@kubb/plugin-axios', version: '5.0.0-beta.99' }, + { name: '@kubb/plugin-ts', version: '5.0.0-beta.99' }, + ]) + }) + + it('returns an empty array when nothing was staged', () => { + expect(parseStaged('No packages to publish')).toStrictEqual([]) + }) +}) diff --git a/scripts/verifyRegistry.mjs b/scripts/verifyRegistry.mjs new file mode 100644 index 000000000..ff97c1c33 --- /dev/null +++ b/scripts/verifyRegistry.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process' +import { appendFileSync } from 'node:fs' + +const RETRY_ATTEMPTS = Number(process.env.VERIFY_RETRY_ATTEMPTS) || 6 +const RETRY_DELAY_MS = (Number(process.env.VERIFY_INTERVAL_SECONDS) || 10) * 1_000 + +function isLiveOnRegistry(pkg) { + const result = spawnSync('npm', ['view', `${pkg.name}@${pkg.version}`, '--json'], { encoding: 'utf8' }) + if (result.status !== 0) return false + + try { + const parsed = JSON.parse(result.stdout) + return Boolean(parsed?.version) + } catch { + return false + } +} + +// A green environment review only means a maintainer clicked approve, not +// that they actually ran `npm stage approve`. Retrying absorbs registry +// propagation lag right after a real approval; giving up after all attempts +// means the version genuinely isn't live yet. +export async function verifyPackage({ pkg, attempts = RETRY_ATTEMPTS, delayMs = RETRY_DELAY_MS }) { + for (let attempt = 1; attempt <= attempts; attempt++) { + if (isLiveOnRegistry(pkg)) return true + + if (attempt < attempts) { + process.stdout.write(`${pkg.name}@${pkg.version} not live yet (attempt ${attempt}/${attempts}), retrying in ${delayMs / 1_000}s ...\n`) + await new Promise((r) => setTimeout(r, delayMs)) + } + } + + return false +} + +async function main() { + const staged = JSON.parse(process.env.STAGED_PACKAGES || '[]') + if (staged.length === 0) { + console.error('STAGED_PACKAGES is empty. The promote job only runs when release staged something, so this should never happen.') + process.exit(1) + } + + // Each package's retries run independently, so verifying them concurrently + // instead of one after another keeps the total wait close to the slowest + // single package's retry budget, not the sum across every staged package. + const results = await Promise.all( + staged.map(async (pkg) => { + process.stdout.write(`Checking ${pkg.name}@${pkg.version} ...\n`) + const live = await verifyPackage({ pkg }) + return { pkg, live } + }), + ) + + const missing = results.filter((result) => !result.live) + if (missing.length > 0) { + const waitedSeconds = ((RETRY_ATTEMPTS - 1) * RETRY_DELAY_MS) / 1_000 + for (const { pkg } of missing) { + console.error( + `${pkg.name}@${pkg.version} isn't live on npm yet after ${RETRY_ATTEMPTS} attempts over ${waitedSeconds}s. Confirm npm stage approve was run for this exact version, or that the staged version hasn't expired.`, + ) + } + process.exit(1) + } + + if (process.env.GITHUB_OUTPUT) { + appendFileSync(process.env.GITHUB_OUTPUT, 'approved=true\n') + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + await main() +} diff --git a/scripts/verifyRegistry.test.mjs b/scripts/verifyRegistry.test.mjs new file mode 100644 index 000000000..af83979b1 --- /dev/null +++ b/scripts/verifyRegistry.test.mjs @@ -0,0 +1,44 @@ +import { spawnSync } from 'node:child_process' +import { describe, expect, it, vi } from 'vitest' +import { verifyPackage } from './verifyRegistry.mjs' + +// node:child_process is a built-in with non-configurable exports, so vi.spyOn +// can't patch it directly in real ESM. vi.mock is the only way to replace it; +// each test still owns its own return-value sequence via mockReturnValueOnce, +// so there's no shared state or beforeEach reset needed between tests. +vi.mock('node:child_process', () => ({ spawnSync: vi.fn() })) + +const pkg = { name: '@kubb/plugin-axios', version: '5.0.0-beta.99' } + +function mockView(results) { + const mocked = vi.mocked(spawnSync) + mocked.mockClear() + for (const result of results) mocked.mockReturnValueOnce(result) + return mocked +} + +const live = { status: 0, stdout: JSON.stringify({ version: pkg.version }) } +const missing = { status: 1, stdout: '' } + +describe('verifyPackage', () => { + it('returns true when the version is live on the first attempt', async () => { + const mocked = mockView([live]) + + await expect(verifyPackage({ pkg, attempts: 3, delayMs: 0 })).resolves.toBe(true) + expect(mocked).toHaveBeenCalledTimes(1) + }) + + it('retries until the version shows up on the registry', async () => { + const mocked = mockView([missing, missing, live]) + + await expect(verifyPackage({ pkg, attempts: 5, delayMs: 0 })).resolves.toBe(true) + expect(mocked).toHaveBeenCalledTimes(3) + }) + + it('returns false after exhausting every attempt', async () => { + const mocked = mockView([missing, missing]) + + await expect(verifyPackage({ pkg, attempts: 2, delayMs: 0 })).resolves.toBe(false) + expect(mocked).toHaveBeenCalledTimes(2) + }) +}) diff --git a/turbo.json b/turbo.json index 1407e63b2..1793b6b31 100644 --- a/turbo.json +++ b/turbo.json @@ -42,25 +42,11 @@ "outputs": [], "cache": false }, - "release": { - "dependsOn": ["build"], - "outputs": [] - }, "generate": { "dependsOn": [], "outputs": ["src/gen/**"], "inputs": ["kubb.config.ts", "package.json"], "cache": false - }, - "release:canary": { - "dependsOn": ["build"], - "outputs": [] - }, - "release:stage": { - "dependsOn": ["build"], - "outputs": [], - "cache": false, - "passThroughEnv": ["ACTIONS_ID_TOKEN_REQUEST_URL", "ACTIONS_ID_TOKEN_REQUEST_TOKEN", "NPM_CONFIG_PROVENANCE", "GITHUB_*", "RUNNER_*", "SIGSTORE_*"] } } }