diff --git a/.changeset/tame-pandas-obey.md b/.changeset/tame-pandas-obey.md deleted file mode 100644 index 9210bd4c..00000000 --- a/.changeset/tame-pandas-obey.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@TODO/starter": none ---- - -No changes affecting use of the package. diff --git a/.github/workflows/force-release.yml b/.github/workflows/force-release.yml index 1279a879..0d3ffc9f 100644 --- a/.github/workflows/force-release.yml +++ b/.github/workflows/force-release.yml @@ -27,6 +27,12 @@ jobs: - uses: actions/checkout@v7 with: fetch-depth: 0 + - name: Guard · refuse a workspace with no publishable package + run: | + if [ "$(jq -r 'select(.private != true) | .name' packages/*/package.json | grep -c .)" -eq 0 ]; then + echo "::error::every packages/*/package.json is private; drop \"private\": true and rename the placeholder before forcing a release" + exit 1 + fi - name: Write dummy changeset run: | cat > ".changeset/force-${{ github.run_id }}-${{ inputs.bump }}.md" < 0` always beat pending + change intents. The version job could never run first, and so could never produce + the changelog the publish job asserts. +4. The changeset gate demanded a change intent for any edit under the package tree, + because it used the same `private`-only filter. The repository satisfied it with a + standing no-release intent — a gate certifying nothing. + +$$\text{released}(p) = \text{publishable}(p) \;\wedge\; \text{tagged}(p)$$ + +The pipeline evaluated $\text{tagged}$ alone. + +## Why This Works + +**Invariant: a release target is a package that is publishable by intent.** Template +placeholders and private tooling packages are not release targets. Every gate that +selects release work must agree on one predicate, and the manifest's `private` flag +is that predicate: it is also what the package manager honors, so the pipeline and +the publish path cannot disagree. + +Corollary: the phase decision must never select publish work for a workspace with no +publishable package, even when change intents are pending. Publishability is checked +before owed-ness, not after. + +Anti-pattern code smell: the package-publishability test written inline in more than +one workspace query. Two copies drift; the shared predicate is the fix. + +## Verification + +Verification is behavioral, at the seam the pipeline itself uses: run the phase +planner and the changeset gate against the workspace and read their verdicts. Both +directions must be exercised — an all-private workspace yields no phase and no intent +demand; a single non-private package yields the pending phase. The CI jobs that call +them are the standing gate. + +No unit test is proposed for these scripts. A test that spawns the planner or the +gate runs them as child processes, which the test-layer admission gate refuses +outright; the scripts' own runs plus the CI jobs are the observable contract. + +## When to Apply + +- Authoring or copying a release pipeline into a template, scaffold, or example + repository. +- A placeholder package exists in a workspace whose CI derives work from workspace + state. +- Adding a second gate that selects packages for release. + +## Related + +- `docs/solutions/tooling-decisions/self-name-imports-type-aware-lint.md` diff --git a/packages/starter/package.json b/packages/starter/package.json index a48d1935..f867b620 100644 --- a/packages/starter/package.json +++ b/packages/starter/package.json @@ -24,6 +24,7 @@ "license": "Apache-2.0", "name": "@TODO/starter", "omp": { "extensions": ["./dist/index.js"] }, + "private": true, "publishConfig": { "exports": { ".": "./dist/index.js", "./package.json": "./package.json" }, "provenance": true }, "scripts": { "build": "rimraf dist && tsdown", diff --git a/scripts/check-changeset.ts b/scripts/check-changeset.ts index a69ac2e4..d0290a6c 100755 --- a/scripts/check-changeset.ts +++ b/scripts/check-changeset.ts @@ -4,6 +4,7 @@ import { withoutAll } from '@std/collections/without-all' import { extractYaml, test } from '@std/front-matter' import { expandGlob } from '@std/fs/expand-glob' import { basename } from '@std/path' +import { isPublishablePackage, type Pkg } from './lib/packages.ts' import { run } from './lib/run.ts' const BUMP: Record = { none: true, patch: true, minor: true, major: true } @@ -18,12 +19,8 @@ const intentPackages = (markdown: string) => { const publicPackages = async () => { const names: string[] = [] for await (const file of expandGlob('packages/*/package.json')) { - const pkg = JSON.parse(await Deno.readTextFile(file.path)) as { - name?: string - version?: string - private?: boolean - } - if (pkg.name && pkg.version && !pkg.private) names.push(pkg.name) + const pkg = JSON.parse(await Deno.readTextFile(file.path)) as Pkg + if (isPublishablePackage(pkg)) names.push(pkg.name) } return names } diff --git a/scripts/lib/cycle.ts b/scripts/lib/cycle.ts index f043bcfb..7e36d0cb 100644 --- a/scripts/lib/cycle.ts +++ b/scripts/lib/cycle.ts @@ -1,4 +1,5 @@ import { join } from '@std/path' +import { isPublishablePackage, loadWorkspacePackages, type Pkg } from './packages.ts' import { run } from './run.ts' export type CycleEntry = { @@ -8,23 +9,18 @@ export type CycleEntry = { changelog: string } -type Pkg = { - name?: string - version?: string - private?: boolean -} - -export const loadWorkspaceCycle = async (): Promise => { - const pkgs = JSON.parse(await run('pnpm', ['ls', '-r', '--json', '--depth=-1'])) as Pkg[] - const remote = new Set( +export const remoteTags = async (): Promise> => + new Set( (await run('git', ['ls-remote', '--tags', 'origin'])) .split('\n') .filter(Boolean) .map((line) => line.replace(/.*refs\/tags\//, '').replace(/\^\{\}$/, '')), ) + +export const cycleOf = (pkgs: Pkg[], remote: Set): CycleEntry[] => { const cycle: CycleEntry[] = [] for (const pkg of pkgs) { - if (!pkg.name || !pkg.version || pkg.private) continue + if (!isPublishablePackage(pkg)) continue const tag = `${pkg.name}@v${pkg.version}` if (remote.has(tag)) continue cycle.push({ @@ -37,6 +33,11 @@ export const loadWorkspaceCycle = async (): Promise => { return cycle } +export const loadWorkspaceCycle = async (): Promise => { + const [pkgs, remote] = await Promise.all([loadWorkspacePackages(), remoteTags()]) + return cycleOf(pkgs, remote) +} + export const loadCaptured = async (path: string): Promise => { const raw: unknown = JSON.parse(await Deno.readTextFile(path)) if (!Array.isArray(raw)) throw new Error('captured file must be a JSON array') diff --git a/scripts/lib/packages.ts b/scripts/lib/packages.ts new file mode 100644 index 00000000..3159c0c3 --- /dev/null +++ b/scripts/lib/packages.ts @@ -0,0 +1,13 @@ +import { run } from './run.ts' + +export type Pkg = { + name?: string + version?: string + private?: boolean +} + +export const isPublishablePackage = (pkg: Pkg): pkg is { name: string; version: string } => + Boolean(pkg.name && pkg.version && !pkg.private) + +export const loadWorkspacePackages = async (): Promise => + JSON.parse(await run('pnpm', ['ls', '-r', '--json', '--depth=-1'])) as Pkg[] diff --git a/scripts/plan-release.ts b/scripts/plan-release.ts index ecf83ecd..7f61323a 100755 --- a/scripts/plan-release.ts +++ b/scripts/plan-release.ts @@ -3,18 +3,30 @@ import { parseArgs } from '@std/cli/parse-args' import { expandGlob } from '@std/fs/expand-glob' import { basename } from '@std/path' -import { loadWorkspaceCycle } from './lib/cycle.ts' +import { cycleOf, remoteTags } from './lib/cycle.ts' +import { isPublishablePackage, loadWorkspacePackages } from './lib/packages.ts' let pending = 0 for await (const entry of expandGlob('.changeset/*.md')) { if (basename(entry.path) !== 'README.md') pending++ } -const owed = (await loadWorkspaceCycle()).length -const phase = owed > 0 ? 'publish' : pending > 0 ? 'version' : 'none' -const outputs = [`phase=${phase}`, `pending_intents=${pending}`, `this_cycle=${owed}`].join('\n') +const packages = await loadWorkspacePackages() +const publishable = packages.filter(isPublishablePackage).length +const owed = publishable === 0 ? 0 : cycleOf(packages, await remoteTags()).length -console.error(`plan-release: pending_intents=${pending} this_cycle=${owed} -> phase=${phase}`) +let phase = 'none' +if (publishable > 0 && owed > 0) phase = 'publish' +else if (publishable > 0 && pending > 0) phase = 'version' + +const outputs = [`phase=${phase}`, `pending_intents=${pending}`, `this_cycle=${owed}`, `publishable=${publishable}`] + .join('\n') + +console.error( + publishable === 0 + ? 'plan-release: no publishable package in the workspace -> phase=none' + : `plan-release: publishable=${publishable} pending_intents=${pending} this_cycle=${owed} -> phase=${phase}`, +) const { output } = parseArgs(Deno.args, { string: ['output'] }) if (output) await Deno.writeTextFile(output, `${outputs}\n`, { append: true })