diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 00000000..5f00be16 --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,62 @@ +name: Preview + +# Publishes a test build of the CLI and plugins to pkg.pr.new, on demand: +# nothing reaches npm, but anyone can install it to try a change (only +# stable versions are released to npm). cli-core and cli-ux are always +# included, and the previews depend on each other's preview URLs, so +# unreleased workspace versions work. +# +# Run it from the Actions tab on the branch to try; the run summary lists the +# install commands: `npm install -g ` for the CLI, +# `commercelayer plugins:install ` for a plugin. +on: + workflow_dispatch: + inputs: + plugins: + description: 'Plugins to preview, by folder (e.g. "tags orders"), or "all"' + required: true + default: all + +permissions: + contents: read + +jobs: + preview: + name: Publish a preview to pkg.pr.new + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: { node-version: 'lts/*', cache: 'pnpm' } + - run: pnpm install --frozen-lockfile + - run: pnpm build + + - name: Publish the preview + env: + PLUGINS: ${{ inputs.plugins }} + run: | + set -euo pipefail + if [ "$PLUGINS" = all ]; then + PATHS=(./plugins/*) + else + PATHS=() + for p in $PLUGINS; do + [ -d "plugins/$p" ] || { echo "::error::No plugin in plugins/$p"; exit 1; } + PATHS+=("./plugins/$p") + done + fi + pnpm dlx pkg-pr-new publish --pnpm --comment=off --json preview.json ./packages/cli ./packages/core ./packages/ux "${PATHS[@]}" + + - name: Install commands + run: | + node -e ' + const { packages = [] } = require("./preview.json") + const lines = ["## Preview, not released on npm", "", "| Package | Install |", "| --- | --- |"] + for (const { name, url } of packages) { + if (/cli-(core|ux)$/.test(name)) continue + lines.push(`| ${name} | \`${name === "@commercelayer/cli" ? "npm install -g" : "commercelayer plugins:install"} ${url}\` |`) + } + lines.push("", "Install the preview CLI first: the preview plugins need its version.") + require("fs").appendFileSync(process.env.GITHUB_STEP_SUMMARY, lines.join("\n") + "\n") + ' diff --git a/.gitignore b/.gitignore index cc44b496..bdfbf3c7 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ test_data* coverage temp/ *.tgz + +# pnpm release:try sandbox +/.release-try/ diff --git a/README.md b/README.md index 78bec673..2c3da359 100644 --- a/README.md +++ b/README.md @@ -74,19 +74,6 @@ Some plugins generate part of their code: `triggers` and `orders` generate a com Don't edit generated files by hand: change the templates (`gen/templates`) or the generator, then regenerate. -## Releasing - -Every package is versioned and released on its own. A release starts from a tag `-v`, where `` is the package's directory (`cli-v6.10.0`, `core-v5.12.0`, `orders-v5.7.0`). - -1. **Bump**: on an up-to-date `main`, run `pnpm release:version`. Only packages with commits touching their folder since their last tag are released. Each one's version is derived from those commits (breaking → major, `feat` → minor, anything else → patch), and you confirm the whole plan once. Use `--interactive` to change or skip single packages, `--yes` to skip the confirmation, `--preid ` for a prerelease (`x.y.z-.n`, published under the `` dist-tag). The bumps go on a `release/…` branch and a `chore(release)` pull request. Packages depending on a released one aren't bumped: they pick it up through their `^` range. -2. **Tag**: after merging it, on an up-to-date `main` run `pnpm release:tag`. It tags the merge commit for every package whose version isn't released yet and pushes the tags. -3. **Draft**: each tag makes [release.yml](.github/workflows/release.yml) draft a GitHub release, with notes from the Conventional Commits that touched that package since its previous release. -4. **Publish**: publishing the draft makes [publish.yml](.github/workflows/publish.yml) build and test the package from the tag, check its command surface against npm, publish it to npm with provenance and announce it on Slack. - -Internal dependencies need no manual step. When a released package uses unreleased changes of a workspace dependency (`cli-core`, `cli-ux`, …), `pnpm release:version` releases the dependency in the same PR, even if you skip it with `--interactive`. `publish.yml` then publishes the dependency to npm before the package, and marks the dependency's draft release as published. - -PRs get a `pkg:` label from the files they touch. Each release's notes are written from the Conventional Commits that touched the package's folder since its previous release (`scripts/release-notes.mjs`). After adding or removing a package, run `pnpm release:config` and commit the generated `.github/labeler.yml`. - ## License [MIT](LICENSE) diff --git a/package.json b/package.json index ef82269f..f75169e8 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "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" + "lint:commits": "commitlint --from origin/main --to HEAD --verbose", + "release:try": "node scripts/release-try.mjs" }, "devDependencies": { "@biomejs/biome": "^2.5.14", diff --git a/packages/cli/src/hooks/prerun/plugin.ts b/packages/cli/src/hooks/prerun/plugin.ts index efbf60d5..1491cf2e 100644 --- a/packages/cli/src/hooks/prerun/plugin.ts +++ b/packages/cli/src/hooks/prerun/plugin.ts @@ -43,6 +43,7 @@ const hook: Hook<'prerun'> = async function (opts) { let index = -1 let plugin: string = '' let pluginArg: string = '' + let testBuild: string | undefined const found = opts.argv.some(a => { @@ -51,6 +52,14 @@ const hook: Hook<'prerun'> = async function (opts) { if (opts.argv[index - 1] === '--tag') return false // ignore --tag value pluginArg = a + // A test build, not released on npm: installed as it is + if (command === 'install') { + testBuild = testBuildPlugin(a) + if (testBuild) { + plugin = a + return true + } + } const p = getPluginInfo(pluginArg) if (p === undefined) this.error(`Unknown Commerce Layer CLI plugin: ${clColor.msg.error(a)}. Run '${clColor.italic(`${this.config.bin} plugins:available`)}' to get a list of all available plugins`) else plugin = p.plugin @@ -60,6 +69,8 @@ const hook: Hook<'prerun'> = async function (opts) { }) + if (found && testBuild) this.log(`\nInstalling a test build of ${clColor.cli.plugin(testBuild)}, not released on npm: ${clColor.italic(plugin)}\n`) + else if (found && plugin) { let errMsg: string = '' @@ -86,6 +97,19 @@ const hook: Hook<'prerun'> = async function (opts) { } +/** + * The plugin a test build installs, when the argument is one: a pkg.pr.new + * preview URL (preview.yml) or a local tarball (file:…/commercelayer-cli-plugin--.tgz, + * from `pnpm release:try` or `pnpm pack`) of a known Commerce Layer CLI plugin + */ +const testBuildPlugin = (arg: string): string | undefined => { + const preview = /^https:\/\/pkg\.pr\.new\/(?:[\w.-]+\/){0,2}(@commercelayer\/cli-plugin-[a-z-]+)@[\w.-]+$/.exec(arg)?.[1] + const tarball = /^file:(?:.*[\\/])?commercelayer-cli-plugin-([a-z-]+?)-\d+\.\d+\.\d+[\w.-]*\.tgz$/.exec(arg)?.[1] + const name = preview ?? (tarball ? `@commercelayer/cli-plugin-${tarball}` : undefined) + return name && getPluginInfo(name) ? name : undefined +} + + const promptPlugin = async (config: Config, command: string): Promise => { const installed = getInstalledPlugins(config) @@ -114,3 +138,4 @@ const promptPlugin = async (config: Config, command: string): Promise => export default hook +export { testBuildPlugin } diff --git a/packages/cli/test/hooks/prerun/plugin.test.ts b/packages/cli/test/hooks/prerun/plugin.test.ts new file mode 100644 index 00000000..c0df9ae4 --- /dev/null +++ b/packages/cli/test/hooks/prerun/plugin.test.ts @@ -0,0 +1,23 @@ +import { expect } from 'chai' +import { testBuildPlugin } from '../../../src/hooks/prerun/plugin' + +describe('hooks:prerun:plugin', () => { + describe('test builds', () => { + it('recognizes a pkg.pr.new preview of a known plugin', () => { + expect(testBuildPlugin('https://pkg.pr.new/commercelayer/commercelayer-cli/@commercelayer/cli-plugin-tags@5259929')).to.equal('@commercelayer/cli-plugin-tags') + expect(testBuildPlugin('https://pkg.pr.new/@commercelayer/cli-plugin-orders@a1b2c3d')).to.equal('@commercelayer/cli-plugin-orders') + }) + + it('recognizes a local tarball of a known plugin', () => { + expect(testBuildPlugin('file:/tmp/try/commercelayer-cli-plugin-webhooks-5.0.0.tgz')).to.equal('@commercelayer/cli-plugin-webhooks') + expect(testBuildPlugin('file:./commercelayer-cli-plugin-imports-5.0.0-beta.0.tgz')).to.equal('@commercelayer/cli-plugin-imports') + }) + + it('rejects unknown plugins and other hosts', () => { + expect(testBuildPlugin('https://pkg.pr.new/@commercelayer/cli-plugin-unicorn@a1b2c3d')).to.equal(undefined) + expect(testBuildPlugin('file:/tmp/commercelayer-cli-plugin-unicorn-1.0.0.tgz')).to.equal(undefined) + expect(testBuildPlugin('https://example.com/@commercelayer/cli-plugin-tags@1')).to.equal(undefined) + expect(testBuildPlugin('tags')).to.equal(undefined) + }) + }) +}) diff --git a/scripts/finish-version.mjs b/scripts/finish-version.mjs index 8be91436..bd68e585 100644 --- a/scripts/finish-version.mjs +++ b/scripts/finish-version.mjs @@ -22,8 +22,7 @@ * 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 [--preid ] [--interactive | --yes] [--base ] [--no-pr] [--dry-run] - * --preid bump to a prerelease (x.y.z-.n) instead of a stable version + * 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) @@ -45,7 +44,6 @@ const DRY = flag('--dry-run') const YES = flag('--yes') const INTERACTIVE = flag('--interactive') const NO_PR = flag('--no-pr') -const PREID = option('--preid') const BASE = option('--base', 'main') const fail = (msg) => { @@ -58,15 +56,12 @@ 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, optionally as a prerelease: - * - from a prerelease: the same preid only moves the counter (x.y.z-beta.1 -> - * x.y.z-beta.2), another one restarts it (x.y.z-rc.0), none releases x.y.z - * - from a stable version: x.y.z bumped by level, -.0 with a preid + * 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, preid) => { - if (semver.prerelease(version)) return preid ? semver.inc(version, 'prerelease', preid) : semver.inc(version, 'release') - return preid ? semver.inc(version, `pre${level}`, preid) : semver.inc(version, level) -} +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 @@ -98,7 +93,7 @@ for (const pkg of publicPackages()) { .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, PREID) }) + candidates.push({ pkg, since, commits, level, why, next: bump(pkg.version, level) }) } if (candidates.length === 0) { @@ -124,7 +119,7 @@ if (INTERACTIVE) { 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)) fail(`'${version}' is not a valid version`) + if (!semver.valid(version) || semver.prerelease(version)) fail(`'${version}' is not a valid stable version (prereleases aren't published)`) selected.push({ ...c, version }) } } else { diff --git a/scripts/lib/workspace.mjs b/scripts/lib/workspace.mjs index 9444923d..40e8b18f 100644 --- a/scripts/lib/workspace.mjs +++ b/scripts/lib/workspace.mjs @@ -59,11 +59,9 @@ export const resolveTag = (tag) => { 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})` } - // `6.0.0-beta.3` -> `beta`; a bare numeric prerelease (`6.0.0-0`) -> `next` - const [preid] = semver.prerelease(version) ?? [] - const prerelease = preid !== undefined - const distTag = prerelease ? (typeof preid === 'number' ? 'next' : preid) : 'latest' - return { ...pkg, tag, prerelease, distTag } + // 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}` diff --git a/scripts/release-try.mjs b/scripts/release-try.mjs new file mode 100644 index 00000000..944474ff --- /dev/null +++ b/scripts/release-try.mjs @@ -0,0 +1,107 @@ +#!/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`)