diff --git a/README.md b/README.md index de0cef8..2a9aa0d 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ uppt runs your package's lifecycle scripts at one specific point and skips them uppt supports lockstep monorepos: every publishable package shares a single version, gets bumped together, lands under one `vX.Y.Z` tag, and is staged in one workflow run. -Declare the publishable workspaces by passing the same `packages:` input to both `uppt/pr` and `uppt/pack`. Each line is a directory path or a glob; `!`-prefixed entries are excluded; workspaces whose `package.json` has `"private": true` are silently skipped (even when listed by an exact path), so playgrounds and example apps stay out of npm. +Declare the publishable workspaces by passing the same `packages:` input to `uppt/pr`, `uppt/release`, and `uppt/pack`. Each line is a directory path or a glob; `!`-prefixed entries are excluded; workspaces whose `package.json` has `"private": true` are silently skipped (even when listed by an exact path), so playgrounds and example apps stay out of npm. ```yaml pr: @@ -198,6 +198,16 @@ Declare the publishable workspaces by passing the same `packages:` input to both packages/* !packages/playground + release: + # ... + steps: + - uses: danielroe/uppt/release@3a4fd445ce266b91dd73ced7ae8140cc0f9fc19c # v0.5.2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + packages: | + packages/* + !packages/playground + pack: # ... steps: @@ -211,7 +221,7 @@ Declare the publishable workspaces by passing the same `packages:` input to both The lockstep version comes from the workspaces themselves: every listed package must agree on a single semver `version`, and that's the version uppt bumps from. The root `package.json#version` (if present) is only bumped when it already matches the lockstep version, so a `0.0.0` or absent root version is left untouched. > [!IMPORTANT] -> The `packages:` value on `uppt/pr` and `uppt/pack` must match. If they diverge, the release PR and the published tarballs will cover different sets of packages. +> The `packages:` value on `uppt/pr`, `uppt/release`, and `uppt/pack` must match. If they diverge, the release PR, the tag, and the published tarballs will cover different sets of packages (and `uppt/release` will tag the wrong version: a private `0.0.0` root with no `packages:` input would otherwise be tagged `v0.0.0`). > [!IMPORTANT] > If you use pnpm, every workspace you list under `packages:` must also be listed in your `pnpm-workspace.yaml`. `pnpm pack` resolves `workspace:` and `catalog:` specifiers via the workspace graph, so a directory missing from `pnpm-workspace.yaml` will produce a tarball with unresolved specifiers (or fail outright). diff --git a/release/action.yml b/release/action.yml index d27c70f..f4f0511 100644 --- a/release/action.yml +++ b/release/action.yml @@ -23,6 +23,10 @@ inputs: description: 'Whether the action should run `actions/checkout` itself. Set to `false` if the caller has already checked out `github.event.pull_request.merge_commit_sha` with `fetch-depth: 0`.' required: false default: 'true' + packages: + description: 'Newline-separated list of publishable workspace directories, relative to the repo root. Each line is a path or glob (e.g. `packages/*`); `!`-prefixed entries are excluded; workspaces with `"private": true` are skipped. Must match the value passed to `uppt/pr`. Omit for single-package repos.' + required: false + default: '' runs: using: composite @@ -75,4 +79,5 @@ runs: GITHUB_TOKEN: ${{ inputs.token }} PR_BODY: ${{ github.event.pull_request.body }} PUBLISH_WORKFLOW: ${{ inputs.publish-workflow }} + PACKAGES: ${{ inputs.packages }} run: node --experimental-strip-types ${{ github.action_path }}/../scripts/tag-and-release.ts diff --git a/scripts/_workspaces.ts b/scripts/_workspaces.ts index 5f8b952..dbf2aed 100644 --- a/scripts/_workspaces.ts +++ b/scripts/_workspaces.ts @@ -177,3 +177,23 @@ export function lockstepVersionFromWorkspaces (workspaces: Workspace[]): string export function isSemver (value: string): boolean { return /^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(value) } + +/** + * Resolve the version uppt should act on, the single place both `uppt/pr` + * (bump source) and `uppt/release` (tag source) agree on so they can never + * drift apart. + */ +export function resolveCurrentVersion (rootDir: string, packagesInput: string): string { + if (packagesInput.length > 0) { + return lockstepVersionFromWorkspaces(resolveWorkspaces(rootDir, packagesInput)) + } + + const pkg = JSON.parse(readFileSync(resolve(rootDir, 'package.json'), 'utf8')) as RawPackageJson + if (pkg.version === '0.0.0' && pkg.private === true) { + throw new Error('Refusing to act on a private root package.json pinned to 0.0.0. This looks like a monorepo: pass the same `packages` input that `uppt/pr` uses.') + } + if (typeof pkg.version !== 'string') { + throw new Error('Cannot determine version: root package.json has no `version` field. Set one, or pass the `packages` input to release a monorepo.') + } + return pkg.version +} diff --git a/scripts/tag-and-release.ts b/scripts/tag-and-release.ts index 7920245..8c4ee7f 100644 --- a/scripts/tag-and-release.ts +++ b/scripts/tag-and-release.ts @@ -10,11 +10,12 @@ // GITHUB_REPOSITORY "owner/repo" (set automatically inside Actions) // PR_BODY PR body, used verbatim as release notes // PUBLISH_WORKFLOW workflow filename to dispatch (default: release.yml) +// PACKAGES newline-separated list of publishable workspace +// dirs/globs (monorepo); omit for single-package repos import process from 'node:process' import { execFileSync } from 'node:child_process' -import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { isSemver, resolveCurrentVersion } from './_workspaces.ts' function run (cmd: string, args: string[], opts: { env?: NodeJS.ProcessEnv } = {}) { execFileSync(cmd, args, { stdio: 'inherit', env: { ...process.env, ...opts.env } }) @@ -46,14 +47,13 @@ function main () { const repo = process.env.GITHUB_REPOSITORY if (!repo || !repo.includes('/')) throw new Error('GITHUB_REPOSITORY is required') - const pkgPath = resolve(process.cwd(), 'package.json') - const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { version: string } - // `pkg.version` flows into ref names and `gh` argv. Pin to strict semver to + const version = resolveCurrentVersion(process.cwd(), process.env.PACKAGES?.trim() ?? '') + // `version` flows into ref names and `gh` argv. Pin to strict semver to // rule out flag-injection (`--upload-pack=...`) and ref-confusion attacks. - if (!/^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(pkg.version)) { - throw new Error(`Refusing to tag: package.json version "${pkg.version}" is not strict semver`) + if (!isSemver(version)) { + throw new Error(`Refusing to tag: resolved version "${version}" is not strict semver`) } - const tag = `v${pkg.version}` + const tag = `v${version}` const ghEnv = { GH_TOKEN: token } if (tagExists(repo, tag, ghEnv)) { diff --git a/scripts/update-changelog.ts b/scripts/update-changelog.ts index 0415d01..92d4909 100644 --- a/scripts/update-changelog.ts +++ b/scripts/update-changelog.ts @@ -24,7 +24,7 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { makePkgFormatter } from './pkg-format.ts' -import { lockstepVersionFromWorkspaces, resolveWorkspaces, type Workspace } from './_workspaces.ts' +import { resolveCurrentVersion, resolveWorkspaces, type Workspace } from './_workspaces.ts' interface Commit { shortHash: string @@ -518,12 +518,7 @@ async function main () { const rootPkgSource = readFileSync(rootPkgPath, 'utf8') const rootPkg = JSON.parse(rootPkgSource) - const currentVersion = monorepo - ? lockstepVersionFromWorkspaces(workspaces) - : rootPkg.version - if (typeof currentVersion !== 'string') { - throw new Error('Cannot determine current version: root package.json has no `version` field. Set one, or use the `packages` input to release a monorepo.') - } + const currentVersion = resolveCurrentVersion(process.cwd(), packagesInput) const bump = determineBump(commits) const newVersion = incVersion(currentVersion, bump) diff --git a/test/_workspaces.test.ts b/test/_workspaces.test.ts index 3d9a1cf..2d8e170 100644 --- a/test/_workspaces.test.ts +++ b/test/_workspaces.test.ts @@ -8,6 +8,7 @@ import { isSemver, lockstepVersionFromWorkspaces, parsePackagesInput, + resolveCurrentVersion, resolveWorkspaces, } from '../scripts/_workspaces.ts' @@ -231,3 +232,41 @@ describe('isSemver', () => { expect(isSemver('not-a-version')).toBe(false) }) }) + +describe('resolveCurrentVersion', () => { + it('reads the root version for a single-package repo', () => { + writePackage('.', { name: 'pkg', version: '1.2.3' }) + expect(resolveCurrentVersion(tmp, '')).toBe('1.2.3') + }) + + it('derives the lockstep version from workspaces, ignoring a private 0.0.0 root', () => { + writePackage('.', { name: 'monorepo', version: '0.0.0', private: true }) + writePackage('packages/a', { name: 'a', version: '0.14.2' }) + writePackage('packages/b', { name: 'b', version: '0.14.2' }) + expect(resolveCurrentVersion(tmp, 'packages/*')).toBe('0.14.2') + }) + + it('skips private workspaces when resolving the lockstep version', () => { + writePackage('.', { name: 'monorepo', version: '0.0.0', private: true }) + writePackage('packages/a', { name: 'a', version: '0.14.2' }) + writePackage('packages/playground', { name: 'playground', version: '0.0.0', private: true }) + expect(resolveCurrentVersion(tmp, 'packages/*')).toBe('0.14.2') + }) + + it('refuses to act on a private 0.0.0 root with no packages input', () => { + writePackage('.', { name: 'monorepo', version: '0.0.0', private: true }) + expect(() => resolveCurrentVersion(tmp, '')).toThrow(/Refusing to act.*0\.0\.0.*monorepo.*packages/s) + }) + + it('throws when workspaces disagree on a version', () => { + writePackage('.', { name: 'monorepo', version: '0.0.0', private: true }) + writePackage('packages/a', { name: 'a', version: '0.14.2' }) + writePackage('packages/b', { name: 'b', version: '0.15.0' }) + expect(() => resolveCurrentVersion(tmp, 'packages/*')).toThrow(/do not agree on a single version/) + }) + + it('throws when the root has no version and no packages input', () => { + writePackage('.', { name: 'pkg' }) + expect(() => resolveCurrentVersion(tmp, '')).toThrow(/no `version` field/) + }) +})