): 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.`)