Skip to content
Closed
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
5 changes: 0 additions & 5 deletions .changeset/tame-pandas-obey.md

This file was deleted.

6 changes: 6 additions & 0 deletions .github/workflows/force-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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" <<EOF
Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,13 @@ commitlint + dprint + oxlint + vitest + stryker + OIDC npm release.

1. **Use this template** on GitHub → clone your new repo.
2. Replace every `TODO` (`packages/starter/package.json` name/author/description,
`.github/workflows/force-release.yml`, `.changeset/ledger.yaml`).
3. Rename `packages/starter` to your package; update `repository.directory`.
`.github/workflows/force-release.yml`).
3. Claim the package: rename `packages/starter` to your package, drop
`"private": true` from its `package.json`, and update `repository.directory`.
4. Register the package as npm trusted publisher (repo + `release.yml` workflow)
before first publish — see `.changeset/README.md`.
5. `pnpm install && pnpm gate:tasks && pnpm gate:dist`.

While the package is still `private`, the release pipeline is inert: `Release`
plans no phase, `Changeset Check` ignores `packages/` edits, and `Force Release`
refuses the dispatch. Step 3 is what arms it.
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
---
title: A template repository must ship its release pipeline inert
date: 2026-09-10
category: tooling-decisions
module: starter template toolchain
problem_type: tooling_decision
component: tooling
severity: medium
applies_when:
- a repository is distributed as a template, scaffold, or starting point
- a CI pipeline derives its work from the state of the workspace it runs in
- a placeholder package exists before any real package does
tags: [template-repo, release-gate, placeholder-package, changeset-intent]
---

# A template repository must ship its release pipeline inert

## Context

The template repository ran its own release pipeline on every push to the default
branch. The workspace held one placeholder package, a reserved name and version
carrying publish metadata but no real content, and the pipeline read it as a real
release target. The phase planner computed an owed release for the untagged
placeholder version, selected the publish phase, and the publish job stopped at the
missing changelog for a version that had never been versioned.

The boundary condition is a workspace whose packages are all placeholders: a
repository just generated from the template, before the consumer claims a package.
Released repositories never reach it, because their released versions carry tags.

Residual: a repository that has already been de-templated still owes a release for
its never-versioned initial version, for the same reason — tag absence alone marks a
version owed. That bootstrap gap needs its own rule (a release is owed only when its
changelog artifact exists) and is deliberately not addressed here.

## Mechanism

1. Release-cycle enumeration filtered only on the manifest's `private` flag, so a
public placeholder entered the cycle.
2. Cycle membership was decided by tag absence alone: any workspace version without
a matching remote tag counted as owed.
3. The phase decision short-circuits on owed, so `owed > 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`
1 change: 1 addition & 0 deletions packages/starter/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 3 additions & 6 deletions scripts/check-changeset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, true> = { none: true, patch: true, minor: true, major: true }
Expand All @@ -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
}
Expand Down
21 changes: 11 additions & 10 deletions scripts/lib/cycle.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -8,23 +9,18 @@ export type CycleEntry = {
changelog: string
}

type Pkg = {
name?: string
version?: string
private?: boolean
}

export const loadWorkspaceCycle = async (): Promise<CycleEntry[]> => {
const pkgs = JSON.parse(await run('pnpm', ['ls', '-r', '--json', '--depth=-1'])) as Pkg[]
const remote = new Set(
export const remoteTags = async (): Promise<Set<string>> =>
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<string>): 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({
Expand All @@ -37,6 +33,11 @@ export const loadWorkspaceCycle = async (): Promise<CycleEntry[]> => {
return cycle
}

export const loadWorkspaceCycle = async (): Promise<CycleEntry[]> => {
const [pkgs, remote] = await Promise.all([loadWorkspacePackages(), remoteTags()])
return cycleOf(pkgs, remote)
}

export const loadCaptured = async (path: string): Promise<CycleEntry[]> => {
const raw: unknown = JSON.parse(await Deno.readTextFile(path))
if (!Array.isArray(raw)) throw new Error('captured file must be a JSON array')
Expand Down
13 changes: 13 additions & 0 deletions scripts/lib/packages.ts
Original file line number Diff line number Diff line change
@@ -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<Pkg[]> =>
JSON.parse(await run('pnpm', ['ls', '-r', '--json', '--depth=-1'])) as Pkg[]
22 changes: 17 additions & 5 deletions scripts/plan-release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down