From 5767469c6572e1fa0565c0c579a05c2e9a55c2fd Mon Sep 17 00:00:00 2001 From: PIERLUIGI VITI Date: Fri, 2 Oct 2026 11:57:10 +0200 Subject: [PATCH] chore: move the release scripts to a cl-release workspace package The release scripts become @commercelayer/cli-release (packages/release, private): TypeScript run through tsx, like cl-generate, one module per command, unit tests for the pure parts. The root scripts and the workflows call `cl-release `; the outputs are unchanged. The test helpers move to packages/test-utils: oclif-root.cjs (mocha setup) and bin/live-sweep.mjs. check-packages stays in scripts/, self-contained. Co-Authored-By: Claude Opus 5.5 --- .github/labeler.yml | 2 +- .github/workflows/integration.yml | 4 +- .github/workflows/publish.yml | 10 +- .github/workflows/release.yml | 12 +- .github/workflows/verify.yml | 4 +- README.md | 2 +- commitlint.config.mjs | 2 +- package.json | 19 +- packages/cli/.mocharc.json | 2 +- packages/dev/.mocharc.json | 2 +- packages/release/.mocharc.json | 3 + packages/release/README.md | 18 ++ packages/release/bin/cl-release.mjs | 6 + packages/release/package.json | 37 +++ packages/release/src/args.ts | 19 ++ packages/release/src/cli.ts | 46 ++++ packages/release/src/commands/labels.ts | 42 ++++ packages/release/src/commands/manifest.ts | 101 ++++++++ packages/release/src/commands/notes.ts | 46 ++++ packages/release/src/commands/publish.ts | 103 ++++++++ packages/release/src/commands/resolve.ts | 29 +++ packages/release/src/commands/tag.ts | 56 +++++ packages/release/src/commands/try.ts | 111 +++++++++ packages/release/src/commands/version.ts | 219 ++++++++++++++++++ packages/release/src/git-semver-tags.d.ts | 4 + packages/release/src/workspace.ts | 150 ++++++++++++ packages/release/test/commands.test.ts | 92 ++++++++ packages/release/test/helpers.ts | 8 + packages/release/test/workspace.test.ts | 56 +++++ packages/release/tsconfig.json | 14 ++ .../test-utils/bin}/live-sweep.mjs | 2 +- .../test-utils}/oclif-root.cjs | 0 packages/test-utils/src/live.ts | 2 +- plugins/checkout/.mocharc.json | 2 +- plugins/cleanups/.mocharc.json | 2 +- plugins/exports/.mocharc.json | 2 +- plugins/imports/.mocharc.json | 2 +- plugins/links/.mocharc.json | 2 +- plugins/metrics/.mocharc.json | 2 +- plugins/microstore/.mocharc.json | 2 +- plugins/orders/.mocharc.json | 2 +- plugins/provisioning/.mocharc.json | 2 +- plugins/resources/.mocharc.json | 2 +- plugins/seeder/.mocharc.json | 2 +- plugins/tags/.mocharc.json | 2 +- plugins/token/.mocharc.json | 2 +- plugins/triggers/.mocharc.json | 2 +- plugins/webhooks/.mocharc.json | 2 +- pnpm-lock.yaml | 75 ++++-- scripts/check-manifest.mjs | 95 -------- scripts/check-packages.mjs | 12 +- scripts/finish-version.mjs | 209 ----------------- scripts/gen-release-config.mjs | 47 ---- scripts/lib/workspace.mjs | 93 -------- scripts/publish.mjs | 93 -------- scripts/release-notes.mjs | 41 ---- scripts/release-try.mjs | 107 --------- scripts/resolve-tag.mjs | 36 --- scripts/tag.mjs | 68 ------ 59 files changed, 1270 insertions(+), 859 deletions(-) create mode 100644 packages/release/.mocharc.json create mode 100644 packages/release/README.md create mode 100755 packages/release/bin/cl-release.mjs create mode 100644 packages/release/package.json create mode 100644 packages/release/src/args.ts create mode 100644 packages/release/src/cli.ts create mode 100644 packages/release/src/commands/labels.ts create mode 100644 packages/release/src/commands/manifest.ts create mode 100644 packages/release/src/commands/notes.ts create mode 100644 packages/release/src/commands/publish.ts create mode 100644 packages/release/src/commands/resolve.ts create mode 100644 packages/release/src/commands/tag.ts create mode 100644 packages/release/src/commands/try.ts create mode 100644 packages/release/src/commands/version.ts create mode 100644 packages/release/src/git-semver-tags.d.ts create mode 100644 packages/release/src/workspace.ts create mode 100644 packages/release/test/commands.test.ts create mode 100644 packages/release/test/helpers.ts create mode 100644 packages/release/test/workspace.test.ts create mode 100644 packages/release/tsconfig.json rename {scripts/test => packages/test-utils/bin}/live-sweep.mjs (97%) rename {scripts/test => packages/test-utils}/oclif-root.cjs (100%) delete mode 100644 scripts/check-manifest.mjs delete mode 100644 scripts/finish-version.mjs delete mode 100644 scripts/gen-release-config.mjs delete mode 100644 scripts/lib/workspace.mjs delete mode 100644 scripts/publish.mjs delete mode 100644 scripts/release-notes.mjs delete mode 100644 scripts/release-try.mjs delete mode 100644 scripts/resolve-tag.mjs delete mode 100644 scripts/tag.mjs diff --git a/.github/labeler.yml b/.github/labeler.yml index 7dcb82033..65ab53557 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,4 +1,4 @@ -# Generated by scripts/gen-release-config.mjs — do not edit by hand. +# Generated by `pnpm release:config` (cl-release labels) — do not edit by hand. 'pkg:checkout': - changed-files: diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 504381c24..4ddf93e1b 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -52,7 +52,7 @@ jobs: CL_CLI_CLIENT_SECRET: ${{ secrets.CL_CLI_CLIENT_SECRET }} # Optional: the checkout and links suites are skipped without it CL_CLI_SALES_CHANNEL_CLIENT_ID: ${{ secrets.CL_CLI_SALES_CHANNEL_CLIENT_ID }} - run: node scripts/test/live-sweep.mjs + run: node packages/test-utils/bin/live-sweep.mjs - name: Run integration suites env: *credentials @@ -67,4 +67,4 @@ jobs: - name: Remove this run's test resources if: always() env: *credentials - run: node scripts/test/live-sweep.mjs + run: node packages/test-utils/bin/live-sweep.mjs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9df467aaa..675cd2036 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,7 +2,7 @@ name: Publish # Publishes to npm when a human publishes a draft release created by # release.yml, then announces it on Slack. The tagged package is published -# (scripts/publish.mjs), built and tested from exactly the tag, together with +# (`cl-release publish`), built and tested from exactly the tag, together with # the workspace dependencies it needs that aren't on npm yet (cli-core, # cli-ux, … released in the same chore(release) PR); their draft releases # are then marked published too. @@ -44,12 +44,12 @@ jobs: node-version: 'lts/*' cache: 'pnpm' - # Before resolve-tag, which needs the release scripts' dependencies + # Before resolving the tag: cl-release and its dependencies - run: pnpm install --frozen-lockfile - name: Resolve package id: meta - run: node scripts/resolve-tag.mjs "$TAG" | tee -a "$GITHUB_OUTPUT" + run: pnpm exec cl-release resolve "$TAG" | tee -a "$GITHUB_OUTPUT" - name: Build the package and its workspace dependencies run: pnpm --filter '${{ steps.meta.outputs.package }}...' build @@ -63,11 +63,11 @@ jobs: run: pnpm --filter '${{ steps.meta.outputs.package }}...' --workspace-concurrency=1 test - name: Command surface vs npm - run: node scripts/check-manifest.mjs '${{ steps.meta.outputs.dir }}' + run: pnpm exec cl-release manifest '${{ steps.meta.outputs.dir }}' - name: Publish id: publish - run: node scripts/publish.mjs "$TAG" + run: pnpm exec cl-release publish "$TAG" - name: Publish the draft releases of the dependencies published along if: steps.publish.outputs.published_deps != '' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4fbd20740..36df0e168 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,12 +4,12 @@ name: Release # here: publish.yml publishes once someone publishes the draft. # # Tags are `-v`, where is the package's directory under -# packages/ or plugins/ (scripts/tag.mjs creates them). The package is derived +# packages/ or plugins/ (`pnpm release:tag` creates them). The package is derived # from the tag, so adding a package needs no change here. Legacy bare `v*` # tags don't match `*-v*` and stay inert. # # Release notes come from the package's Conventional Commits since its -# previous release (scripts/release-notes.mjs, conventional-changelog): only +# previous release (`cl-release notes`, conventional-changelog): only # the commits that touched its folder, however long the range. on: push: @@ -32,17 +32,17 @@ jobs: - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: { node-version: 'lts/*', cache: 'pnpm' } - # The release scripts' own dependencies only (the root package) - - run: pnpm install --frozen-lockfile --filter @commercelayer/cli-monorepo + # The release tooling only: the root package and cl-release (packages/release) + - run: pnpm install --frozen-lockfile --filter "@commercelayer/cli-monorepo..." - name: Resolve package id: meta - run: node scripts/resolve-tag.mjs "$GITHUB_REF_NAME" | tee -a "$GITHUB_OUTPUT" + run: pnpm exec cl-release resolve "$GITHUB_REF_NAME" | tee -a "$GITHUB_OUTPUT" - name: Generate release notes for this package run: | set -euo pipefail - node scripts/release-notes.mjs "$GITHUB_REF_NAME" "${{ steps.meta.outputs.previous_tag }}" > release-notes.md + pnpm exec cl-release notes "$GITHUB_REF_NAME" "${{ steps.meta.outputs.previous_tag }}" > release-notes.md cat release-notes.md - name: Create draft release diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 647da81fd..a17a5c044 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -27,7 +27,7 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm lint - name: Release config is up to date - run: node scripts/gen-release-config.mjs --check + run: pnpm exec cl-release labels --check - name: Packages are consistent run: node scripts/check-packages.mjs @@ -109,7 +109,7 @@ jobs: set -euo pipefail for path in $(pnpm --filter '${{ steps.select.outputs.filter }}' --filter '!@commercelayer/cli-monorepo' list --depth -1 --json | node -e 'for (const p of JSON.parse(require("fs").readFileSync(0))) console.log(require("path").relative(process.cwd(), p.path))'); do [ "$path" = "" ] && continue - node scripts/check-manifest.mjs "$(basename "$path")" --warn + pnpm exec cl-release manifest "$(basename "$path")" --warn done generated: diff --git a/README.md b/README.md index 2c3da359b..a71c456cc 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ CL_CLI_ORGANIZATION= CL_CLI_CLIENT_ID= CL_CLI_CLIENT_SECRET Use an integration application of a test organization, never a production one. In CI, [integration.yml](.github/workflows/integration.yml) runs them against `cli-test-org` on pushes to `monorepo` and `main`, every night, on demand, and on pull requests that change the suites or the dependencies. They assert on the shape of the output, not on specific records. -The suites that change data (`*-write.it.ts`) only work on resources they create and delete themselves, whose names, emails and references start with `cli-it-`. `node scripts/test/live-sweep.mjs` (run before and after the suites in CI) deletes whatever an interrupted run left behind, and nothing else. The checkout and links suites also need `CL_CLI_SALES_CHANNEL_CLIENT_ID`, the client ID of a sales channel application of the organization, and are skipped without it. +The suites that change data (`*-write.it.ts`) only work on resources they create and delete themselves, whose names, emails and references start with `cli-it-`. `node packages/test-utils/bin/live-sweep.mjs` (run before and after the suites in CI) deletes whatever an interrupted run left behind, and nothing else. The checkout and links suites also need `CL_CLI_SALES_CHANNEL_CLIENT_ID`, the client ID of a sales channel application of the organization, and are skipped without it. ## Generated code diff --git a/commitlint.config.mjs b/commitlint.config.mjs index 672a93720..062064994 100644 --- a/commitlint.config.mjs +++ b/commitlint.config.mjs @@ -6,7 +6,7 @@ */ export default { extends: ['@commitlint/config-conventional'], - // The release commits are written by scripts/finish-version.mjs and list + // The release commits are written by `pnpm release:version` and list // every released tag in the header, well past the length limit ignores: [(message) => message.startsWith('chore(release): ')], } diff --git a/package.json b/package.json index f75169e8a..ff88a43a4 100644 --- a/package.json +++ b/package.json @@ -14,27 +14,22 @@ "test:integration": "pnpm -r --no-bail --if-present --workspace-concurrency=1 test:integration", "lint": "biome check --enforce-assist=true", "check": "biome check --enforce-assist=true --write", - "release:version": "node scripts/finish-version.mjs", - "release:tag": "node scripts/tag.mjs", - "release:config": "node scripts/gen-release-config.mjs", - "release:publish": "node scripts/publish.mjs", + "release:version": "cl-release version", + "release:tag": "cl-release tag", + "release:config": "cl-release labels", + "release:publish": "cl-release publish", "generate": "pnpm -r --if-present generate", "generate-local": "pnpm -r --if-present generate-local", "check:packages": "node scripts/check-packages.mjs", "lint:commits": "commitlint --from origin/main --to HEAD --verbose", - "release:try": "node scripts/release-try.mjs" + "release:try": "cl-release try" }, "devDependencies": { "@biomejs/biome": "^2.5.14", + "@commercelayer/cli-release": "workspace:*", "@commitlint/cli": "^21.2.3", "@commitlint/config-conventional": "^21.2.3", - "@manypkg/get-packages": "^3.1.0", - "@pnpm/deps.graph-sequencer": "^1101.0.0", - "conventional-changelog": "^8.1.3", - "conventional-changelog-conventionalcommits": "^10.4.0", - "conventional-recommended-bump": "^12.1.0", - "git-semver-tags": "^8.0.1", - "semver": "^7.8.5" + "@manypkg/get-packages": "^3.1.0" }, "engines": { "node": ">=22.13" diff --git a/packages/cli/.mocharc.json b/packages/cli/.mocharc.json index 865d41b6b..01dd933ba 100644 --- a/packages/cli/.mocharc.json +++ b/packages/cli/.mocharc.json @@ -1,7 +1,7 @@ { "import": "tsx", "require": [ - "../../scripts/test/oclif-root.cjs" + "../../packages/test-utils/oclif-root.cjs" ], "timeout": 10000 } diff --git a/packages/dev/.mocharc.json b/packages/dev/.mocharc.json index 865d41b6b..01dd933ba 100644 --- a/packages/dev/.mocharc.json +++ b/packages/dev/.mocharc.json @@ -1,7 +1,7 @@ { "import": "tsx", "require": [ - "../../scripts/test/oclif-root.cjs" + "../../packages/test-utils/oclif-root.cjs" ], "timeout": 10000 } diff --git a/packages/release/.mocharc.json b/packages/release/.mocharc.json new file mode 100644 index 000000000..c05ca9ac1 --- /dev/null +++ b/packages/release/.mocharc.json @@ -0,0 +1,3 @@ +{ + "import": "tsx" +} diff --git a/packages/release/README.md b/packages/release/README.md new file mode 100644 index 000000000..fac2655e2 --- /dev/null +++ b/packages/release/README.md @@ -0,0 +1,18 @@ +# @commercelayer/cli-release + +Private workspace package, not published. The release tooling of the monorepo, run from the root scripts and the workflows. + +```sh +cl-release version [--interactive | --yes] [--base ] [--no-pr] [--dry-run] # pnpm release:version +cl-release tag [--base ] [--no-push] [--dry-run] # pnpm release:tag +cl-release try [...] [--out ] # pnpm release:try +cl-release labels [--check] # pnpm release:config +cl-release resolve # release.yml, publish.yml +cl-release notes [previous-tag] # release.yml +cl-release publish [--dry-run] # publish.yml (pnpm release:publish) +cl-release manifest [--against ] [--warn] # verify.yml, publish.yml +``` + +`cl-release --help` shows the options of a command. It runs at the workspace root wherever it is started from. + +The sources are TypeScript run through tsx (`bin/cl-release.mjs`), with no build step. Each command is a module of `src/commands`; `src/workspace.ts` has the helpers they share (packages, tags, npm). The pure parts are unit tested (`pnpm --filter @commercelayer/cli-release test`). diff --git a/packages/release/bin/cl-release.mjs b/packages/release/bin/cl-release.mjs new file mode 100755 index 000000000..89241c987 --- /dev/null +++ b/packages/release/bin/cl-release.mjs @@ -0,0 +1,6 @@ +#!/usr/bin/env node +// The sources are TypeScript, run through tsx: no build step, as for cl-generate +import { register } from 'tsx/esm/api' + +register() +await import('../src/cli.ts') diff --git a/packages/release/package.json b/packages/release/package.json new file mode 100644 index 000000000..b05b6d16d --- /dev/null +++ b/packages/release/package.json @@ -0,0 +1,37 @@ +{ + "name": "@commercelayer/cli-release", + "version": "0.0.0", + "private": true, + "description": "Release tooling of the Commerce Layer CLI monorepo: versions, tags, release notes, publishing, release sandbox. Private workspace package, not published.", + "license": "MIT", + "type": "module", + "bin": { + "cl-release": "./bin/cl-release.mjs" + }, + "scripts": { + "test": "mocha 'test/**/*.test.ts'", + "ts:check": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@manypkg/get-packages": "^3.1.0", + "@pnpm/deps.graph-sequencer": "^1101.0.0", + "conventional-changelog": "^8.1.3", + "conventional-changelog-conventionalcommits": "^10.4.0", + "conventional-recommended-bump": "^12.1.0", + "git-semver-tags": "^8.0.1", + "semver": "^7.8.5", + "tsx": "catalog:" + }, + "devDependencies": { + "@types/chai": "catalog:", + "@types/mocha": "catalog:", + "@types/node": "catalog:", + "@types/semver": "^7.8.0", + "chai": "catalog:", + "mocha": "catalog:", + "typescript": "catalog:" + }, + "engines": { + "node": ">=22.13" + } +} diff --git a/packages/release/src/args.ts b/packages/release/src/args.ts new file mode 100644 index 000000000..2690674c4 --- /dev/null +++ b/packages/release/src/args.ts @@ -0,0 +1,19 @@ +import { type ParseArgsOptionsConfig, parseArgs } from 'node:util' + +/** + * Parses a command's arguments (node:util parseArgs, strict), printing its + * usage on --help or on an unknown option. + */ +export const parse = (args: string[], options: O, usage: string) => { + try { + const parsed = parseArgs({ args, options: { ...options, help: { type: 'boolean', short: 'h' } }, allowPositionals: true, strict: true }) + if ((parsed.values as { help?: boolean }).help) { + console.log(usage) + process.exit(0) + } + return parsed + } catch (error) { + console.error(`${(error as Error).message}\n\n${usage}`) + process.exit(1) + } +} diff --git a/packages/release/src/cli.ts b/packages/release/src/cli.ts new file mode 100644 index 000000000..8ec5b855d --- /dev/null +++ b/packages/release/src/cli.ts @@ -0,0 +1,46 @@ +/** + * cl-release: the release tooling of the monorepo. + * + * The root scripts run it (`pnpm release:version`, …), and the workflows call + * its commands directly (`pnpm exec cl-release resolve `, …). It works + * from anywhere in the repository: it runs at the workspace root. + */ +import { execFileSync } from 'node:child_process' + +const COMMANDS = { + version: ['Bump the changed packages and open the chore(release) pull request', () => import('./commands/version.ts')], + tag: ['Tag the package versions not released yet and push the tags', () => import('./commands/tag.ts')], + try: ['Install the working tree in a sandbox, packed as for npm', () => import('./commands/try.ts')], + resolve: ['Resolve a release tag to its package, as $GITHUB_OUTPUT lines', () => import('./commands/resolve.ts')], + notes: ['Write the release notes of a tag', () => import('./commands/notes.ts')], + publish: ['Publish a tag to npm, with the workspace dependencies it needs', () => import('./commands/publish.ts')], + manifest: ['Compare a package command surface with its npm version', () => import('./commands/manifest.ts')], + labels: ['Generate .github/labeler.yml (pkg: labels)', () => import('./commands/labels.ts')], +} satisfies Record Promise<{ run: (args: string[]) => Promise | void }>]> + +const usage = (): string => { + const width = Math.max(...Object.keys(COMMANDS).map((c) => c.length)) + return [ + 'Usage: cl-release [options]', + '', + ...Object.entries(COMMANDS).map(([name, [description]]) => ` ${name.padEnd(width)} ${description}`), + '', + 'cl-release --help shows the options of a command.', + ].join('\n') +} + +const [command, ...args] = process.argv.slice(2) + +if (!command || command === '--help' || command === '-h') { + console.log(usage()) + process.exit(command ? 0 : 1) +} +if (!(command in COMMANDS)) { + console.error(`Unknown command '${command}'\n\n${usage()}`) + process.exit(1) +} + +process.chdir(execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim()) + +const { run } = await COMMANDS[command as keyof typeof COMMANDS][1]() +await run(args) diff --git a/packages/release/src/commands/labels.ts b/packages/release/src/commands/labels.ts new file mode 100644 index 000000000..9bd502720 --- /dev/null +++ b/packages/release/src/commands/labels.ts @@ -0,0 +1,42 @@ +/** + * Generates .github/labeler.yml: one `pkg:` label per package, applied + * from the files a PR touches, so a PR shows at a glance which packages it + * changes. Removes the GitHub release-notes configs (.github/release-.yml) + * once generated: the notes now come from the commits (`cl-release notes`). + */ +import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { parse } from '../args.ts' +import { type Package, publicPackages } from '../workspace.ts' + +const USAGE = `Usage: cl-release labels [--check] + --check exit 1 if the committed files are out of date (used in CI)` + +const GITHUB = '.github' +const HEADER = '# Generated by `pnpm release:config` (cl-release labels) — do not edit by hand.\n' + +export const labelerConfig = (packages: Pick[]): string => + HEADER + packages.map((pkg) => `\n'pkg:${pkg.dir}':\n - changed-files:\n - any-glob-to-any-file:\n - '${pkg.path}/**'\n`).join('') + +export const run = (args: string[]): void => { + const { values } = parse(args, { check: { type: 'boolean' } }, USAGE) + const packages = publicPackages() + + const files = new Map([[join(GITHUB, 'labeler.yml'), labelerConfig(packages)]]) + // The release-notes configs of GitHub's generated notes, no longer used + const stale = readdirSync(GITHUB).filter((f) => /^release-.+\.yml$/.test(f) && !files.has(join(GITHUB, f))) + const outdated = [...files].filter(([path, content]) => !existsSync(path) || readFileSync(path, 'utf8') !== content).map(([path]) => path) + + if (values.check) { + const problems = [...outdated, ...stale.map((f) => join(GITHUB, f))] + if (problems.length > 0) { + console.error(`Release config is out of date: ${problems.join(', ')}\nRun \`pnpm release:config\` and commit the result.`) + process.exit(1) + } + console.log(`Release config is up to date (${packages.length} packages).`) + } else { + for (const path of outdated) writeFileSync(path, files.get(path) as string) + for (const f of stale) rmSync(join(GITHUB, f)) + console.log(`Wrote ${outdated.length} file(s), removed ${stale.length}, ${packages.length} packages.`) + } +} diff --git a/packages/release/src/commands/manifest.ts b/packages/release/src/commands/manifest.ts new file mode 100644 index 000000000..70d3794ab --- /dev/null +++ b/packages/release/src/commands/manifest.ts @@ -0,0 +1,101 @@ +/** + * Compares an oclif package's command surface with its latest version on npm. + * + * Commands, flags and arguments removed since the published version are + * breaking for anyone scripting the CLI. If the package's version is not a + * new major, that is an error; additions are only reported. + * + * The package must be built (lib/) first. Packages without oclif commands, + * and packages not yet on npm, are skipped. + */ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import semver from 'semver' +import { parse } from '../args.ts' +import { fail, listPackages } from '../workspace.ts' + +const USAGE = `Usage: cl-release manifest [--against ] [--warn] + --against compare with this published version instead of \`latest\` + --warn report breaking changes without failing` + +type OclifManifest = { commands: Record; args?: Record }> } + +/** Commands, flags and args removed and added from one manifest to the next. */ +export const surfaceDiff = (previous: OclifManifest, current: OclifManifest): { removed: string[]; added: string[] } => { + const removed: string[] = [] + const added: string[] = [] + const names = (o: Record | undefined) => new Set(Object.keys(o ?? {})) + for (const [id, before] of Object.entries(previous.commands)) { + const after = current.commands[id] + if (!after) { + removed.push(`command ${id}`) + continue + } + for (const kind of ['flags', 'args'] as const) { + const label = (name: string) => `${kind.slice(0, -1)} ${id} ${kind === 'flags' ? '--' : ''}${name}` + const was = names(before[kind]) + const now = names(after[kind]) + for (const name of was) if (!now.has(name)) removed.push(label(name)) + for (const name of now) if (!was.has(name)) added.push(label(name)) + } + } + for (const id of Object.keys(current.commands)) if (!previous.commands[id]) added.push(`command ${id}`) + return { removed, added } +} + +export const run = (args: string[]): void => { + const { values, positionals } = parse(args, { against: { type: 'string', default: 'latest' }, warn: { type: 'boolean' } }, USAGE) + const dir = positionals[0] + + const pkg = listPackages().find((p) => p.dir === dir) ?? fail(`No package in packages/ or plugins/ named '${dir}'`, '') + if (pkg.private) { + console.log(`${pkg.name}: private, skipped`) + return + } + if (!pkg.manifest.oclif?.commands) { + console.log(`${pkg.name}: no oclif commands, skipped`) + return + } + + const exec = (cmd: string, cmdArgs: string[], cwd?: string) => execFileSync(cmd, cmdArgs, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) + + let published: string | undefined + try { + published = exec('npm', ['view', `${pkg.name}@${values.against}`, 'version']).trim() + } catch {} + if (!published) { + console.log(`${pkg.name}: not on npm yet, skipped`) + return + } + + const tmp = mkdtempSync(join(tmpdir(), 'check-manifest-')) + try { + // Current surface, generated from the built package + exec('pnpm', ['exec', 'oclif', 'manifest'], pkg.path) + const current = JSON.parse(readFileSync(join(pkg.path, 'oclif.manifest.json'), 'utf8')) + rmSync(join(pkg.path, 'oclif.manifest.json')) + + // Published surface, from the npm tarball + const [{ filename }] = JSON.parse(exec('npm', ['pack', `${pkg.name}@${published}`, '--json', '--pack-destination', tmp])) + exec('tar', ['-xzf', join(tmp, filename), '-C', tmp, 'package/oclif.manifest.json']) + const previous = JSON.parse(readFileSync(join(tmp, 'package', 'oclif.manifest.json'), 'utf8')) + + const { removed, added } = surfaceDiff(previous, current) + console.log(`${pkg.name} ${pkg.version} vs npm ${published}: ${Object.keys(current.commands).length} commands`) + for (const a of added) console.log(` + ${a}`) + for (const r of removed) console.log(` - ${r}`) + + if (removed.length > 0 && semver.major(pkg.version) <= semver.major(published)) { + const msg = `${removed.length} command/flag/arg removal(s) since ${published} without a major version bump (${pkg.version})` + if (values.warn) console.log(`::warning::${pkg.name}: ${msg}`) + else { + console.error(`::error::${pkg.name}: ${msg}`) + process.exitCode = 1 + } + } + } finally { + rmSync(tmp, { recursive: true, force: true }) + } +} diff --git a/packages/release/src/commands/notes.ts b/packages/release/src/commands/notes.ts new file mode 100644 index 000000000..6cfe86469 --- /dev/null +++ b/packages/release/src/commands/notes.ts @@ -0,0 +1,46 @@ +/** + * Writes the release notes of a package tag, from its Conventional Commits: + * the commits that touched the package's folder since its previous release, + * grouped by type (breaking changes, features, bug fixes, …), with links to + * the commits and the compare view. conventional-changelog (conventionalcommits + * preset) reads the local git history, so the notes are complete however many + * commits the range spans (GitHub's generated notes stop at 250). + */ +import { ConventionalChangelog } from 'conventional-changelog' +import { parse } from '../args.ts' +import { git, previousRelease, readJson, resolveTagOrFail } from '../workspace.ts' + +const USAGE = `Usage: cl-release notes [previous-tag] + previous-tag defaults to the package's previous release (as \`cl-release resolve\` finds it)` + +/** The preset hides chore, test, ci, docs, …: say so when that is all there is. */ +export const withMaintenanceNote = (notes: string, previous: string | undefined): string => + notes.includes('\n### ') ? notes : `${notes}\n\nMaintenance release: no changes to features or fixes since ${previous ?? 'the beginning'} (tests, tooling, dependencies).` + +export const run = async (args: string[]): Promise => { + const { positionals } = parse(args, {}, USAGE) + const [tag, previousArg] = positionals + const pkg = resolveTagOrFail(tag) + const previous = previousArg || previousRelease(pkg) + + // The release date is the tag's, not the day the notes are written + const date = git('log', '-1', '--format=%cs', pkg.tag) + + const changelog = new ConventionalChangelog() + .loadPreset('conventionalcommits') + // Links from the root package.json, not the git remote, which a clone may not point at GitHub + .repository(readJson('package.json').repository) + .package({ name: pkg.name, version: pkg.version }) + .tags({ prefix: `${pkg.dir}-v` }) + .commits({ path: pkg.path, ...(previous ? { from: previous } : {}), to: pkg.tag }) + .context({ + version: pkg.version, + date, + currentTag: pkg.tag, + ...(previous ? { previousTag: previous, linkCompare: true } : { linkCompare: false }), + }) + + let notes = '' + for await (const chunk of changelog.write()) notes += chunk + process.stdout.write(`${withMaintenanceNote(notes.trim(), previous)}\n`) +} diff --git a/packages/release/src/commands/publish.ts b/packages/release/src/commands/publish.ts new file mode 100644 index 000000000..cb4c79572 --- /dev/null +++ b/packages/release/src/commands/publish.ts @@ -0,0 +1,103 @@ +/** + * Publishes the package a release tag points at, together with the workspace + * dependencies it needs. + * + * Never "everything whose version is not on npm": publishing one draft + * release publishes only that package, plus the workspace dependencies + * (cli-core, cli-ux, …) whose required version isn't on npm yet. Those are + * released in the same `chore(release)` PR (`cl-release version`), so + * their tags exist on the same commit; they are published first, from that + * tree, and reported so publish.yml can mark their draft releases published. + * A dependency version without a release tag was bumped outside the release + * flow, and publishing stops. + * + * A package already on npm is skipped: it may have been published as the + * dependency of another one. + * + * Each package is published with `pnpm publish`, which runs prepack, rewrites + * `workspace:` ranges to real versions, and in CI does the OIDC + * trusted-publishing exchange with npm and attaches provenance. Not + * `pnpm publish -r`: it would give every package the same dist-tag, and + * publish any workspace version not on npm, tagged for release or not. + * + * The package and its workspace dependencies must be built first. + */ +import { execFileSync } from 'node:child_process' +import { appendFileSync } from 'node:fs' +import { parse } from '../args.ts' +import { fail, git, listPackages, onNpm, type Package, resolveTagOrFail, tagOf, workspaceDeps } from '../workspace.ts' + +const USAGE = `Usage: cl-release publish [--dry-run] + --dry-run pnpm publish --dry-run: nothing reaches npm` + +/** + * The workspace dependencies of a package that must be published with it, + * dependencies first: those not on npm at the required version. Each needs a + * release tag. + */ +export const dependenciesToPublish = ( + pkg: Package, + packages: Package[], + { published, tags }: { published: (p: Package) => boolean; tags: Set }, +): Package[] => { + const workspace = new Map(packages.map((p) => [p.name, p])) + const toPublish: Package[] = [] + const visit = (p: Package, chain: string[]): void => { + for (const name of workspaceDeps(p)) { + const dep = workspace.get(name) + if (!dep || toPublish.includes(dep) || published(dep)) continue + if (dep.private) throw new Error(`${p.name} depends on the private ${dep.name}`) + const depTag = tagOf(dep) + if (!tags.has(depTag)) + throw new Error( + `${[...chain, p.name].join(' → ')} needs ${dep.name}@${dep.version}, which is not on npm and has no ${depTag} release tag. Release it with \`pnpm release:version\`.`, + ) + visit(dep, [...chain, p.name]) + toPublish.push(dep) + } + } + visit(pkg, []) + return toPublish +} + +export const run = (args: string[]): void => { + const { values, positionals } = parse(args, { 'dry-run': { type: 'boolean' } }, USAGE) + const dry = values['dry-run'] + const packages = listPackages() + const pkg = resolveTagOrFail(positionals[0], packages) + + if (onNpm(pkg.name, pkg.version)) { + console.log(`${pkg.name}@${pkg.version} is already on npm, nothing to publish`) + return + } + + let toPublish: Package[] = [] + try { + toPublish = dependenciesToPublish(pkg, packages, { + published: (p) => onNpm(p.name, p.version), + tags: new Set(git('tag', '--list', '*-v*').split('\n')), + }) + } catch (error) { + fail((error as Error).message, '::error::') + } + + const publish = (p: Package): void => { + const target = resolveTagOrFail(tagOf(p), packages) + // The release tag is checked out detached: no branch or clean-tree checks + const pnpmArgs = ['publish', '--access', 'public', '--tag', target.distTag, '--no-git-checks'] + if (process.env.CI) pnpmArgs.push('--provenance') + if (dry) pnpmArgs.push('--dry-run') + console.log(`› pnpm ${pnpmArgs.join(' ')} (${p.path})`) + execFileSync('pnpm', pnpmArgs, { cwd: p.path, stdio: 'inherit' }) + console.log(`✓ ${dry ? 'Would publish' : 'Published'} ${p.name}@${p.version} (dist-tag ${target.distTag})`) + } + + for (const dep of toPublish) { + console.log(`› ${pkg.name} needs ${dep.name}@${dep.version}, not on npm yet: publishing it first`) + publish(dep) + } + publish(pkg) + + // Tags of the dependencies published along, for publish.yml + if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `published_deps=${toPublish.map((d) => tagOf(d)).join(' ')}\n`) +} diff --git a/packages/release/src/commands/resolve.ts b/packages/release/src/commands/resolve.ts new file mode 100644 index 000000000..f09a69851 --- /dev/null +++ b/packages/release/src/commands/resolve.ts @@ -0,0 +1,29 @@ +/** + * Resolves a release tag `-v` to its package, as key=value lines + * for $GITHUB_OUTPUT. Fails loudly when the tag does not belong to a public + * workspace package or its version does not match that package.json. + * + * dir, path, package, version, prerelease, dist_tag, previous_tag + * + * previous_tag is the package's closest lower release, so the generated notes + * cover this package's range instead of whichever tag GitHub finds first. + */ +import { parse } from '../args.ts' +import { previousRelease, resolveTagOrFail } from '../workspace.ts' + +const USAGE = 'Usage: cl-release resolve ' + +export const run = (args: string[]): void => { + const { positionals } = parse(args, {}, USAGE) + const pkg = resolveTagOrFail(positionals[0]) + const out = { + dir: pkg.dir, + path: pkg.path, + package: pkg.name, + version: pkg.version, + prerelease: pkg.prerelease, + dist_tag: pkg.distTag, + previous_tag: previousRelease(pkg) ?? '', + } + for (const [k, v] of Object.entries(out)) console.log(`${k}=${v}`) +} diff --git a/packages/release/src/commands/tag.ts b/packages/release/src/commands/tag.ts new file mode 100644 index 000000000..f51ce222f --- /dev/null +++ b/packages/release/src/commands/tag.ts @@ -0,0 +1,56 @@ +/** + * Tags every public package whose package.json version has no release yet. + * + * Run it on an up-to-date base branch after merging a `chore(release)` PR + * (`cl-release version`). For each package it creates the annotated tag + * `-v` on HEAD, unless that tag already exists or the version is + * already on npm, and then pushes the new tags. Each pushed tag makes + * release.yml draft a GitHub release; publishing the draft publishes to npm. + * + * GitHub does not trigger workflows when more than three tags are pushed at + * once, so tags are pushed one at a time. + */ +import { parse } from '../args.ts' +import { git, onNpm, publicPackages, requireUpToDate, tagOf } from '../workspace.ts' + +const USAGE = `Usage: cl-release tag [--base ] [--no-push] [--dry-run] + --base branch HEAD must be (default: main) + --no-push create the tags locally only + --dry-run show the tags and create none` + +export const run = (args: string[]): void => { + const { values } = parse(args, { base: { type: 'string', default: 'main' }, 'no-push': { type: 'boolean' }, 'dry-run': { type: 'boolean' } }, USAGE) + const dry = values['dry-run'] + + requireUpToDate(values.base) + + const existing = new Set(git('tag', '--list', '*-v*').split('\n')) + const created: string[] = [] + for (const pkg of publicPackages()) { + const tag = tagOf(pkg) + if (existing.has(tag)) continue + if (onNpm(pkg.name, pkg.version)) { + console.log(`· ${tag}: ${pkg.name}@${pkg.version} is already on npm, not tagging`) + continue + } + console.log(`+ ${tag}`) + if (!dry) git('tag', '--annotate', tag, '--message', `${pkg.name} v${pkg.version}`) + created.push(tag) + } + + if (created.length === 0) { + console.log('Nothing to tag.') + return + } + if (dry) { + console.log('(dry run: no tag created)') + return + } + if (values['no-push']) { + console.log(`\nCreated ${created.length} tag(s). Push each with \`git push origin \`.`) + return + } + + for (const tag of created) git('push', '--quiet', 'origin', `refs/tags/${tag}`) + console.log(`\n✓ Pushed ${created.length} tag(s). Review and publish the draft releases on GitHub to publish to npm.`) +} diff --git a/packages/release/src/commands/try.ts b/packages/release/src/commands/try.ts new file mode 100644 index 000000000..cfc8ab4a6 --- /dev/null +++ b/packages/release/src/commands/try.ts @@ -0,0 +1,111 @@ +/** + * Installs the working tree's CLI and plugins in a sandbox, to try them as a + * user would before a release: packed like for npm, nothing published. + * + * The CLI, cli-core, cli-ux and the chosen plugins (all of them by default) + * are built and packed (oclif manifest included), the CLI is installed in + * /cli, and the plugins in its data folder (/data) as user + * plugins, as `plugins:install` does: cli-core and cli-ux come from the packed + * tarballs, so unreleased versions work too. The sandbox has its own config, + * data and cache folders: logins made there don't touch the real ones. + * + * Then run the CLI with /commercelayer, e.g. `.release-try/commercelayer plugins`. + */ +import { type ExecFileSyncOptions, execFileSync } from 'node:child_process' +import { chmodSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { parse } from '../args.ts' +import { fail, type Package, publicPackages, workspaceDeps } from '../workspace.ts' + +const USAGE = `Usage: pnpm release:try [...] [--out ] + plugins to install, e.g. tags orders (default: all) + --out sandbox folder (default: .release-try)` + +/** The packages a set of packages needs: themselves and their workspace dependencies, transitively. */ +export const withWorkspaceDeps = (roots: Package[], packages: Package[]): Set => { + const byName = new Map(packages.map((p) => [p.name, p])) + const selected = new Set() + const add = (p: Package): void => { + if (selected.has(p)) return + selected.add(p) + for (const name of workspaceDeps(p)) { + const dep = byName.get(name) + if (dep) add(dep) + } + } + for (const p of roots) add(p) + return selected +} + +export const run = (args: string[]): void => { + const { values, positionals } = parse(args, { out: { type: 'string', default: '.release-try' } }, USAGE) + // Relative to where pnpm was run from, not to the workspace root + const out = resolve(process.env.INIT_CWD ?? process.cwd(), values.out) + const exec = (cmd: string, cmdArgs: string[], opts: ExecFileSyncOptions = {}) => execFileSync(cmd, cmdArgs, { stdio: 'inherit', ...opts }) + + const packages = publicPackages() + const cli = packages.find((p) => p.dir === 'cli') ?? fail('No CLI package in packages/cli') + const allPlugins = packages.filter((p) => p.path.startsWith('plugins/')) + const plugins = positionals.length ? positionals.map((dir) => allPlugins.find((p) => p.dir === dir) ?? fail(`No plugin in plugins/${dir}`)) : allPlugins + + // The CLI, the plugins and the workspace packages they depend on + const selected = withWorkspaceDeps([cli, ...plugins], packages) + const isOclif = (p: Package) => Boolean(p.manifest.oclif?.commands) + + console.log(`› Building ${[...selected].map((p) => p.dir).join(', ')}`) + exec('pnpm', ['--silent', ...[...selected].flatMap((p) => ['--filter', p.name]), 'build']) + + rmSync(out, { recursive: true, force: true }) + const tgz = join(out, 'tgz') + mkdirSync(tgz, { recursive: true }) + + // Packed without the lifecycle scripts: prepack would also regenerate and + // git add the README; the oclif manifest is generated here instead + const tarballs = new Map() + for (const p of selected) { + console.log(`› Packing ${p.name}@${p.version}`) + const before = new Set(readdirSync(tgz)) + if (isOclif(p)) exec('pnpm', ['exec', 'oclif', 'manifest'], { cwd: p.path, stdio: 'ignore' }) + try { + exec('pnpm', ['pack', '--ignore-scripts', '--pack-destination', tgz], { cwd: p.path, stdio: 'ignore' }) + } finally { + if (isOclif(p)) rmSync(join(p.path, 'oclif.manifest.json'), { force: true }) + } + tarballs.set(p.name, `file:${join(tgz, readdirSync(tgz).find((f) => !before.has(f)) as string)}`) + } + + // The workspace libraries come from the tarballs, also for the plugins + const overrides = Object.fromEntries([...selected].filter((p) => !isOclif(p)).map((p) => [p.name, tarballs.get(p.name)])) + const install = (dir: string, manifest: Record): void => { + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'package.json'), `${JSON.stringify({ private: true, ...manifest, overrides }, null, 2)}\n`) + exec('npm', ['install', '--no-audit', '--no-fund', '--loglevel=error'], { cwd: dir }) + } + + console.log(`› Installing ${cli.name} in ${join(out, 'cli')}`) + install(join(out, 'cli'), { name: 'release-try-cli', dependencies: { [cli.name]: tarballs.get(cli.name) } }) + + console.log(`› Installing ${plugins.length} plugin(s) as user plugins in ${join(out, 'data')}`) + install(join(out, 'data'), { + name: 'release-try-plugins', + oclif: { schema: 1, plugins: plugins.map((p) => ({ name: p.name, type: 'user', tag: 'latest' })) }, + dependencies: Object.fromEntries(plugins.map((p) => [p.name, tarballs.get(p.name)])), + }) + + // The sandbox's own entry point and folders + const bin = join(out, 'commercelayer') + writeFileSync( + bin, + `#!/bin/sh +# The CLI and plugins of release-try, with the sandbox's own config, data and cache +SANDBOX="${out}" +export COMMERCELAYER_DATA_DIR="$SANDBOX/data" COMMERCELAYER_CONFIG_DIR="$SANDBOX/config" COMMERCELAYER_CACHE_DIR="$SANDBOX/cache" +export XDG_CONFIG_HOME="$SANDBOX/xdg-config" +exec node "$SANDBOX/cli/node_modules/${cli.name}/bin/run.js" "$@" +`, + ) + chmodSync(bin, 0o755) + + console.log(`\n✓ ${cli.name}@${cli.version} with ${plugins.map((p) => `${p.dir}@${p.version}`).join(', ')}`) + console.log(` Try it: ${bin} --version`) +} diff --git a/packages/release/src/commands/version.ts b/packages/release/src/commands/version.ts new file mode 100644 index 000000000..70e76b80c --- /dev/null +++ b/packages/release/src/commands/version.ts @@ -0,0 +1,219 @@ +/** + * Bumps the version of the packages changed since their last release and + * opens a `chore(release)` pull request with the bumps. + * + * Same role as commercelayer-sdk's finish-version.mjs, without Lerna: Lerna + * only recognises its own `@` tags, so with `-v*` tags it + * would treat all packages as changed on every release. + * + * Only packages with commits touching their directory since their last + * `-v*` tag are released; the others are left alone. Each one's version + * is derived from those commits' Conventional Commit types by + * conventional-recommended-bump (breaking -> major, feat -> minor, anything + * else -> patch). Packages depending on a released one are not bumped: they + * pick it up through their `^` range, and adopting a new major of a + * dependency is a change of its own. + * + * By default it prints the plan and asks for a single confirmation; + * --interactive lets you change or skip each package's version. + * + * Nothing is committed to the base branch: the bumps go on a `release/…` + * branch and a pull request. Once it is merged, `pnpm release:tag` tags the + * merge commit, and pushing the tags drafts the GitHub releases. + */ +import { execFileSync } from 'node:child_process' +import { join } from 'node:path' +import { createInterface } from 'node:readline/promises' +import { Bumper } from 'conventional-recommended-bump' +import semver from 'semver' +import { parse } from '../args.ts' +import { fail, git, lastTag, type Package, publicPackages, readJson, requireUpToDate, sortByDependencies, tagOf, workspaceDeps, writeJson } from '../workspace.ts' + +const USAGE = `Usage: cl-release version [--interactive | --yes] [--base ] [--no-pr] [--dry-run] + --interactive confirm, change or skip each package's version + --yes no confirmation at all + --base branch the release PR targets (default: main) + --no-pr commit on a local release branch only, do not push or open a PR + --dry-run show what would happen and change nothing` + +type Level = 'major' | 'minor' | 'patch' +type Candidate = { pkg: Package; since?: string; commits: string[][]; level: Level; why: string; next: string } +type Selected = Candidate & { version: string } + +/** + * The next version for a bump level. Only stable versions are released: + * a prerelease left in a package.json (x.y.z-beta.n) is released as x.y.z. + * Prereleases aren't published to npm: try a release with `pnpm release:try`, + * or share a pkg.pr.new preview (preview.yml). + */ +export const bump = (version: string, level: Level): string => + (semver.prerelease(version) ? semver.inc(version, 'release') : semver.inc(version, level)) as string + +/** + * The bump level of a package's commits since a tag, from the Conventional + * Commits preset (breaking -> major, feat -> minor, anything else -> patch), + * and the header of the commit that decides it, shown in the plan. + */ +const recommend = async (pkg: Package, since: string | undefined): Promise<{ level: Level; why: string }> => { + const result = await new Bumper().loadPreset('conventionalcommits').tag(since ?? '').commits({ path: pkg.path }).bump() + const { releaseType, commits = [] } = result as { releaseType?: Level; commits?: { notes: unknown[]; type?: string | null; header?: string | null }[] } + const level = releaseType ?? 'patch' + const decisive = level === 'major' ? commits.find((c) => c.notes.length > 0) : level === 'minor' ? commits.find((c) => c.type === 'feat') : undefined + return { level, why: decisive?.header ?? '' } +} + +/** + * A package released with a change in a workspace dependency (cli-core, + * cli-ux, …) needs that change on npm too: the unreleased changes of its + * runtime workspace dependencies are released with it, so nobody has to + * release and publish them first by hand. Returns the added ones. + */ +export const addChangedDependencies = (selected: Selected[], candidates: Candidate[]): { added: Selected; by: Selected }[] => { + const added: { added: Selected; by: Selected }[] = [] + for (let more = true; more; ) { + more = false + for (const s of [...selected]) { + for (const name of workspaceDeps(s.pkg)) { + if (selected.some((x) => x.pkg.name === name)) continue + const dep = candidates.find((c) => c.pkg.name === name) + if (!dep) continue + const entry = { ...dep, version: dep.next } + selected.push(entry) + added.push({ added: entry, by: s }) + more = true + } + } + } + return added +} + +export const run = async (args: string[]): Promise => { + const { values } = parse( + args, + { + interactive: { type: 'boolean' }, + yes: { type: 'boolean' }, + base: { type: 'string', default: 'main' }, + 'no-pr': { type: 'boolean' }, + 'dry-run': { type: 'boolean' }, + }, + USAGE, + ) + const base = values.base + + requireUpToDate(base) + + const candidates: Candidate[] = [] + for (const pkg of publicPackages()) { + const since = await lastTag(pkg) + const range = since ? [`${since}..HEAD`] : [] + const log = git('log', '--no-merges', '--format=%s%x1f%b%x1e', ...range, '--', pkg.path) + const commits = log + .split('\x1e') + .map((c) => c.trim()) + .filter(Boolean) + .map((c) => c.split('\x1f')) + .filter(([subject]) => !/^chore\(release\)/.test(subject)) + if (commits.length === 0) continue + const { level, why } = await recommend(pkg, since) + candidates.push({ pkg, since, commits, level, why, next: bump(pkg.version, level) }) + } + + if (candidates.length === 0) { + console.log('Nothing to release: no package has changed since its last tag.') + return + } + + const rl = values.yes ? undefined : createInterface({ input: process.stdin }) + // Line by line rather than rl.question(), which drops answers piped in ahead of the prompt + const lines = rl?.[Symbol.asyncIterator]() + const ask = async (prompt: string): Promise => { + process.stdout.write(prompt) + const { value } = (await lines?.next()) ?? {} + return (value ?? '').trim() + } + + let selected: Selected[] = [] + if (values.interactive) { + for (const c of candidates) { + console.log(`\n${c.pkg.name} (${c.pkg.path})`) + console.log(` ${c.commits.length} commit(s) since ${c.since ?? 'the beginning'}; ${c.level}: ${c.pkg.version} → ${c.next}`) + for (const [subject] of c.commits.slice(0, 8)) console.log(` · ${subject}`) + if (c.commits.length > 8) console.log(` · … ${c.commits.length - 8} more`) + const answer = await ask(` Version [${c.next}] (s to skip, or type a version): `) + if (answer === 's') continue + const version = answer || c.next + if (!semver.valid(version) || semver.prerelease(version)) fail(`'${version}' is not a valid stable version (prereleases aren't published)`) + selected.push({ ...c, version }) + } + } else { + selected = candidates.map((c) => ({ ...c, version: c.next })) + const width = Math.max(...selected.map((s) => s.pkg.name.length)) + console.log(`\n${selected.length} changed package(s):\n`) + for (const s of selected) { + const why = s.why || s.commits[0][0] + console.log(` ${s.pkg.name.padEnd(width)} ${s.pkg.version} → ${s.version} ${s.level}, ${s.commits.length} commit(s): ${why}`) + } + if (rl) { + const answer = (await ask('\nRelease these versions? [y/N] ')).toLowerCase() + if (answer !== 'y' && answer !== 'yes') { + console.log('Aborted. Use --interactive to change or skip single packages.') + process.exit(0) + } + } + } + rl?.close() + + for (const { added, by } of addChangedDependencies(selected, candidates)) + console.log(`+ ${added.pkg.name} ${added.pkg.version} → ${added.version}: unreleased changes, required by ${by.pkg.name}`) + // Dependencies first, so tags, drafts and publishing follow the same order + const order = sortByDependencies(selected.map((s) => s.pkg)) + selected.sort((a, b) => order.indexOf(a.pkg) - order.indexOf(b.pkg)) + + if (selected.length === 0) { + console.log('\nNothing selected.') + return + } + + const tags = selected.map((s) => tagOf(s.pkg, s.version)) + const branch = `release/${tags.length === 1 ? tags[0] : new Date().toISOString().slice(0, 16).replace(/[-:T]/g, '')}` + const subject = `chore(release): ${tags.join(', ')}` + + console.log(`\n${subject}\n branch: ${branch}`) + if (values['dry-run']) { + console.log(' (dry run: nothing changed)') + return + } + + git('switch', '--quiet', '-c', branch) + for (const s of selected) { + const file = join(s.pkg.path, 'package.json') + const manifest = readJson(file) + manifest.version = s.version + writeJson(file, manifest) + git('add', file) + } + git('commit', '--quiet', '-m', subject) + console.log(`✓ Committed on ${branch}`) + + if (values['no-pr']) { + console.log(`\nPush it and open a PR to ${base}; once merged run \`pnpm release:tag\`.`) + return + } + + git('push', '--quiet', '-u', 'origin', branch) + const body = [ + 'Version bumps:', + '', + ...selected.map((s) => `- \`${s.pkg.name}\` ${s.pkg.version} → **${s.version}** (\`${tagOf(s.pkg, s.version)}\`)`), + '', + `After merging, run \`pnpm release:tag\` on an up-to-date \`${base}\` to tag the merge commit and draft the releases.`, + ].join('\n') + // The release PR itself has no place in the release notes + execFileSync('gh', ['label', 'create', 'ignore-for-release', '--force', '--color', 'ededed', '--description', 'Excluded from release notes'], { + stdio: 'ignore', + }) + execFileSync('gh', ['pr', 'create', '--base', base, '--head', branch, '--title', subject, '--body', body, '--label', 'ignore-for-release'], { + stdio: 'inherit', + }) +} diff --git a/packages/release/src/git-semver-tags.d.ts b/packages/release/src/git-semver-tags.d.ts new file mode 100644 index 000000000..90600c7a4 --- /dev/null +++ b/packages/release/src/git-semver-tags.d.ts @@ -0,0 +1,4 @@ +// git-semver-tags ships no types +declare module 'git-semver-tags' { + export function getSemverTags(options?: { tagPrefix?: string; lernaTags?: boolean; package?: string; skipUnstable?: boolean; cwd?: string }): Promise +} diff --git a/packages/release/src/workspace.ts b/packages/release/src/workspace.ts new file mode 100644 index 000000000..76183f865 --- /dev/null +++ b/packages/release/src/workspace.ts @@ -0,0 +1,150 @@ +/** + * Workspace helpers shared by the release commands. + * + * The packages are the workspace's (pnpm-workspace.yaml, read by + * @manypkg/get-packages): `packages/` and `plugins/`, with `` + * unique across both. It is the package's identity everywhere in + * the release flow: tags are `-v`, labels are `pkg:`, the + * release notes cover the commits under its folder. + * + * Paths are relative to the workspace root, which cl-release makes the + * working directory (src/cli.ts). + */ +import { execFileSync } from 'node:child_process' +import { readFileSync, writeFileSync } from 'node:fs' +import { basename } from 'node:path' +import { getPackagesSync } from '@manypkg/get-packages' +import { graphSequencer } from '@pnpm/deps.graph-sequencer' +import { getSemverTags } from 'git-semver-tags' +import semver from 'semver' + +export type Manifest = { + name: string + version: string + private?: boolean + dependencies?: Record + peerDependencies?: Record + optionalDependencies?: Record + oclif?: { commands?: unknown; [key: string]: unknown } + [key: string]: unknown +} + +export type Package = { + /** Folder name, unique in the workspace: the package's identity in tags and labels */ + dir: string + /** Folder, relative to the workspace root */ + path: string + name: string + version: string + private: boolean + manifest: Manifest +} + +export type ReleaseTarget = Package & { tag: string; prerelease: false; distTag: 'latest' } + +export const git = (...args: string[]): string => execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() + +export const readJson = (path: string): T => JSON.parse(readFileSync(path, 'utf8')) +export const writeJson = (path: string, data: unknown): void => writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`) + +/** Prints an error and exits: the commands' way to stop. */ +export const fail = (message: string, prefix = '✖ '): never => { + console.error(`${prefix}${message}`) + process.exit(1) +} + +/** All workspace packages, public and private, sorted by directory name. */ +export const listPackages = (): Package[] => + getPackagesSync(process.cwd()) + .packages.map(({ relativeDir: path, packageJson }) => { + const manifest = packageJson as unknown as Manifest + return { dir: basename(path), path, name: manifest.name, version: manifest.version, private: manifest.private === true, manifest } + }) + .sort((a, b) => a.dir.localeCompare(b.dir)) + +export const publicPackages = (): Package[] => listPackages().filter((p) => !p.private) + +/** The workspace packages a package needs at runtime (`workspace:` ranges, by name). */ +export const workspaceDeps = (pkg: Pick): string[] => { + const { dependencies = {}, peerDependencies = {}, optionalDependencies = {} } = pkg.manifest + return Object.entries({ ...dependencies, ...peerDependencies, ...optionalDependencies }) + .filter(([, range]) => range.startsWith('workspace:')) + .map(([name]) => name) +} + +/** Packages with their workspace dependencies first (pnpm's own sequencer), otherwise by directory. */ +export const sortByDependencies =

(pkgs: P[]): P[] => { + const byName = new Map([...pkgs].sort((a, b) => a.dir.localeCompare(b.dir)).map((p) => [p.name, p])) + const graph = new Map([...byName.values()].map((p) => [p.name, workspaceDeps(p).filter((name) => byName.has(name))])) + return graphSequencer(graph).order.map((name) => byName.get(name) as P) +} + +export const tagOf = (pkg: Pick, version = pkg.version): string => `${pkg.dir}-v${version}` + +/** The package a `

-v` tag belongs to, or an error message. */ +export const resolveTag = (tag: string, packages: Package[] = listPackages()): ReleaseTarget | { error: string } => { + const match = /^(.+?)-v(.+)$/.exec(tag) + if (!match || !semver.valid(match[2])) return { error: `'${tag}' is not a -v tag` } + const [, dir, version] = match + const pkg = packages.find((p) => p.dir === dir) + if (!pkg) return { error: `Tag prefix '${dir}' does not match a workspace package directory` } + if (pkg.private) return { error: `${pkg.name} is private and must not be released` } + if (pkg.version !== version) return { error: `Tag version ${version} does not match ${pkg.path}/package.json (${pkg.version})` } + // Only stable versions reach npm: try a release with `pnpm release:try`, share a pkg.pr.new preview (preview.yml) + if (semver.prerelease(version)) return { error: `${tag} is a prerelease: prereleases aren't released or published` } + return { ...pkg, tag, prerelease: false, distTag: 'latest' } +} + +/** resolveTag, stopping on an error with a GitHub Actions annotation. */ +export const resolveTagOrFail = (tag: string | undefined, packages?: Package[]): ReleaseTarget => { + const target = resolveTag(tag ?? '', packages) + if ('error' in target) return fail(target.error, '::error::') + return target +} + +/** + * Among a package's released versions, the closest lower one to `version`: + * for a stable release the previous stable one, for a prerelease the previous + * release of any kind. + */ +export const previousVersion = (versions: string[], version: string): string | undefined => + versions + .filter((v) => semver.valid(v)) + .filter((v) => semver.prerelease(version) || !semver.prerelease(v)) + .filter((v) => semver.lt(v, version)) + .sort(semver.compare) + .pop() + +/** The tag of a package's previous release (previousVersion), undefined for a first release. */ +export const previousRelease = (pkg: Pick, version = pkg.version): string | undefined => { + const prefix = `${pkg.dir}-v` + const versions = git('tag', '--list', `${prefix}*`) + .split('\n') + .filter(Boolean) + .map((t) => t.slice(prefix.length)) + const previous = previousVersion(versions, version) + return previous ? `${prefix}${previous}` : undefined +} + +/** + * The most recent tag of a package, stable or not, reachable from HEAD: the + * first in history order (git-semver-tags), not the closest one by commit + * count, so a release made on a maintenance branch and merged back counts. + */ +export const lastTag = async (pkg: Pick): Promise => (await getSemverTags({ tagPrefix: `${pkg.dir}-v` }))[0] + +/** Whether a version of a package is on npm. */ +export const onNpm = (name: string, version: string): boolean => { + try { + return execFileSync('npm', ['view', `${name}@${version}`, 'version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() === version + } catch { + return false + } +} + +/** HEAD must be an up-to-date base branch with a clean tree, to version or tag a release. */ +export const requireUpToDate = (base: string): void => { + if (git('status', '--porcelain')) fail('Working tree is not clean.') + git('fetch', '--quiet', 'origin', base, '--tags') + if (git('rev-parse', 'HEAD') !== git('rev-parse', `origin/${base}`)) fail(`HEAD is not origin/${base}. Check out an up-to-date ${base} first.`) +} diff --git a/packages/release/test/commands.test.ts b/packages/release/test/commands.test.ts new file mode 100644 index 000000000..5c0d96070 --- /dev/null +++ b/packages/release/test/commands.test.ts @@ -0,0 +1,92 @@ +import { expect } from 'chai' +import { labelerConfig } from '../src/commands/labels.ts' +import { surfaceDiff } from '../src/commands/manifest.ts' +import { withMaintenanceNote } from '../src/commands/notes.ts' +import { dependenciesToPublish } from '../src/commands/publish.ts' +import { withWorkspaceDeps } from '../src/commands/try.ts' +import { addChangedDependencies, bump } from '../src/commands/version.ts' +import { pkg } from './helpers.ts' + +describe('version', () => { + it('bumps by level', () => { + expect(bump('6.9.9', 'patch')).to.equal('6.9.10') + expect(bump('6.9.9', 'minor')).to.equal('6.10.0') + expect(bump('6.9.9', 'major')).to.equal('7.0.0') + }) + it('releases a prerelease left in a package.json as its stable version', () => { + expect(bump('7.0.0-beta.1', 'patch')).to.equal('7.0.0') + expect(bump('7.0.0-beta.1', 'major')).to.equal('7.0.0') + }) + + it('adds the changed workspace dependencies of the selected packages, transitively', () => { + const core = pkg('core', '6.0.0') + const ux = pkg('ux', '2.0.0', ['core']) + const tags = pkg('tags', '3.0.0', ['ux']) + const other = pkg('other', '1.0.0') + const candidate = (p: typeof core, next: string) => ({ pkg: p, commits: [['fix: x']], level: 'patch' as const, why: '', next }) + const candidates = [candidate(core, '6.0.1'), candidate(ux, '2.0.1'), candidate(tags, '3.0.1'), candidate(other, '1.0.1')] + const selected = [{ ...candidates[2], version: '3.1.0' }] + const added = addChangedDependencies(selected, candidates) + expect(selected.map((s) => `${s.pkg.dir}@${s.version}`)).to.deep.equal(['tags@3.1.0', 'ux@2.0.1', 'core@6.0.1']) + expect(added.map((a) => `${a.added.pkg.dir}<-${a.by.pkg.dir}`)).to.deep.equal(['ux<-tags', 'core<-ux']) + }) +}) + +describe('publish', () => { + const core = pkg('core', '6.0.0') + const ux = pkg('ux', '2.0.0', ['core']) + const tags = pkg('tags', '3.0.0', ['core', 'ux']) + const packages = [core, ux, tags] + + it('publishes the dependencies not on npm first', () => { + const deps = dependenciesToPublish(tags, packages, { published: () => false, tags: new Set(['core-v6.0.0', 'ux-v2.0.0']) }) + expect(deps.map((p) => p.dir)).to.deep.equal(['core', 'ux']) + }) + it('skips the dependencies already on npm', () => { + const deps = dependenciesToPublish(tags, packages, { published: (p) => p.dir === 'core', tags: new Set(['ux-v2.0.0']) }) + expect(deps.map((p) => p.dir)).to.deep.equal(['ux']) + }) + it('stops on a dependency without a release tag', () => { + expect(() => dependenciesToPublish(tags, packages, { published: () => false, tags: new Set(['ux-v2.0.0']) })).to.throw(/@cl\/tags needs @cl\/core@6.0.0.*no core-v6.0.0 release tag/) + }) + it('stops on a private dependency', () => { + const dev = pkg('dev', '1.0.0', [], { private: true }) + expect(() => dependenciesToPublish(pkg('x', '1.0.0', ['dev']), [dev], { published: () => false, tags: new Set() })).to.throw(/private @cl\/dev/) + }) +}) + +describe('manifest', () => { + it('reports removed and added commands, flags and args', () => { + const previous = { commands: { a: { flags: { x: {}, y: {} }, args: { id: {} } }, gone: {} } } + const current = { commands: { a: { flags: { x: {}, z: {} }, args: {} }, fresh: {} } } + expect(surfaceDiff(previous, current)).to.deep.equal({ + removed: ['flag a --y', 'arg a id', 'command gone'], + added: ['flag a --z', 'command fresh'], + }) + }) +}) + +describe('notes', () => { + it('adds a maintenance note when no section is shown', () => { + expect(withMaintenanceNote('## 1.0.1', 'x-v1.0.0')).to.match(/Maintenance release: no changes to features or fixes since x-v1.0.0/) + expect(withMaintenanceNote('## 1.1.0\n\n### Features\n\n* a', 'x-v1.0.0')).to.not.match(/Maintenance/) + }) +}) + +describe('labels', () => { + it('has one pkg: label per package', () => { + const config = labelerConfig([{ dir: 'tags', path: 'plugins/tags' }]) + expect(config).to.match(/^# Generated by/) + expect(config).to.contain("'pkg:tags':\n - changed-files:\n - any-glob-to-any-file:\n - 'plugins/tags/**'\n") + }) +}) + +describe('try', () => { + it('installs the workspace dependencies of the chosen packages', () => { + const core = pkg('core', '1.0.0') + const ux = pkg('ux', '1.0.0', ['core']) + const tags = pkg('tags', '1.0.0', ['ux']) + const orders = pkg('orders', '1.0.0', ['core']) + expect([...withWorkspaceDeps([tags], [core, ux, tags, orders])].map((p) => p.dir)).to.deep.equal(['tags', 'ux', 'core']) + }) +}) diff --git a/packages/release/test/helpers.ts b/packages/release/test/helpers.ts new file mode 100644 index 000000000..db822f51e --- /dev/null +++ b/packages/release/test/helpers.ts @@ -0,0 +1,8 @@ +import type { Package } from '../src/workspace.ts' + +/** A workspace package for the tests, with `workspace:` dependencies on the named packages. */ +export const pkg = (dir: string, version: string, deps: string[] = [], extra: Partial = {}): Package => { + const name = `@cl/${dir}` + const dependencies = Object.fromEntries(deps.map((d) => [`@cl/${d}`, 'workspace:^'])) + return { dir, path: `plugins/${dir}`, name, version, private: false, manifest: { name, version, dependencies: { chalk: '^5', ...dependencies } }, ...extra } +} diff --git a/packages/release/test/workspace.test.ts b/packages/release/test/workspace.test.ts new file mode 100644 index 000000000..48bcc62c6 --- /dev/null +++ b/packages/release/test/workspace.test.ts @@ -0,0 +1,56 @@ +import { expect } from 'chai' +import { previousVersion, resolveTag, sortByDependencies, tagOf, workspaceDeps } from '../src/workspace.ts' +import { pkg } from './helpers.ts' + +describe('workspace', () => { + const packages = [pkg('core', '6.0.0'), pkg('tags', '3.1.0', ['core']), pkg('dev', '1.0.0', [], { private: true }), pkg('beta', '2.0.0-beta.1')] + + describe('resolveTag', () => { + it('resolves a release tag to its package', () => { + const target = resolveTag('tags-v3.1.0', packages) + expect(target).to.include({ name: '@cl/tags', tag: 'tags-v3.1.0', prerelease: false, distTag: 'latest' }) + }) + it('refuses what is not a -v tag', () => { + expect(resolveTag('v3.1.0', packages)).to.have.property('error').that.matches(/not a -v tag/) + expect(resolveTag('tags-v3.1', packages)).to.have.property('error') + }) + it('refuses an unknown folder, a private package, a version mismatch and a prerelease', () => { + expect(resolveTag('nope-v1.0.0', packages)).to.have.property('error').that.matches(/does not match a workspace package/) + expect(resolveTag('dev-v1.0.0', packages)).to.have.property('error').that.matches(/private/) + expect(resolveTag('tags-v3.0.0', packages)).to.have.property('error').that.matches(/does not match plugins\/tags\/package.json \(3.1.0\)/) + expect(resolveTag('beta-v2.0.0-beta.1', packages)).to.have.property('error').that.matches(/prerelease/) + }) + }) + + it('tagOf builds -v', () => { + expect(tagOf(packages[1])).to.equal('tags-v3.1.0') + expect(tagOf(packages[1], '4.0.0')).to.equal('tags-v4.0.0') + }) + + it('workspaceDeps lists the workspace: dependencies only', () => { + expect(workspaceDeps(packages[1])).to.deep.equal(['@cl/core']) + }) + + it('sortByDependencies puts dependencies first, the rest by folder', () => { + const sorted = sortByDependencies([pkg('zeta', '1.0.0', ['ux']), pkg('cli', '1.0.0', ['core', 'ux']), pkg('ux', '1.0.0', ['core']), pkg('core', '1.0.0'), pkg('alpha', '1.0.0')]) + const order = sorted.map((p) => p.dir) + expect(order.indexOf('core')).to.be.lessThan(order.indexOf('ux')) + expect(order.indexOf('ux')).to.be.lessThan(order.indexOf('cli')) + expect(order.indexOf('ux')).to.be.lessThan(order.indexOf('zeta')) + expect(order[0]).to.equal('alpha') + }) + + describe('previousVersion', () => { + const versions = ['1.0.0', '1.1.0', '2.0.0-beta.0', '2.0.0-beta.1', '1.10.0', 'junk'] + it('is the closest lower stable version for a stable release', () => { + expect(previousVersion(versions, '2.0.0')).to.equal('1.10.0') + expect(previousVersion(versions, '1.10.0')).to.equal('1.1.0') + }) + it('is the closest lower version of any kind for a prerelease', () => { + expect(previousVersion(versions, '2.0.0-beta.2')).to.equal('2.0.0-beta.1') + }) + it('is undefined for a first release', () => { + expect(previousVersion(versions, '1.0.0')).to.equal(undefined) + }) + }) +}) diff --git a/packages/release/tsconfig.json b/packages/release/tsconfig.json new file mode 100644 index 000000000..2ba669636 --- /dev/null +++ b/packages/release/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "declaration": false, + "importHelpers": false, + "allowImportingTsExtensions": true, + "types": ["node", "mocha"] + }, + "include": [ + "src/**/*", + "test/**/*" + ] +} diff --git a/scripts/test/live-sweep.mjs b/packages/test-utils/bin/live-sweep.mjs similarity index 97% rename from scripts/test/live-sweep.mjs rename to packages/test-utils/bin/live-sweep.mjs index 234fbebfd..75a4b4ef2 100644 --- a/scripts/test/live-sweep.mjs +++ b/packages/test-utils/bin/live-sweep.mjs @@ -7,7 +7,7 @@ * * Runs before the integration suites in CI (integration.yml). * - * Usage: CL_CLI_ORGANIZATION=… CL_CLI_CLIENT_ID=… CL_CLI_CLIENT_SECRET=… node scripts/test/live-sweep.mjs [--dry-run] + * Usage: CL_CLI_ORGANIZATION=… CL_CLI_CLIENT_ID=… CL_CLI_CLIENT_SECRET=… node packages/test-utils/bin/live-sweep.mjs [--dry-run] */ const PREFIX = 'cli-it-' const DRY = process.argv.includes('--dry-run') diff --git a/scripts/test/oclif-root.cjs b/packages/test-utils/oclif-root.cjs similarity index 100% rename from scripts/test/oclif-root.cjs rename to packages/test-utils/oclif-root.cjs diff --git a/packages/test-utils/src/live.ts b/packages/test-utils/src/live.ts index ebdb997eb..a41b64081 100644 --- a/packages/test-utils/src/live.ts +++ b/packages/test-utils/src/live.ts @@ -71,7 +71,7 @@ export const describeLive = (title: string, fn: (this: Mocha.Suite) => void, ena /* * Suites that change data create their own resources and delete them in an * after() hook. Every name, email, code or reference they create starts with - * LIVE_PREFIX, so a run never touches existing data and scripts/test/live-sweep.mjs + * LIVE_PREFIX, so a run never touches existing data and packages/test-utils/bin/live-sweep.mjs * can remove what an interrupted run left behind. */ diff --git a/plugins/checkout/.mocharc.json b/plugins/checkout/.mocharc.json index 865d41b6b..01dd933ba 100644 --- a/plugins/checkout/.mocharc.json +++ b/plugins/checkout/.mocharc.json @@ -1,7 +1,7 @@ { "import": "tsx", "require": [ - "../../scripts/test/oclif-root.cjs" + "../../packages/test-utils/oclif-root.cjs" ], "timeout": 10000 } diff --git a/plugins/cleanups/.mocharc.json b/plugins/cleanups/.mocharc.json index 865d41b6b..01dd933ba 100644 --- a/plugins/cleanups/.mocharc.json +++ b/plugins/cleanups/.mocharc.json @@ -1,7 +1,7 @@ { "import": "tsx", "require": [ - "../../scripts/test/oclif-root.cjs" + "../../packages/test-utils/oclif-root.cjs" ], "timeout": 10000 } diff --git a/plugins/exports/.mocharc.json b/plugins/exports/.mocharc.json index 865d41b6b..01dd933ba 100644 --- a/plugins/exports/.mocharc.json +++ b/plugins/exports/.mocharc.json @@ -1,7 +1,7 @@ { "import": "tsx", "require": [ - "../../scripts/test/oclif-root.cjs" + "../../packages/test-utils/oclif-root.cjs" ], "timeout": 10000 } diff --git a/plugins/imports/.mocharc.json b/plugins/imports/.mocharc.json index 865d41b6b..01dd933ba 100644 --- a/plugins/imports/.mocharc.json +++ b/plugins/imports/.mocharc.json @@ -1,7 +1,7 @@ { "import": "tsx", "require": [ - "../../scripts/test/oclif-root.cjs" + "../../packages/test-utils/oclif-root.cjs" ], "timeout": 10000 } diff --git a/plugins/links/.mocharc.json b/plugins/links/.mocharc.json index 865d41b6b..01dd933ba 100644 --- a/plugins/links/.mocharc.json +++ b/plugins/links/.mocharc.json @@ -1,7 +1,7 @@ { "import": "tsx", "require": [ - "../../scripts/test/oclif-root.cjs" + "../../packages/test-utils/oclif-root.cjs" ], "timeout": 10000 } diff --git a/plugins/metrics/.mocharc.json b/plugins/metrics/.mocharc.json index 865d41b6b..01dd933ba 100644 --- a/plugins/metrics/.mocharc.json +++ b/plugins/metrics/.mocharc.json @@ -1,7 +1,7 @@ { "import": "tsx", "require": [ - "../../scripts/test/oclif-root.cjs" + "../../packages/test-utils/oclif-root.cjs" ], "timeout": 10000 } diff --git a/plugins/microstore/.mocharc.json b/plugins/microstore/.mocharc.json index 865d41b6b..01dd933ba 100644 --- a/plugins/microstore/.mocharc.json +++ b/plugins/microstore/.mocharc.json @@ -1,7 +1,7 @@ { "import": "tsx", "require": [ - "../../scripts/test/oclif-root.cjs" + "../../packages/test-utils/oclif-root.cjs" ], "timeout": 10000 } diff --git a/plugins/orders/.mocharc.json b/plugins/orders/.mocharc.json index 865d41b6b..01dd933ba 100644 --- a/plugins/orders/.mocharc.json +++ b/plugins/orders/.mocharc.json @@ -1,7 +1,7 @@ { "import": "tsx", "require": [ - "../../scripts/test/oclif-root.cjs" + "../../packages/test-utils/oclif-root.cjs" ], "timeout": 10000 } diff --git a/plugins/provisioning/.mocharc.json b/plugins/provisioning/.mocharc.json index 865d41b6b..01dd933ba 100644 --- a/plugins/provisioning/.mocharc.json +++ b/plugins/provisioning/.mocharc.json @@ -1,7 +1,7 @@ { "import": "tsx", "require": [ - "../../scripts/test/oclif-root.cjs" + "../../packages/test-utils/oclif-root.cjs" ], "timeout": 10000 } diff --git a/plugins/resources/.mocharc.json b/plugins/resources/.mocharc.json index 865d41b6b..01dd933ba 100644 --- a/plugins/resources/.mocharc.json +++ b/plugins/resources/.mocharc.json @@ -1,7 +1,7 @@ { "import": "tsx", "require": [ - "../../scripts/test/oclif-root.cjs" + "../../packages/test-utils/oclif-root.cjs" ], "timeout": 10000 } diff --git a/plugins/seeder/.mocharc.json b/plugins/seeder/.mocharc.json index 865d41b6b..01dd933ba 100644 --- a/plugins/seeder/.mocharc.json +++ b/plugins/seeder/.mocharc.json @@ -1,7 +1,7 @@ { "import": "tsx", "require": [ - "../../scripts/test/oclif-root.cjs" + "../../packages/test-utils/oclif-root.cjs" ], "timeout": 10000 } diff --git a/plugins/tags/.mocharc.json b/plugins/tags/.mocharc.json index 865d41b6b..01dd933ba 100644 --- a/plugins/tags/.mocharc.json +++ b/plugins/tags/.mocharc.json @@ -1,7 +1,7 @@ { "import": "tsx", "require": [ - "../../scripts/test/oclif-root.cjs" + "../../packages/test-utils/oclif-root.cjs" ], "timeout": 10000 } diff --git a/plugins/token/.mocharc.json b/plugins/token/.mocharc.json index 865d41b6b..01dd933ba 100644 --- a/plugins/token/.mocharc.json +++ b/plugins/token/.mocharc.json @@ -1,7 +1,7 @@ { "import": "tsx", "require": [ - "../../scripts/test/oclif-root.cjs" + "../../packages/test-utils/oclif-root.cjs" ], "timeout": 10000 } diff --git a/plugins/triggers/.mocharc.json b/plugins/triggers/.mocharc.json index 865d41b6b..01dd933ba 100644 --- a/plugins/triggers/.mocharc.json +++ b/plugins/triggers/.mocharc.json @@ -1,7 +1,7 @@ { "import": "tsx", "require": [ - "../../scripts/test/oclif-root.cjs" + "../../packages/test-utils/oclif-root.cjs" ], "timeout": 10000 } diff --git a/plugins/webhooks/.mocharc.json b/plugins/webhooks/.mocharc.json index 865d41b6b..01dd933ba 100644 --- a/plugins/webhooks/.mocharc.json +++ b/plugins/webhooks/.mocharc.json @@ -1,7 +1,7 @@ { "import": "tsx", "require": [ - "../../scripts/test/oclif-root.cjs" + "../../packages/test-utils/oclif-root.cjs" ], "timeout": 10000 } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3daeed24e..b2c47e13f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -274,6 +274,9 @@ importers: '@biomejs/biome': specifier: ^2.5.14 version: 2.5.14 + '@commercelayer/cli-release': + specifier: workspace:* + version: link:packages/release '@commitlint/cli': specifier: ^21.2.3 version: 21.2.3(@types/node@26.6.3)(conventional-commits-filter@6.0.1)(conventional-commits-parser@7.1.2)(typescript@6.0.3) @@ -283,24 +286,6 @@ importers: '@manypkg/get-packages': specifier: ^3.1.0 version: 3.1.0 - '@pnpm/deps.graph-sequencer': - specifier: ^1101.0.0 - version: 1101.0.0 - conventional-changelog: - specifier: ^8.1.3 - version: 8.1.3 - conventional-changelog-conventionalcommits: - specifier: ^10.4.0 - version: 10.4.0 - conventional-recommended-bump: - specifier: ^12.1.0 - version: 12.1.0 - git-semver-tags: - specifier: ^8.0.1 - version: 8.0.1(conventional-commits-filter@6.0.1)(conventional-commits-parser@7.1.2) - semver: - specifier: ^7.8.5 - version: 7.8.5 packages/cli: dependencies: @@ -540,6 +525,55 @@ importers: specifier: 'catalog:' version: 6.0.3 + packages/release: + dependencies: + '@manypkg/get-packages': + specifier: ^3.1.0 + version: 3.1.0 + '@pnpm/deps.graph-sequencer': + specifier: ^1101.0.0 + version: 1101.0.0 + conventional-changelog: + specifier: ^8.1.3 + version: 8.1.3 + conventional-changelog-conventionalcommits: + specifier: ^10.4.0 + version: 10.4.0 + conventional-recommended-bump: + specifier: ^12.1.0 + version: 12.1.0 + git-semver-tags: + specifier: ^8.0.1 + version: 8.0.1(conventional-commits-filter@6.0.1)(conventional-commits-parser@7.1.2) + semver: + specifier: ^7.8.5 + version: 7.8.5 + tsx: + specifier: 'catalog:' + version: 4.23.15 + devDependencies: + '@types/chai': + specifier: 'catalog:' + version: 5.2.3 + '@types/mocha': + specifier: 'catalog:' + version: 10.0.10 + '@types/node': + specifier: 'catalog:' + version: 26.6.3 + '@types/semver': + specifier: ^7.8.0 + version: 7.8.0 + chai: + specifier: 'catalog:' + version: 6.2.2 + mocha: + specifier: 'catalog:' + version: 12.0.2 + typescript: + specifier: 'catalog:' + version: 6.0.3 + packages/test-utils: dependencies: nock: @@ -2869,6 +2903,9 @@ packages: '@types/node@26.6.3': resolution: {integrity: sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==} + '@types/semver@7.8.0': + resolution: {integrity: sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==} + '@types/wordwrap@1.0.3': resolution: {integrity: sha512-jx39cOYWJxZxVOZeNHvLVoDLRUFcYtIJaurC6C0qzCovIB3GPDbMDbYvoWi9D1B2PtIE16rElQOFR4Y+8QbUgw==} @@ -6530,6 +6567,8 @@ snapshots: dependencies: undici-types: 8.9.0 + '@types/semver@7.8.0': {} + '@types/wordwrap@1.0.3': {} '@types/wrap-ansi@3.0.0': {} diff --git a/scripts/check-manifest.mjs b/scripts/check-manifest.mjs deleted file mode 100644 index 6e47c90e8..000000000 --- a/scripts/check-manifest.mjs +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env node -/** - * Compares an oclif package's command surface with its latest version on npm. - * - * Commands, flags and arguments removed since the published version are - * breaking for anyone scripting the CLI. If the package's version is not a - * new major, that is an error; additions are only reported. - * - * The package must be built (lib/) first. Packages without oclif commands, - * and packages not yet on npm, are skipped. - * - * Usage: node scripts/check-manifest.mjs [--against ] [--warn] - * --against compare with this published version instead of `latest` - * --warn report breaking changes without failing - */ -import { execFileSync } from 'node:child_process' -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { listPackages } from './lib/workspace.mjs' - -const [dir, ...rest] = process.argv.slice(2) -const WARN = rest.includes('--warn') -const AGAINST = rest.includes('--against') ? rest[rest.indexOf('--against') + 1] : 'latest' - -const pkg = listPackages().find((p) => p.dir === dir) -if (!pkg) { - console.error(`No package in packages/ or plugins/ named '${dir}'`) - process.exit(1) -} -if (pkg.private) { - console.log(`${pkg.name}: private, skipped`) - process.exit(0) -} -if (!pkg.manifest.oclif?.commands) { - console.log(`${pkg.name}: no oclif commands, skipped`) - process.exit(0) -} - -const run = (cmd, args, cwd) => execFileSync(cmd, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) - -let published -try { - published = run('npm', ['view', `${pkg.name}@${AGAINST}`, 'version']).trim() -} catch {} -if (!published) { - console.log(`${pkg.name}: not on npm yet, skipped`) - process.exit(0) -} - -const tmp = mkdtempSync(join(tmpdir(), 'check-manifest-')) -try { - // Current surface, generated from the built package - run('pnpm', ['exec', 'oclif', 'manifest'], pkg.path) - const current = JSON.parse(readFileSync(join(pkg.path, 'oclif.manifest.json'), 'utf8')) - rmSync(join(pkg.path, 'oclif.manifest.json')) - - // Published surface, from the npm tarball - const [{ filename }] = JSON.parse(run('npm', ['pack', `${pkg.name}@${published}`, '--json', '--pack-destination', tmp])) - run('tar', ['-xzf', join(tmp, filename), '-C', tmp, 'package/oclif.manifest.json']) - const previous = JSON.parse(readFileSync(join(tmp, 'package', 'oclif.manifest.json'), 'utf8')) - - const removed = [] - const added = [] - const names = (o) => new Set(Object.keys(o ?? {})) - for (const [id, before] of Object.entries(previous.commands)) { - const after = current.commands[id] - if (!after) { - removed.push(`command ${id}`) - continue - } - for (const kind of ['flags', 'args']) { - const now = names(after[kind]) - for (const name of names(before[kind])) if (!now.has(name)) removed.push(`${kind.slice(0, -1)} ${id} ${kind === 'flags' ? '--' : ''}${name}`) - for (const name of now) if (!names(before[kind]).has(name)) added.push(`${kind.slice(0, -1)} ${id} ${kind === 'flags' ? '--' : ''}${name}`) - } - } - for (const id of Object.keys(current.commands)) if (!previous.commands[id]) added.push(`command ${id}`) - - console.log(`${pkg.name} ${pkg.version} vs npm ${published}: ${Object.keys(current.commands).length} commands`) - for (const a of added) console.log(` + ${a}`) - for (const r of removed) console.log(` - ${r}`) - - const major = (v) => Number(v.split('.')[0]) - if (removed.length > 0 && major(pkg.version) <= major(published)) { - const msg = `${removed.length} command/flag/arg removal(s) since ${published} without a major version bump (${pkg.version})` - if (WARN) console.log(`::warning::${pkg.name}: ${msg}`) - else { - console.error(`::error::${pkg.name}: ${msg}`) - process.exitCode = 1 - } - } -} finally { - rmSync(tmp, { recursive: true, force: true }) -} diff --git a/scripts/check-packages.mjs b/scripts/check-packages.mjs index 32ca16e65..9eedbe368 100644 --- a/scripts/check-packages.mjs +++ b/scripts/check-packages.mjs @@ -21,8 +21,8 @@ * Usage: node scripts/check-packages.mjs */ import { existsSync, readFileSync } from 'node:fs' -import { join } from 'node:path' -import { listPackages } from './lib/workspace.mjs' +import { basename, join } from 'node:path' +import { getPackagesSync } from '@manypkg/get-packages' const REPO = 'https://github.com/commercelayer/commercelayer-cli' const AUTHOR = 'Pierluigi Viti ' @@ -48,7 +48,13 @@ for (const line of readFileSync('pnpm-workspace.yaml', 'utf8').split('\n')) { } } -const packages = listPackages() +const packages = getPackagesSync(process.cwd()).packages.map(({ relativeDir: path, packageJson: manifest }) => ({ + dir: basename(path), + path, + name: manifest.name, + private: manifest.private === true, + manifest, +})) const workspace = new Set(packages.map((p) => p.name)) const problems = [] const check = (pkg, ok, message) => { diff --git a/scripts/finish-version.mjs b/scripts/finish-version.mjs deleted file mode 100644 index bd68e5851..000000000 --- a/scripts/finish-version.mjs +++ /dev/null @@ -1,209 +0,0 @@ -#!/usr/bin/env node -/** - * Bumps the version of the packages changed since their last release and - * opens a `chore(release)` pull request with the bumps. - * - * Same role as commercelayer-sdk's finish-version.mjs, without Lerna: Lerna - * only recognises its own `@` tags, so with `-v*` tags it - * would treat all packages as changed on every release. - * - * Only packages with commits touching their directory since their last - * `-v*` tag are released; the others are left alone. Each one's version - * is derived from those commits' Conventional Commit types by - * conventional-recommended-bump (breaking -> major, feat -> minor, anything - * else -> patch). Packages depending on a released one - * are not bumped: they pick it up through their `^` range, and adopting a new - * major of a dependency is a change of its own. - * - * By default it prints the plan and asks for a single confirmation; - * --interactive lets you change or skip each package's version. - * - * Nothing is committed to the base branch: the bumps go on a `release/…` - * branch and a pull request. Once it is merged, `pnpm release:tag` tags the - * merge commit, and pushing the tags drafts the GitHub releases. - * - * Usage: node scripts/finish-version.mjs [--interactive | --yes] [--base ] [--no-pr] [--dry-run] - * --interactive confirm, change or skip each package's version - * --yes no confirmation at all - * --base branch the release PR targets (default: main) - * --no-pr commit on a local release branch only, do not push or open a PR - * --dry-run show what would happen and change nothing - */ -import { execFileSync } from 'node:child_process' -import { join } from 'node:path' -import { createInterface } from 'node:readline/promises' -import { Bumper } from 'conventional-recommended-bump' -import semver from 'semver' -import { git, lastTag, publicPackages, readJson, sortByDependencies, tagOf, workspaceDeps, writeJson } from './lib/workspace.mjs' - -const args = process.argv.slice(2) -const flag = (name) => args.includes(name) -const option = (name, fallback) => (args.includes(name) ? args[args.indexOf(name) + 1] : fallback) - -const DRY = flag('--dry-run') -const YES = flag('--yes') -const INTERACTIVE = flag('--interactive') -const NO_PR = flag('--no-pr') -const BASE = option('--base', 'main') - -const fail = (msg) => { - console.error(`\n✖ ${msg}\n`) - process.exit(1) -} - -if (git('status', '--porcelain')) fail('Working tree is not clean.') -git('fetch', '--quiet', 'origin', BASE, '--tags') -if (git('rev-parse', 'HEAD') !== git('rev-parse', `origin/${BASE}`)) fail(`HEAD is not origin/${BASE}. Check out an up-to-date ${BASE} first.`) - -/** - * The next version for a bump level. Only stable versions are released: - * a prerelease left in a package.json (x.y.z-beta.n) is released as x.y.z. - * Prereleases aren't published to npm: try a release with `pnpm release:try`, - * or share a pkg.pr.new preview (preview.yml). - */ -const bump = (version, level) => (semver.prerelease(version) ? semver.inc(version, 'release') : semver.inc(version, level)) - -/** - * The bump level of a package's commits since a tag, from the Conventional - * Commits preset (breaking -> major, feat -> minor, anything else -> patch), - * and the header of the commit that decides it, shown in the plan. - */ -const recommend = async (pkg, since) => { - const { releaseType, commits } = await new Bumper() - .loadPreset('conventionalcommits') - .tag(since ?? '') - .commits({ path: pkg.path }) - .bump() - const level = releaseType ?? 'patch' - const breaking = (c) => c.notes.length > 0 - const decisive = level === 'major' ? commits.find(breaking) : level === 'minor' ? commits.find((c) => c.type === 'feat') : undefined - return { level, why: decisive?.header ?? '' } -} - -const candidates = [] -for (const pkg of publicPackages()) { - const since = await lastTag(pkg) - const range = since ? [`${since}..HEAD`] : [] - const log = git('log', '--no-merges', '--format=%s%x1f%b%x1e', ...range, '--', pkg.path) - const commits = log - .split('\x1e') - .map((c) => c.trim()) - .filter(Boolean) - .map((c) => c.split('\x1f')) - .filter(([subject]) => !/^chore\(release\)/.test(subject)) - if (commits.length === 0) continue - const { level, why } = await recommend(pkg, since) - candidates.push({ pkg, since, commits, level, why, next: bump(pkg.version, level) }) -} - -if (candidates.length === 0) { - console.log('Nothing to release: no package has changed since its last tag.') - process.exit(0) -} - -const rl = YES ? undefined : createInterface({ input: process.stdin }) -// Line by line rather than rl.question(), which drops answers piped in ahead of the prompt -const lines = rl?.[Symbol.asyncIterator]() -const ask = async (prompt) => { - process.stdout.write(prompt) - const { value } = await lines.next() - return (value ?? '').trim() -} -let selected = [] -if (INTERACTIVE) { - for (const c of candidates) { - console.log(`\n${c.pkg.name} (${c.pkg.path})`) - console.log(` ${c.commits.length} commit(s) since ${c.since ?? 'the beginning'}; ${c.level}: ${c.pkg.version} → ${c.next}`) - for (const [subject] of c.commits.slice(0, 8)) console.log(` · ${subject}`) - if (c.commits.length > 8) console.log(` · … ${c.commits.length - 8} more`) - const answer = await ask(` Version [${c.next}] (s to skip, or type a version): `) - if (answer === 's') continue - const version = answer || c.next - if (!semver.valid(version) || semver.prerelease(version)) fail(`'${version}' is not a valid stable version (prereleases aren't published)`) - selected.push({ ...c, version }) - } -} else { - selected = candidates.map((c) => ({ ...c, version: c.next })) - const width = Math.max(...selected.map((s) => s.pkg.name.length)) - console.log(`\n${selected.length} changed package(s):\n`) - for (const s of selected) { - const why = s.why || s.commits[0][0] - console.log(` ${s.pkg.name.padEnd(width)} ${s.pkg.version} → ${s.version} ${s.level}, ${s.commits.length} commit(s): ${why}`) - } - if (rl) { - const answer = (await ask('\nRelease these versions? [y/N] ')).toLowerCase() - if (answer !== 'y' && answer !== 'yes') { - console.log('Aborted. Use --interactive to change or skip single packages.') - process.exit(0) - } - } -} -rl?.close() - -// A package released with a change in a workspace dependency (cli-core, -// cli-ux, …) needs that change on npm too: unreleased changes of runtime -// workspace dependencies are always released with it, so nobody has to -// release and publish them first by hand. -for (let added = true; added; ) { - added = false - for (const s of [...selected]) { - for (const name of workspaceDeps(s.pkg)) { - if (selected.some((x) => x.pkg.name === name)) continue - const dep = candidates.find((c) => c.pkg.name === name) - if (!dep) continue - selected.push({ ...dep, version: dep.next }) - console.log(`+ ${dep.pkg.name} ${dep.pkg.version} → ${dep.next}: unreleased changes, required by ${s.pkg.name}`) - added = true - } - } -} -// Dependencies first, so tags, drafts and publishing follow the same order -const order = sortByDependencies(selected.map((s) => s.pkg)) -selected.sort((a, b) => order.indexOf(a.pkg) - order.indexOf(b.pkg)) - -if (selected.length === 0) { - console.log('\nNothing selected.') - process.exit(0) -} - -const tags = selected.map((s) => tagOf(s.pkg, s.version)) -const branch = `release/${tags.length === 1 ? tags[0] : new Date().toISOString().slice(0, 16).replace(/[-:T]/g, '')}` -const subject = `chore(release): ${tags.join(', ')}` - -console.log(`\n${subject}\n branch: ${branch}`) -if (DRY) { - console.log(' (dry run: nothing changed)') - process.exit(0) -} - -git('switch', '--quiet', '-c', branch) -for (const s of selected) { - const file = join(s.pkg.path, 'package.json') - const manifest = readJson(file) - manifest.version = s.version - writeJson(file, manifest) - git('add', file) -} -git('commit', '--quiet', '-m', subject) -console.log(`✓ Committed on ${branch}`) - -if (NO_PR) { - console.log(`\nPush it and open a PR to ${BASE}; once merged run \`pnpm release:tag\`.`) - process.exit(0) -} - -git('push', '--quiet', '-u', 'origin', branch) -const body = [ - 'Version bumps:', - '', - ...selected.map((s) => `- \`${s.pkg.name}\` ${s.pkg.version} → **${s.version}** (\`${tagOf(s.pkg, s.version)}\`)`), - '', - 'After merging, run `pnpm release:tag` on an up-to-date `' + BASE + '` to tag the merge commit and draft the releases.', -].join('\n') -// The release PR itself has no place in the release notes -execFileSync('gh', ['label', 'create', 'ignore-for-release', '--force', '--color', 'ededed', '--description', 'Excluded from release notes'], { - stdio: 'ignore', -}) -execFileSync('gh',['pr', 'create', '--base', BASE, '--head', branch, '--title', subject, '--body', body, '--label', 'ignore-for-release'], { - stdio: 'inherit', -}) diff --git a/scripts/gen-release-config.mjs b/scripts/gen-release-config.mjs deleted file mode 100644 index b81126452..000000000 --- a/scripts/gen-release-config.mjs +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env node -/** - * Generates .github/labeler.yml: one `pkg:` label per package, applied - * from the files a PR touches, so a PR shows at a glance which packages it - * changes. Removes the GitHub release-notes configs (.github/release-.yml) - * it used to generate: the notes now come from the commits - * (scripts/release-notes.mjs). - * - * Usage: node scripts/gen-release-config.mjs [--check] - * --check exit 1 if the committed files are out of date (used in CI) - */ -import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { publicPackages } from './lib/workspace.mjs' - -const CHECK = process.argv.includes('--check') -const GITHUB = '.github' -const HEADER = '# Generated by scripts/gen-release-config.mjs — do not edit by hand.\n' - -const packages = publicPackages() -const label = (pkg) => `pkg:${pkg.dir}` - -const files = new Map() - -files.set( - join(GITHUB, 'labeler.yml'), - HEADER + - packages.map((pkg) => `\n'${label(pkg)}':\n - changed-files:\n - any-glob-to-any-file:\n - '${pkg.path}/**'\n`).join(''), -) - -// The release-notes configs of GitHub's generated notes, no longer used -const stale = readdirSync(GITHUB).filter((f) => /^release-.+\.yml$/.test(f) && !files.has(join(GITHUB, f))) - -const outdated = [...files].filter(([path, content]) => !existsSync(path) || readFileSync(path, 'utf8') !== content).map(([path]) => path) - -if (CHECK) { - const problems = [...outdated, ...stale.map((f) => join(GITHUB, f))] - if (problems.length > 0) { - console.error(`Release config is out of date: ${problems.join(', ')}\nRun \`pnpm release:config\` and commit the result.`) - process.exit(1) - } - console.log(`Release config is up to date (${packages.length} packages).`) -} else { - for (const path of outdated) writeFileSync(path, files.get(path)) - for (const f of stale) rmSync(join(GITHUB, f)) - console.log(`Wrote ${outdated.length} file(s), removed ${stale.length}, ${packages.length} packages.`) -} diff --git a/scripts/lib/workspace.mjs b/scripts/lib/workspace.mjs deleted file mode 100644 index 40e8b18f2..000000000 --- a/scripts/lib/workspace.mjs +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Workspace helpers shared by the release scripts. - * - * The packages are the workspace's (pnpm-workspace.yaml, read by - * @manypkg/get-packages): `packages/` and `plugins/`, with `` - * unique across both. It is the package's identity everywhere in - * the release flow: tags are `-v`, labels are `pkg:`, the - * release notes cover the commits under its folder. - */ -import { execFileSync } from 'node:child_process' -import { readFileSync, writeFileSync } from 'node:fs' -import { basename } from 'node:path' -import { getPackagesSync } from '@manypkg/get-packages' -import { graphSequencer } from '@pnpm/deps.graph-sequencer' -import { getSemverTags } from 'git-semver-tags' -import semver from 'semver' - -export const git = (...args) => execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() - -export const readJson = (path) => JSON.parse(readFileSync(path, 'utf8')) -export const writeJson = (path, data) => writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`) - -/** All workspace packages, public and private, sorted by directory name. */ -export const listPackages = () => - getPackagesSync(process.cwd()) - .packages.map(({ relativeDir: path, packageJson: manifest }) => ({ - dir: basename(path), - path, - name: manifest.name, - version: manifest.version, - private: manifest.private === true, - manifest, - })) - .sort((a, b) => a.dir.localeCompare(b.dir)) - -export const publicPackages = () => listPackages().filter((p) => !p.private) - -/** The workspace packages a package needs at runtime (`workspace:` ranges, by name). */ -export const workspaceDeps = (pkg) => { - const { dependencies = {}, peerDependencies = {}, optionalDependencies = {} } = pkg.manifest - return Object.entries({ ...dependencies, ...peerDependencies, ...optionalDependencies }) - .filter(([, range]) => range.startsWith('workspace:')) - .map(([name]) => name) -} - -/** Packages with their workspace dependencies first (pnpm's own sequencer), otherwise by directory. */ -export const sortByDependencies = (pkgs) => { - const byName = new Map([...pkgs].sort((a, b) => a.dir.localeCompare(b.dir)).map((p) => [p.name, p])) - const graph = new Map([...byName.values()].map((p) => [p.name, workspaceDeps(p).filter((name) => byName.has(name))])) - return graphSequencer(graph).order.map((name) => byName.get(name)) -} - -/** The package a `-v` tag belongs to, or an error message. */ -export const resolveTag = (tag) => { - const match = /^(.+?)-v(.+)$/.exec(tag) - if (!match || !semver.valid(match[2])) return { error: `'${tag}' is not a -v tag` } - const [, dir, version] = match - const pkg = listPackages().find((p) => p.dir === dir) - if (!pkg) return { error: `Tag prefix '${dir}' does not match a workspace package directory` } - if (pkg.private) return { error: `${pkg.name} is private and must not be released` } - if (pkg.version !== version) return { error: `Tag version ${version} does not match ${pkg.path}/package.json (${pkg.version})` } - // Only stable versions reach npm: try a release with `pnpm release:try`, share a pkg.pr.new preview (preview.yml) - if (semver.prerelease(version)) return { error: `${tag} is a prerelease: prereleases aren't released or published` } - return { ...pkg, tag, prerelease: false, distTag: 'latest' } -} - -export const tagOf = (pkg, version = pkg.version) => `${pkg.dir}-v${version}` - -/** - * The tag of a package's closest lower release, for the notes of `version`: - * for a stable release the previous stable one, for a prerelease the previous - * release of any kind. Undefined for a first release. - */ -export const previousRelease = (pkg, version = pkg.version) => { - const prefix = `${pkg.dir}-v` - const previous = git('tag', '--list', `${prefix}*`) - .split('\n') - .filter(Boolean) - .map((t) => t.slice(prefix.length)) - .filter((v) => semver.valid(v)) - .filter((v) => semver.prerelease(version) || !semver.prerelease(v)) - .filter((v) => semver.lt(v, version)) - .sort(semver.compare) - .pop() - return previous ? `${prefix}${previous}` : undefined -} - -/** - * The most recent tag of a package, stable or not, reachable from HEAD: the - * first in history order (git-semver-tags), not the closest one by commit - * count, so a release made on a maintenance branch and merged back counts. - */ -export const lastTag = async (pkg) => (await getSemverTags({ tagPrefix: `${pkg.dir}-v` }))[0] diff --git a/scripts/publish.mjs b/scripts/publish.mjs deleted file mode 100644 index 475355c09..000000000 --- a/scripts/publish.mjs +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env node -/** - * Publishes the package a release tag points at, together with the workspace - * dependencies it needs. - * - * Never "everything whose version is not on npm": publishing one draft - * release publishes only that package, plus the workspace dependencies - * (cli-core, cli-ux, …) whose required version isn't on npm yet. Those are - * released in the same `chore(release)` PR (scripts/finish-version.mjs), so - * their tags exist on the same commit; they are published first, from that - * tree, and reported so publish.yml can mark their draft releases published. - * A dependency version without a release tag was bumped outside the release - * flow, and publishing stops. - * - * A package already on npm is skipped: it may have been published as the - * dependency of another one. - * - * Each package is published with `pnpm publish`, which runs prepack, rewrites - * `workspace:` ranges to real versions, and in CI does the OIDC - * trusted-publishing exchange with npm and attaches provenance. Not - * `pnpm publish -r`: it would give every package the same dist-tag, and - * publish any workspace version not on npm, tagged for release or not. - * - * The package and its workspace dependencies must be built first. - * - * Usage: node scripts/publish.mjs [--dry-run] - */ -import { execFileSync } from 'node:child_process' -import { appendFileSync } from 'node:fs' -import { git, listPackages, resolveTag, tagOf, workspaceDeps } from './lib/workspace.mjs' - -const [tag, ...rest] = process.argv.slice(2) -const DRY = rest.includes('--dry-run') - -const fail = (msg) => { - console.error(`::error::${msg}`) - process.exit(1) -} - -const pkg = resolveTag(tag ?? '') -if (pkg.error) fail(pkg.error) - -const run = (cmd, args, opts = {}) => execFileSync(cmd, args, { encoding: 'utf8', ...opts }) -const onNpm = (name, version) => { - try { - return run('npm', ['view', `${name}@${version}`, 'version'], { stdio: ['ignore', 'pipe', 'ignore'] }).trim() === version - } catch { - return false - } -} - -if (onNpm(pkg.name, pkg.version)) { - console.log(`${pkg.name}@${pkg.version} is already on npm, nothing to publish`) - process.exit(0) -} - -// Workspace dependencies not on npm at the required version, dependencies first -const workspace = new Map(listPackages().map((p) => [p.name, p])) -const tags = new Set(git('tag', '--list', '*-v*').split('\n')) -const toPublish = [] -const visit = (p, chain) => { - for (const name of workspaceDeps(p)) { - const dep = workspace.get(name) - if (!dep || toPublish.includes(dep) || onNpm(dep.name, dep.version)) continue - if (dep.private) fail(`${p.name} depends on the private ${dep.name}`) - const depTag = tagOf(dep) - if (!tags.has(depTag)) fail(`${[...chain, p.name].join(' → ')} needs ${dep.name}@${dep.version}, which is not on npm and has no ${depTag} release tag. Release it with \`pnpm release:version\`.`) - visit(dep, [...chain, p.name]) - toPublish.push(dep) - } -} -visit(pkg, []) - -const publish = (p) => { - const target = resolveTag(tagOf(p)) - if (target.error) fail(target.error) - // The release tag is checked out detached: no branch or clean-tree checks - const args = ['publish', '--access', 'public', '--tag', target.distTag, '--no-git-checks'] - if (process.env.CI) args.push('--provenance') - if (DRY) args.push('--dry-run') - console.log(`› pnpm ${args.join(' ')} (${p.path})`) - run('pnpm', args, { cwd: p.path, stdio: 'inherit' }) - console.log(`✓ ${DRY ? 'Would publish' : 'Published'} ${p.name}@${p.version} (dist-tag ${target.distTag})`) -} - -for (const dep of toPublish) { - console.log(`› ${pkg.name} needs ${dep.name}@${dep.version}, not on npm yet: publishing it first`) - publish(dep) -} -publish(pkg) - -// Tags of the dependencies published along, for publish.yml -if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `published_deps=${toPublish.map((d) => tagOf(d)).join(' ')}\n`) diff --git a/scripts/release-notes.mjs b/scripts/release-notes.mjs deleted file mode 100644 index 8a471cd60..000000000 --- a/scripts/release-notes.mjs +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env node -/** - * Writes the release notes of a package tag, from its Conventional Commits: - * the commits that touched the package's folder since its previous release, - * grouped by type (breaking changes, features, bug fixes, …), with links to - * the commits and the compare view. conventional-changelog (conventionalcommits - * preset) reads the local git history, so the notes are complete however many - * commits the range spans (GitHub's generated notes stop at 250). - * - * Usage: node scripts/release-notes.mjs [previous-tag] - * previous-tag defaults to the package's previous release (as resolve-tag finds it) - */ -import { ConventionalChangelog } from 'conventional-changelog' -import { git, previousRelease, readJson, resolveTag } from './lib/workspace.mjs' - -const [tag, previousArg] = process.argv.slice(2) -const pkg = resolveTag(tag ?? '') -if (pkg.error) { - console.error(`::error::${pkg.error}`) - process.exit(1) -} -const previous = previousArg || previousRelease(pkg) - -// The release date is the tag's, not the day the notes are written -const date = git('log', '-1', '--format=%cs', tag) - -const changelog = new ConventionalChangelog() - .loadPreset('conventionalcommits') - // Links from the root package.json, not the git remote, which a clone may not point at GitHub - .repository(readJson('package.json').repository) - .package({ name: pkg.name, version: pkg.version }) - .tags({ prefix: `${pkg.dir}-v` }) - .commits({ path: pkg.path, ...(previous ? { from: previous } : {}), to: tag }) - .context({ version: pkg.version, date, currentTag: tag, ...(previous ? { previousTag: previous, linkCompare: true } : { linkCompare: false }) }) - -let notes = '' -for await (const chunk of changelog.write()) notes += chunk -notes = notes.trim() -// The preset hides chore, test, ci, docs, …: say so when that is all there is -if (!notes.includes('\n### ')) notes += `\n\nMaintenance release: no changes to features or fixes since ${previous ?? 'the beginning'} (tests, tooling, dependencies).` -process.stdout.write(`${notes}\n`) diff --git a/scripts/release-try.mjs b/scripts/release-try.mjs deleted file mode 100644 index 944474ffb..000000000 --- a/scripts/release-try.mjs +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env node -/** - * Installs the working tree's CLI and plugins in a sandbox, to try them as a - * user would before a release: packed like for npm, nothing published. - * - * The CLI, cli-core, cli-ux and the chosen plugins (all of them by default) - * are built and packed (oclif manifest included), the CLI is installed in - * /cli, and the plugins in its data folder (/data) as user - * plugins, as `plugins:install` does: cli-core and cli-ux come from the packed - * tarballs, so unreleased versions work too. The sandbox has its own config, - * data and cache folders: logins made there don't touch the real ones. - * - * Usage: pnpm release:try [...] [--out ] - * plugins to install, e.g. tags orders (default: all) - * --out sandbox folder (default: .release-try) - * - * Then run the CLI with /commercelayer, e.g. `.release-try/commercelayer plugins`. - */ -import { execFileSync } from 'node:child_process' -import { chmodSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs' -import { join, resolve } from 'node:path' -import { publicPackages, workspaceDeps } from './lib/workspace.mjs' - -const args = process.argv.slice(2) -const outIndex = args.indexOf('--out') -const OUT = resolve(outIndex > -1 ? args[outIndex + 1] : '.release-try') -const outValue = outIndex > -1 ? outIndex + 1 : -1 -const wanted = args.filter((a, i) => !a.startsWith('--') && i !== outValue) - -const fail = (msg) => { - console.error(`✖ ${msg}`) - process.exit(1) -} -const run = (cmd, cmdArgs, opts = {}) => execFileSync(cmd, cmdArgs, { stdio: 'inherit', ...opts }) - -const packages = publicPackages() -const cli = packages.find((p) => p.dir === 'cli') -const allPlugins = packages.filter((p) => p.path.startsWith('plugins/')) -const plugins = wanted.length ? wanted.map((dir) => allPlugins.find((p) => p.dir === dir) ?? fail(`No plugin in plugins/${dir}`)) : allPlugins - -// The CLI, the plugins and the workspace packages they depend on -const byName = new Map(packages.map((p) => [p.name, p])) -const selected = new Set() -const add = (p) => { - if (selected.has(p)) return - selected.add(p) - for (const name of workspaceDeps(p)) if (byName.has(name)) add(byName.get(name)) -} -for (const p of [cli, ...plugins]) add(p) -const isOclif = (p) => Boolean(p.manifest.oclif?.commands) - -console.log(`› Building ${[...selected].map((p) => p.dir).join(', ')}`) -run('pnpm', ['--silent', ...[...selected].flatMap((p) => ['--filter', p.name]), 'build']) - -rmSync(OUT, { recursive: true, force: true }) -const tgz = join(OUT, 'tgz') -mkdirSync(tgz, { recursive: true }) - -// Packed without the lifecycle scripts: prepack would also regenerate and -// git add the README; the oclif manifest is generated here instead -const tarballs = new Map() -for (const p of selected) { - console.log(`› Packing ${p.name}@${p.version}`) - const before = new Set(readdirSync(tgz)) - if (isOclif(p)) run('pnpm', ['exec', 'oclif', 'manifest'], { cwd: p.path, stdio: 'ignore' }) - try { - run('pnpm', ['pack', '--ignore-scripts', '--pack-destination', tgz], { cwd: p.path, stdio: 'ignore' }) - } finally { - if (isOclif(p)) rmSync(join(p.path, 'oclif.manifest.json'), { force: true }) - } - tarballs.set(p.name, `file:${join(tgz, readdirSync(tgz).find((f) => !before.has(f)))}`) -} - -// The workspace libraries come from the tarballs, also for the plugins -const overrides = Object.fromEntries([...selected].filter((p) => !isOclif(p)).map((p) => [p.name, tarballs.get(p.name)])) -const install = (dir, manifest) => { - mkdirSync(dir, { recursive: true }) - writeFileSync(join(dir, 'package.json'), `${JSON.stringify({ private: true, ...manifest, overrides }, null, 2)}\n`) - run('npm', ['install', '--no-audit', '--no-fund', '--loglevel=error'], { cwd: dir }) -} - -console.log(`› Installing ${cli.name} in ${join(OUT, 'cli')}`) -install(join(OUT, 'cli'), { name: 'release-try-cli', dependencies: { [cli.name]: tarballs.get(cli.name) } }) - -console.log(`› Installing ${plugins.length} plugin(s) as user plugins in ${join(OUT, 'data')}`) -install(join(OUT, 'data'), { - name: 'release-try-plugins', - oclif: { schema: 1, plugins: plugins.map((p) => ({ name: p.name, type: 'user', tag: 'latest' })) }, - dependencies: Object.fromEntries(plugins.map((p) => [p.name, tarballs.get(p.name)])), -}) - -// The sandbox's own entry point and folders -const bin = join(OUT, 'commercelayer') -writeFileSync( - bin, - `#!/bin/sh -# The CLI and plugins of release-try, with the sandbox's own config, data and cache -SANDBOX="${OUT}" -export COMMERCELAYER_DATA_DIR="$SANDBOX/data" COMMERCELAYER_CONFIG_DIR="$SANDBOX/config" COMMERCELAYER_CACHE_DIR="$SANDBOX/cache" -export XDG_CONFIG_HOME="$SANDBOX/xdg-config" -exec node "$SANDBOX/cli/node_modules/${cli.name}/bin/run.js" "$@" -`, -) -chmodSync(bin, 0o755) - -console.log(`\n✓ ${cli.name}@${cli.version} with ${plugins.map((p) => `${p.dir}@${p.version}`).join(', ')}`) -console.log(` Try it: ${bin} --version`) diff --git a/scripts/resolve-tag.mjs b/scripts/resolve-tag.mjs deleted file mode 100644 index be7f03f9d..000000000 --- a/scripts/resolve-tag.mjs +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env node -/** - * Resolves a release tag `-v` to its package, as key=value lines - * for $GITHUB_OUTPUT. Fails loudly when the tag does not belong to a public - * workspace package or its version does not match that package.json. - * - * dir, path, package, version, prerelease, dist_tag, previous_tag - * - * previous_tag is the package's closest lower release, so the generated notes - * cover this package's range instead of whichever tag GitHub finds first: - * for a stable release the previous stable one, for a prerelease the previous - * release of any kind. - * - * Usage: node scripts/resolve-tag.mjs - */ -import { previousRelease, resolveTag } from './lib/workspace.mjs' - -const tag = process.argv[2] -const pkg = resolveTag(tag ?? '') -if (pkg.error) { - console.error(`::error::${pkg.error}`) - process.exit(1) -} - -const previous = previousRelease(pkg) - -const out = { - dir: pkg.dir, - path: pkg.path, - package: pkg.name, - version: pkg.version, - prerelease: pkg.prerelease, - dist_tag: pkg.distTag, - previous_tag: previous ?? '', -} -for (const [k, v] of Object.entries(out)) console.log(`${k}=${v}`) diff --git a/scripts/tag.mjs b/scripts/tag.mjs deleted file mode 100644 index b6276fb34..000000000 --- a/scripts/tag.mjs +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env node -/** - * Tags every public package whose package.json version has no release yet. - * - * Run it on an up-to-date base branch after merging a `chore(release)` PR - * (see scripts/finish-version.mjs). For each package it creates the annotated tag - * `-v` on HEAD, unless that tag already exists or the version is - * already on npm, and then pushes the new tags. Each pushed tag makes - * release.yml draft a GitHub release; publishing the draft publishes to npm. - * - * GitHub does not trigger workflows when more than three tags are pushed at - * once, so tags are pushed one at a time. - * - * Usage: node scripts/tag.mjs [--base ] [--no-push] [--dry-run] - */ -import { execFileSync } from 'node:child_process' -import { git, publicPackages, tagOf } from './lib/workspace.mjs' - -const args = process.argv.slice(2) -const DRY = args.includes('--dry-run') -const NO_PUSH = args.includes('--no-push') -const BASE = args.includes('--base') ? args[args.indexOf('--base') + 1] : 'main' - -const fail = (msg) => { - console.error(`\n✖ ${msg}\n`) - process.exit(1) -} - -if (git('status', '--porcelain')) fail('Working tree is not clean.') -git('fetch', '--quiet', 'origin', BASE, '--tags') -if (git('rev-parse', 'HEAD') !== git('rev-parse', `origin/${BASE}`)) fail(`HEAD is not origin/${BASE}. Check out an up-to-date ${BASE} first.`) - -const onNpm = (name, version) => { - try { - return execFileSync('npm', ['view', `${name}@${version}`, 'version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() === version - } catch { - return false - } -} - -const existing = new Set(git('tag', '--list', '*-v*').split('\n')) -const created = [] -for (const pkg of publicPackages()) { - const tag = tagOf(pkg) - if (existing.has(tag)) continue - if (onNpm(pkg.name, pkg.version)) { - console.log(`· ${tag}: ${pkg.name}@${pkg.version} is already on npm, not tagging`) - continue - } - console.log(`+ ${tag}`) - if (!DRY) git('tag', '--annotate', tag, '--message', `${pkg.name} v${pkg.version}`) - created.push(tag) -} - -if (created.length === 0) { - console.log('Nothing to tag.') - process.exit(0) -} -if (DRY) { - console.log('(dry run: no tag created)') - process.exit(0) -} -if (NO_PUSH) { - console.log(`\nCreated ${created.length} tag(s). Push each with \`git push origin \`.`) - process.exit(0) -} -for (const tag of created) git('push', '--quiet', 'origin', `refs/tags/${tag}`) -console.log(`\n✓ Pushed ${created.length} tag(s). Review and publish the draft releases on GitHub to publish to npm.`)