Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions .github/workflows/preview.yml
Original file line number Diff line number Diff line change
@@ -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 <url>` for the CLI,
# `commercelayer plugins:install <url>` 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")
'
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,6 @@ test_data*
coverage
temp/
*.tgz

# pnpm release:try sandbox
/.release-try/
13 changes: 0 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<dir>-v<version>`, where `<dir>` 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 <id>` for a prerelease (`x.y.z-<id>.n`, published under the `<id>` 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:<dir>` 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)
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 25 additions & 0 deletions packages/cli/src/hooks/prerun/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {

Expand All @@ -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
Expand All @@ -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 = ''
Expand All @@ -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-<name>-<version>.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<string> => {

const installed = getInstalledPlugins(config)
Expand Down Expand Up @@ -114,3 +138,4 @@ const promptPlugin = async (config: Config, command: string): Promise<string> =>


export default hook
export { testBuildPlugin }
23 changes: 23 additions & 0 deletions packages/cli/test/hooks/prerun/plugin.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
21 changes: 8 additions & 13 deletions scripts/finish-version.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>] [--interactive | --yes] [--base <branch>] [--no-pr] [--dry-run]
* --preid <id> bump to a prerelease (x.y.z-<id>.n) instead of a stable version
* Usage: node scripts/finish-version.mjs [--interactive | --yes] [--base <branch>] [--no-pr] [--dry-run]
* --interactive confirm, change or skip each package's version
* --yes no confirmation at all
* --base <b> branch the release PR targets (default: main)
Expand All @@ -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) => {
Expand All @@ -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, -<preid>.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
Expand Down Expand Up @@ -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) {
Expand All @@ -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 {
Expand Down
8 changes: 3 additions & 5 deletions scripts/lib/workspace.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
Expand Down
107 changes: 107 additions & 0 deletions scripts/release-try.mjs
Original file line number Diff line number Diff line change
@@ -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
* <sandbox>/cli, and the plugins in its data folder (<sandbox>/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 [<plugin dir>...] [--out <dir>]
* <plugin dir> plugins to install, e.g. tags orders (default: all)
* --out <dir> sandbox folder (default: .release-try)
*
* Then run the CLI with <sandbox>/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`)
Loading