From bba1ef4ecfec58b04f405fef7d727282a6448597 Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Fri, 31 Jul 2026 09:07:03 +0200 Subject: [PATCH 1/2] ci(release): publish tokenless on a published release, matching the siblings streamctl was the odd one out. sidebase/ssm-secrets and sidebase/nuxt-auth both trigger on a published GitHub Release and publish with no secret at all, using the OIDC token from `id-token: write` for npm trusted publishing. Four ssm-secrets releases and three nuxt-auth releases have shipped that way. This repo instead dispatched manually and read secrets.NPM_TOKEN, which does not exist here and which the org appears to have moved off deliberately. It also declared `environment: release` for an approval gate that was never configured: the repo has zero environments, no rulesets and no branch protection, and GitHub creates a referenced-but-missing environment unprotected and secretless, so the gate silently did nothing. Publishing the GitHub Release is now the human decision point, which is the sibling convention and needs no configuration to work. The tag becomes the source of truth for the version, so the workflow checks out the tag and refuses when the tag and package.json version disagree. That check matters more here than in the siblings: build.config.ts injects the version into the CLI's --version string, so a mismatch ships a CLI that misreports itself. Kept from the old workflow, deviating from the siblings on purpose: the full typecheck/test/lint/build gate plus publint and attw, because publishing is the only thing that runs against the tagged tree specifically; SHA-pinned actions, which is this repo's convention and has dependabot wired for it; and binding the tag to an env var rather than interpolating it into shell. One setup step remains before this can publish: @sidebase/streamctl needs a trusted publisher registered on npmjs.com for sidebase/streamctl + release.yml. --- .github/workflows/release.yml | 130 ++++++++++++++-------------------- docs/release.md | 100 +++++++++++++------------- 2 files changed, 103 insertions(+), 127 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 152c745..e43bdfa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,50 +1,40 @@ -name: Release +name: Release on NPM -# Publishes @sidebase/streamctl to the public npm registry under the @sidebase -# scope. workflow_dispatch only, and scoped to the `release` environment. +# Publishes @sidebase/streamctl to the public npm registry, tokenless. # -# That environment is only a gate once it exists and carries reviewers plus -# NPM_TOKEN. GitHub auto-creates a missing environment unprotected and -# secretless, so an unconfigured dispatch skips the approval pause and dies at -# publish on auth. docs/release.md has the setup checklist and how to check it. -# -# The two packages version independently, no lockstep. Publish the CLI before -# the config package on a first publish: the config repo's validate-presets -# consumes the schema this package exports. See docs/release.md. +# Same shape as sidebase/ssm-secrets and sidebase/nuxt-auth: publishing a +# GitHub Release for a vX.Y.Z tag is the trigger, and the OIDC token minted by +# `id-token: write` authenticates to npm. No NPM_TOKEN exists, so there is no +# publish secret to leak or rotate. docs/release.md has the checklist. on: - workflow_dispatch: - inputs: - version: - description: "Release version X.Y.Z (no leading v)." - required: true - type: string + release: + types: [published] # runs when a GitHub Release is published permissions: - contents: write # create + push the vX.Y.Z tag - id-token: write # OIDC for npm provenance (--provenance) + contents: read + id-token: write # OIDC: npm trusted publishing + provenance concurrency: group: release cancel-in-progress: false jobs: - release: - # Belt-and-braces: never release off a non-default branch. - if: github.ref == 'refs/heads/main' + publish: + name: Publish package from release tag + # Only tags produced by the release process, `vX.Y.Z` or a `-` prerelease. + if: startsWith(github.event.release.tag_name, 'v') runs-on: ubuntu-latest timeout-minutes: 15 - # Protected environment: require a reviewer + hold the NPM_TOKEN secret here. - # A compromised release propagates to every consuming repo on the next - # `streamctl upgrade`, so this gate matters. - environment: release - # Bind the dispatch input to an env var and reference "$VERSION" in run blocks, - # rather than interpolating `${{ inputs.version }}` straight into shell (which is - # a script-injection shape even though the input is maintainer-only here). + # Bind the tag to an env var and reference "$TAG" in run blocks rather than + # interpolating `${{ }}` into shell, which is a script-injection shape even + # though only a maintainer can publish a release here. env: - VERSION: ${{ inputs.version }} + TAG: ${{ github.event.release.tag_name }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Check out the tag referenced by this release + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event.release.tag_name }} fetch-depth: 0 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 @@ -53,69 +43,55 @@ jobs: with: node-version: 24 cache: pnpm + # Required for setup-node to write the registry into .npmrc. registry-url: https://registry.npmjs.org - scope: "@sidebase" - - - run: pnpm install --frozen-lockfile - # Fail fast if this version was already tagged. A re-release would publish - # a tarball that diverges from the existing tag. - - name: Guard, release tag must not already exist + # The tag is the source of truth for the version now, so a tag that + # disagrees with package.json would publish a version nobody asked for. + # build.config.ts also injects package.json "version" into the CLI's + # `--version` string, so a mismatch ships a CLI that misreports itself. + - name: Verify tag matches package.json version run: | - tag="v${VERSION}" - if git ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then - echo "::error::Tag ${tag} already exists on origin, refusing to re-release." + PKG_VERSION=$(node -p "require('./package.json').version") + if [ "v$PKG_VERSION" != "$TAG" ]; then + echo "::error::Tag ($TAG) does not match package.json version (v$PKG_VERSION)" exit 1 fi - echo "Tag ${tag} is free." + echo "Tag $TAG matches package.json." - # Set the version FIRST: build.config.ts injects package.json "version" into - # the CLI's `--version` string at build time, so the bump must precede build. - - name: Set version - run: npm pkg set version="${VERSION}" + - run: pnpm install --frozen-lockfile - # Full quality gate before anything is published. + # Full quality gate before anything is published. The siblings skip this + # because their PR CI covers it; kept here because publishing is the only + # thing that runs against the tagged tree specifically. - run: pnpm typecheck - run: pnpm test - run: pnpm lint - run: pnpm build - # Verify the PACKAGED artifact before publishing. publint checks the - # published package.json (exports / files / bin correctness); attw checks type - # resolution. `--profile esm-only` acknowledges this is an intentionally - # ESM-only package, so the CJS/node10 "failures" are expected, not real. + # Verify the PACKAGED artifact. publint checks the published package.json + # (exports / files / bin); attw checks type resolution. `--profile + # esm-only` acknowledges this is an intentionally ESM-only package, so the + # CJS/node10 "failures" are expected, not real. - name: Lint the package (publint) run: pnpm exec publint - name: Check type resolution (are-the-types-wrong) run: pnpm exec attw --pack . --profile esm-only - # Commit the version bump and tag it BEFORE publishing, so the tagged tree - # is the published artifact. The tag is annotated, so - # `git push --follow-tags` carries it with the commit. - - name: Commit version bump + tag + # `npm publish`, not `pnpm publish`: the OIDC exchange for trusted + # publishing is implemented in the npm CLI, and it is what the sibling + # repos publish with. `prepublishOnly` rebuilds, which is redundant after + # the build above and cheap enough to leave as a backstop. + - name: Publish to npm with provenance run: | - tag="v${VERSION}" - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git commit -m "release: ${tag}" package.json - git tag -a "${tag}" -m "Release ${tag}" + # Stable release (vX.Y.Z) + if echo "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + npm publish --provenance --access public - # Public npm publish WITH provenance: `--provenance` attaches a signed - # build-attestation (requires `id-token: write` + npm CLI >=9.5 on a public - # repo). `--access public` is required for a first-time scoped publish. - - name: Publish to npm (public, with provenance) - run: pnpm publish --no-git-checks --access public --provenance - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + # Pre-release (vX.Y.Z-*) + elif echo "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+-'; then + npm publish --provenance --access public --tag next - # Push the release commit + tag ONLY after a successful publish, so a failed - # publish leaves origin untouched (no orphan tag for an unpublished version). - - name: Push release commit + tag - run: git push --follow-tags origin HEAD:main - - # Surface the tag as a GitHub Release with notes generated from the commit - # subjects since the previous tag. Runs after the tag is on origin. - - name: Create GitHub Release - run: gh release create "v${VERSION}" --title "v${VERSION}" --generate-notes - env: - GITHUB_TOKEN: ${{ github.token }} + else + echo "Not a valid release tag ($TAG), skipping publish." + fi diff --git a/docs/release.md b/docs/release.md index 35bff72..14197d4 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,10 +1,9 @@ # Release runbook (`@sidebase/streamctl`) -> **Status: `0.1.0` is on the registry, published by hand.** The `Release` -> workflow (`.github/workflows/release.yml`) has never published, and the -> one-time setup below is not confirmed done. It stays `workflow_dispatch`-only -> and scoped to the `release` environment, so every publish is a deliberate -> manual dispatch. +> **Status: `0.1.0` is on the registry, published by hand.** The `Release on NPM` +> workflow (`.github/workflows/release.yml`) has never published. It triggers on +> a published GitHub Release, so the deliberate human step is publishing that +> release. One setup item below is outstanding. `streamctl` publishes to the public npm registry under the `@sidebase` scope. It is an intentionally ESM-only package; the published tarball ships only `dist/`. @@ -37,57 +36,58 @@ removed) so consumer CI that parses it survives CLI upgrades. ## One-time setup -**Not verified as done.** `0.1.0` reached the registry by a manual publish that -bypassed this workflow, so its presence says nothing about whether the workflow -can publish. As of writing, `gh api repos/sidebase/streamctl/environments` -returns zero environments and `gh secret list` is empty, so at minimum step 3 is -outstanding. Step 2's token may exist as an **org** secret, which is not readable -without org admin. Confirm before the first dispatch. - -A missing environment does not fail loudly: GitHub creates one on demand with no -protection rules and no secrets, so the run loses its approval gate and reaches -the publish step with an empty `NODE_AUTH_TOKEN`. It then fails on auth, before -the push, leaving origin untouched. - -1. **npm org / scope.** Create/claim the `@sidebase` org on npmjs.com and add the - release machine account. Confirm the package name `@sidebase/streamctl` is free - (or owned). `publishConfig.access` is already `public` in `package.json`. -2. **Token / secret.** Mint an npm **automation** token (bypasses 2FA for CI) with - publish rights on `@sidebase`, and store it as the `NPM_TOKEN` secret **on the - protected `release` environment** (not repo-wide). The workflow uses OIDC - (`id-token: write`) for `--provenance`; provenance additionally requires the - repository to be **public**. -3. **Environment protection.** Add required reviewer(s) to the `release` - environment so a dispatch pauses for approval before publish. +There is **no publish secret**. The workflow authenticates to npm with the OIDC +token minted by `id-token: write`, the same tokenless setup as +`sidebase/ssm-secrets` and `sidebase/nuxt-auth`. That means nothing to leak or +rotate, but it does mean npm has to be told which workflow is allowed to publish. + +1. **npm org / scope.** Done. The `@sidebase` org exists and owns + `@sidebase/streamctl`. `publishConfig.access` is already `public`. +2. **Trusted publisher.** **Outstanding, and the only thing blocking a release.** + On npmjs.com, package settings for `@sidebase/streamctl`, add a trusted + publisher: repository `sidebase/streamctl`, workflow `release.yml`. Without it + the run reaches the publish step and fails on auth. Provenance additionally + requires the repository to be **public**, which it is. + +No `release` environment and no required reviewers, matching the sibling repos. +Publishing the GitHub Release is the human decision point. ## Cutting a release -Dispatch the `Release` workflow with the target `X.Y.Z` (no leading `v`) and -approve the environment gate. The workflow: - -1. Refuses if the `vX.Y.Z` tag already exists on origin. -2. Sets `package.json` version to the input, then runs the full gate: - `typecheck` → `test` → `lint` → `build`, then `publint` and - `attw --pack . --profile esm-only`. -3. Commits `release: vX.Y.Z` and an annotated `vX.Y.Z` tag. -4. Publishes with `pnpm publish --access public --provenance`. -5. Pushes the release commit + tag to `main` **only after** a successful publish, - so a failed publish leaves origin untouched. -6. Creates a GitHub Release for the tag with notes generated from the commit - subjects since the previous tag (`gh release create --generate-notes`). - -After the run, verify the published tarball on npm, the `vX.Y.Z` tag, and the -generated GitHub Release notes. - -Step 6 diffs against the previous tag, so every release needs its predecessor -tagged or the notes cover the whole history. **`0.1.0` was published outside this -workflow and left no tag.** `v0.1.0` has since been backfilled onto +The tag drives everything, so the version lands in `main` first and the release +publishes it. + +1. Bump `package.json` to `X.Y.Z` on a branch, and merge it to `main`. +2. Tag that commit and push the tag: + + ```sh + git tag -a vX.Y.Z -m "vX.Y.Z" && git push origin vX.Y.Z + ``` + +3. Create the GitHub Release for `vX.Y.Z` and **publish** it. A draft does not + trigger anything; publishing is what starts the workflow. + + ```sh + gh release create vX.Y.Z --title vX.Y.Z --generate-notes + ``` + +Publishing the release runs the workflow, which checks out the tag, refuses if +the tag and `package.json` version disagree, runs `typecheck` → `test` → `lint` +→ `build`, then `publint` and `attw --pack . --profile esm-only`, then publishes +with `npm publish --provenance --access public`. A `vX.Y.Z-rc.1`-style tag +publishes under the `next` dist-tag instead of `latest`. + +Afterwards, verify the tarball on npm and that the provenance attestation is +attached. + +`--generate-notes` diffs against the previous tag, so every release needs its +predecessor tagged or the notes cover the whole history. **`0.1.0` was published +outside this workflow and left no tag.** `v0.1.0` has since been backfilled onto `265809b` (`chore: bump deps (#6)`), the last commit carrying that version, so the next release diffs against the right point. Nothing else needs backfilling. -The version is an input, not something you edit first. Do not bump -`package.json` by hand before dispatching: step 2 sets it, and a pre-bumped -working tree just means the release commit contains no version change. +Nothing is published until the release is, and a failed run leaves the tag and +release in place, so a fix plus a re-publish of the same release re-runs it. ## Notes for the next release From 730d63b34d4d505a8965327318033edb55088daf Mon Sep 17 00:00:00 2001 From: Kevin Boshold Date: Fri, 31 Jul 2026 09:11:59 +0200 Subject: [PATCH 2/2] docs(release): tighten the runbook to what a releaser needs 129 lines to 79. The runbook had grown to explain the design rather than the procedure: how CONFIG_VERSION_MISMATCH is reported, how the version token is substituted at build time, a paragraph narrowing the affected population of the .config/ break. All true, none of it something you act on while shipping. Kept every fact that changes what you do: the blocking setup item, the three steps, why the tag must match package.json, why v0.1.0 was backfilled, and the hand-written notes for the next release. Also restores a citation that never resolved. init.ts quotes docs/release.md for "no lockstep and no shared version number", and that phrase has only ever existed in init.ts; it was never in this file. The claim is correct, so the Versioning section now states it rather than the comment pointing at nothing. --- docs/release.md | 159 +++++++++++++++++------------------------------- 1 file changed, 55 insertions(+), 104 deletions(-) diff --git a/docs/release.md b/docs/release.md index 14197d4..028ce8e 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,128 +1,79 @@ # Release runbook (`@sidebase/streamctl`) -> **Status: `0.1.0` is on the registry, published by hand.** The `Release on NPM` -> workflow (`.github/workflows/release.yml`) has never published. It triggers on -> a published GitHub Release, so the deliberate human step is publishing that -> release. One setup item below is outstanding. - -`streamctl` publishes to the public npm registry under the `@sidebase` scope. It -is an intentionally ESM-only package; the published tarball ships only `dist/`. - -## Compatibility contract: `schemaVersion` - -What couples the CLI to a payload is the payload manifest's integer -**`schemaVersion`** (currently `2`), not the package version. - -- A CLI major supports exactly one `schemaVersion`. The supported value is exported - at the CLI's `./manifest` subpath — the same zod schema a payload validates its - presets against. -- If the running CLI does not support a payload's `schemaVersion`, the user gets a - dedicated "payload requires a newer/older streamctl" error rather than a generic - `CONFIG_INVALID`. The loader leaves room for per-version migrations later. -- The config file's `version` pin governs the payload package only, and - `CONFIG_VERSION_MISMATCH` compares the installed payload against that pin. - `upgrade` moves the payload pin and its devDep, and leaves the CLI version alone. - -**Bumping `schemaVersion` is a CLI major.** Ship a CLI major that supports the new -schema before any payload adopts it, or existing installs break. - -## How `--version` is produced - -The CLI's `--version` is injected at build time from `package.json` -(`build.config.ts` rollup replace of the `__STREAMCTL_VERSION__` token). Set the -version, then build, then publish — the workflow already orders these correctly. -The JSON envelope (`--json`) is **append-only** (new fields, never renamed or -removed) so consumer CI that parses it survives CLI upgrades. - -## One-time setup - -There is **no publish secret**. The workflow authenticates to npm with the OIDC -token minted by `id-token: write`, the same tokenless setup as -`sidebase/ssm-secrets` and `sidebase/nuxt-auth`. That means nothing to leak or -rotate, but it does mean npm has to be told which workflow is allowed to publish. - -1. **npm org / scope.** Done. The `@sidebase` org exists and owns - `@sidebase/streamctl`. `publishConfig.access` is already `public`. -2. **Trusted publisher.** **Outstanding, and the only thing blocking a release.** - On npmjs.com, package settings for `@sidebase/streamctl`, add a trusted - publisher: repository `sidebase/streamctl`, workflow `release.yml`. Without it - the run reaches the publish step and fails on auth. Provenance additionally - requires the repository to be **public**, which it is. - -No `release` environment and no required reviewers, matching the sibling repos. -Publishing the GitHub Release is the human decision point. +> **`0.1.0` is on npm, published by hand.** The `Release on NPM` workflow has +> never run. One setup item below still blocks it. + +ESM-only, published public under the `@sidebase` scope, tarball ships only `dist/`. + +## Blocking: register the trusted publisher + +There is no publish secret. The workflow authenticates with the OIDC token from +`id-token: write`, the same tokenless setup as `sidebase/ssm-secrets` and +`sidebase/nuxt-auth`. npm still has to be told which workflow may publish. + +On npmjs.com, package settings for `@sidebase/streamctl`, add a trusted publisher +for repository `sidebase/streamctl`, workflow `release.yml`. Until then a run +fails at publish on auth, having changed nothing. ## Cutting a release -The tag drives everything, so the version lands in `main` first and the release -publishes it. +1. Bump `package.json` to `X.Y.Z` and merge to `main`. +2. `git tag -a vX.Y.Z -m vX.Y.Z && git push origin vX.Y.Z` +3. `gh release create vX.Y.Z --title vX.Y.Z --generate-notes`, then **publish** + it. A draft triggers nothing. -1. Bump `package.json` to `X.Y.Z` on a branch, and merge it to `main`. -2. Tag that commit and push the tag: +Publishing runs the workflow: checks out the tag, refuses if the tag and +`package.json` version disagree, runs `typecheck` / `test` / `lint` / `build` +plus `publint` and `attw`, then `npm publish --provenance`. A `vX.Y.Z-rc.1` tag +goes to the `next` dist-tag instead of `latest`. - ```sh - git tag -a vX.Y.Z -m "vX.Y.Z" && git push origin vX.Y.Z - ``` +Nothing ships until you publish the release, and a failed run leaves the tag and +release intact, so re-publishing the same release re-runs it. Afterwards, check +the tarball on npm and that provenance is attached. -3. Create the GitHub Release for `vX.Y.Z` and **publish** it. A draft does not - trigger anything; publishing is what starts the workflow. +Two things worth knowing: - ```sh - gh release create vX.Y.Z --title vX.Y.Z --generate-notes - ``` +- `--generate-notes` diffs against the previous tag. That is why `v0.1.0` was + backfilled onto `265809b`; nothing else needs backfilling. +- `--version` is baked in at build time from `package.json`, so a tag that + disagrees would ship a CLI that misreports itself. Hence the check. -Publishing the release runs the workflow, which checks out the tag, refuses if -the tag and `package.json` version disagree, runs `typecheck` → `test` → `lint` -→ `build`, then `publint` and `attw --pack . --profile esm-only`, then publishes -with `npm publish --provenance --access public`. A `vX.Y.Z-rc.1`-style tag -publishes under the `next` dist-tag instead of `latest`. +## Versioning -Afterwards, verify the tarball on npm and that the provenance attestation is -attached. +What couples the CLI to a payload is the manifest's `schemaVersion` (currently +`2`), not the package version. A CLI major supports exactly one, exported at the +`./manifest` subpath. **Bumping it is a CLI major**, and the supporting CLI has +to ship before any payload adopts it. -`--generate-notes` diffs against the previous tag, so every release needs its -predecessor tagged or the notes cover the whole history. **`0.1.0` was published -outside this workflow and left no tag.** `v0.1.0` has since been backfilled onto -`265809b` (`chore: bump deps (#6)`), the last commit carrying that version, so -the next release diffs against the right point. Nothing else needs backfilling. +The config file's `version` pin governs the payload package only. `upgrade` moves +that pin and leaves the CLI version alone. The CLI and the payload release on +their own schedules: **no lockstep and no shared version number**, which is why +`init` writes the two devDep pins from separate values. -Nothing is published until the release is, and a failed run leaves the tag and -release in place, so a fix plus a re-publish of the same release re-runs it. +The `--json` envelope is append-only: new fields, never renamed or removed, so +consumer CI survives upgrades. Use Conventional Commit subjects, with `!` for +anything that moves that envelope, the exit codes, or `schemaVersion`. ## Notes for the next release -Include these in the release notes; the rest is generated from commit subjects. +Generated notes only cover commit subjects, so add these by hand. -- **The config file's default location moved** to `streamctl.config.ts` at the repo +- **The default config location moved** to `streamctl.config.ts` in the repo root. `init` writes it there. -- **`.streamctl/config.*` keeps working, permanently.** Not deprecated, no warning, - no removal planned. Existing repos need to do nothing. A repo that *does* move its - config needs this CLI version or newer. -- **One breaking edge:** a config at `.config/.streamctl/config.ts` resolved before - this release and does not now — it raises `NOT_INITIALIZED`. Measured against - c12 3.3.4: the old `configFile: ".streamctl/config"` spelling made c12 probe - `.config/.streamctl/config`, and the new spelling does not. The form is - undocumented and nested, so realistically nobody is on it, but the fix is one - command: +- **`.streamctl/config.*` keeps working, permanently.** Not deprecated, no + warning, no removal planned. Existing repos need to do nothing. +- **One break:** `.config/.streamctl/config.ts` used to resolve through c12's + `.config/` convention and now raises `NOT_INITIALIZED`. Fix: ```sh git mv .config/.streamctl/config.ts streamctl.config.ts ``` - Nothing else under `.config/` is read by streamctl, before or after this release. - - The affected population is narrower than it reads: below c12 3.2.0 there is no - `_configFile`, so the old loader raised `NOT_INITIALIZED` from any location. A - repo on this layout was only ever working if its tree resolved c12 >= 3.2.0. - The `git mv` is worth doing either way, so the instruction above is not - conditional on that. -- **New warning:** two extensions of the same config at one location - (`streamctl.config.js` next to `streamctl.config.ts`) now warn on stderr that one is - *shadowed by* the other, naming the one being read. c12's order puts `.js` ahead of - `.ts`, which surprises most people. **Nothing is read differently than before** — this - is a new diagnostic, not new behaviour, so a repo that sees it needs no migration. + Nothing else under `.config/` was ever read. Realistically nobody is affected: + the layout is undocumented, and it only ever worked on trees resolving + c12 >= 3.2.0. +- **New warning:** two extensions at one location (`streamctl.config.js` beside + `streamctl.config.ts`) now warn on stderr which one is being read, since c12 + orders `.js` ahead of `.ts`. Nothing is read differently than before, so a repo + that sees it needs no migration. - Minor bump: new default, no removals. - -Use Conventional Commit subjects (and `!` / `BREAKING CHANGE:` for anything that -moves the `--json` envelope, exit codes, or the manifest `schemaVersion`) so the -history reads clearly for consumers.