# Changelog All notable changes to `putitoutthere` are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Every PR that changes public API adds an entry here (see [`AGENTS.md`](./AGENTS.md#changelog-and-migration-policy)). Breaking changes are prefixed `**BREAKING**` and link to the matching section in [`MIGRATIONS.md`](./MIGRATIONS.md). ## Unreleased ### Changed - Changed: `_matrix.yml`'s build job now primes a cargo cache via `Swatinem/rust-cache@v2` on every row that invokes cargo (pypi/maturin non-sdist, npm/napi, npm/bundled-cli non-main), partitioned by `matrix.target` via `shared-key`. Previously every per-target matrix cell cold-compiled its full Cargo dep graph on every PR — observed at 4-6 min per cell and ~8 min wall-clock on a downstream consumer's `release-precheck` run (thekevinscott/dirsql run #125, a typical no-Rust-change PR), leaving the gate one bad runner-queue minute away from tripping a 10-min CI budget with no headroom. The cache step is gated to skip rows that produce no cargo work (pypi sdist, pure-Python hatch wheels, npm vanilla, bundled-cli `main`), and the per-target `shared-key` prevents one target's writer from blowing away the next cell's cache slot. `workspaces` enumerates both `matrix.path` (the consumer's package crate) and `matrix.bundle_cli.crate_path` (the bundle_cli crate when it lives in a separate dir), so the dirsql shape and single-crate layouts both cache their target dirs. #391. (verified by: unit/ubuntu-latest) ### Fixed - Fixed: `kind = "npm"` `build = "napi"` releases now bake the planned release version into the compiled `.node`. `_matrix.yml` (and its e2e mirror `e2e-fixture-job.yml`) run a `write-crate-version` step on each per-triple napi row before `npm run build`, so `napi build` compiles the addon with the right `CARGO_PKG_VERSION` — matching the maturin `write-version` (#276) and bundled-cli `write-crate-version` (#366) pre-build bumps. Previously napi had no pre-build bump: the synthesized per-platform `package.json` carried `matrix.version`, but the `.node` inside it embedded whatever literal sat in the crate's `Cargo.toml`, so a library re-exposing the Rust core's `version()` through napi reported a version diverging from the published npm package. The bump targets `matrix.path` and runs only when a `Cargo.toml` is colocated there (the napi-rs single-crate default — the shape template-lib and the `js-napi` fixture use), resolving `version.workspace = true` to the workspace root via #428. A multi-mode package (`build = ["napi", "bundled-cli"]`) or one whose napi crate lives elsewhere has no `Cargo.toml` at the package path and is skipped with a `::notice::` — there is no per-napi `crate_path` config to point at a non-colocated crate yet, so that `.node` still embeds its on-disk version. The noarch `main` row is excluded (it compiles no `.node`). See [MIGRATIONS.md](./MIGRATIONS.md#napi-node-embeds-the-release-version). #429. (verified by: unit/ubuntu-latest) - Fixed: `putitoutthere`'s pre-build version rewrite now follows Cargo **workspace version inheritance** — a crate that sources its version from `[workspace.package].version` via `version.workspace = true` (the idiomatic polyglot layout: one Rust core wrapped by a PyO3 wheel and a napi addon) is now bumped correctly instead of failing the release. The maturin `write-version` (#276) and npm/pypi bundled-cli `write-crate-version` (#366) steps both rewrote only a literal `[package].version` and threw `Cargo.toml: no [package].version field found` on an inheriting member, because the inherited version lives in a different file — the workspace root — that neither step read. So a release for that layout was blocked, not merely mis-versioned. The rewrite path now resolves the manifest first: a literal `[package].version` is bumped in place byte-for-byte as before, while an inheriting member walks up to the nearest ancestor `[workspace]` `Cargo.toml` and rewrites its `[workspace.package].version`. Unparseable or version-less manifests fall through to the prior literal-only path (`replaceCargoVersion`'s existing error), so single-crate layouts are byte-for-byte unchanged. New `src/find-workspace-root.ts`, `src/replace-workspace-package-version.ts`, and `src/write-resolved-cargo-version.ts`; `write-version.ts` / `write-crate-version.ts` now delegate to the resolver. See [MIGRATIONS.md](./MIGRATIONS.md#version-bump-follows-cargo-workspace-inheritance). #428. (verified by: unit/ubuntu-latest) - Fixed: `putitoutthere publish` now writes a package's git tag when that version is already live on the registry but untagged, instead of skipping it silently. The publish loop's already-published branch (`isPublished` → true) returned *before* the tag-creation step, so a version that reached the registry on a half-failed run (the run died after publishing but before tagging — e.g. a cancelled downstream job) was left published-but-untagged and never self-healed: piot derives "last released version" from git tags, so on every later run the package looked unreleased, fell back to `first_version` (already live), skipped again, and stayed stuck forever — while its dependents kept bumping and publishing, opening unflagged version skew. The skip branch now calls a shared, idempotent `ensureTag` (new `src/ensure-tag.ts`; a no-op when the tag already exists, and reused by the normal post-publish tag step), so a stuck package self-heals on its next release run. See [MIGRATIONS.md](./MIGRATIONS.md#publish-path-auto-heal-missing-tags). #407. (verified by: integration, unit/ubuntu-latest) - Fixed: a `kind = "pypi"` `build = "maturin"` package whose wheel is **Python-version-independent** — `[tool.maturin].bindings = "bin"` (a Rust-binary wheel tagged `py3-none`) or a pyo3 `abi3` / `abi3-pyXY` extension (a stable-ABI wheel tagged `cp3x-abi3`) — no longer fans its wheel build across every CPython version `requires-python` / `python_versions` resolves to. Such a wheel is byte-identical regardless of the interpreter that built it, so the fan produced N duplicate wheels: each fanned row uploaded under its own `-py` artifact, and the documented consumer `pypi-publish` recipe's `actions/download-artifact … merge-multiple: true` then extracted N copies of the same wheel filename onto one path concurrently — a torn write that failed `twine check` with `zipfile.BadZipFile` (observed on a `bindings = "bin"`, `requires-python = ">=3.9"` package: six identical wheels per platform, the x86_64-linux copy losing its extraction race while macOS/aarch64 won theirs). The planner (`src/plan.ts`) now collapses the fan to a single wheel row per target for these packages — built once on the newest resolved interpreter, keeping the historical unsuffixed `-wheel-` artifact name — and the new `src/wheel-abi.ts` detects version independence from the package's real `pyproject.toml` (`[tool.maturin].bindings`, `[tool.maturin].features`) and `Cargo.toml` (an `abi3` / `abi3-pyXY` feature on the `pyo3` / `pyo3-ffi` dependency). Ordinary per-version extension modules (no abi3, no `bindings = "bin"`) still fan and keep their `-py` suffixes; the sdist row is unchanged. Also saves the redundant N−1 wheel builds. Detection is conservative — an unrecognized abi3 setup (workspace-inherited `pyo3`, a target-specific dependency table) falls back to fanning, never worse than before. See [README → `kind = "pypi"`](./README.md#kind--pypi) and [MIGRATIONS.md](./MIGRATIONS.md#pypi-version-independent-wheels-build-once). #401. (verified by: integration, unit/ubuntu-latest) - Fixed: `kind = "npm"` releases no longer abort mid-matrix on npm's Sigstore/Rekor transparency-log dedupe (`TLOG_CREATE_ENTRY_ERROR`, HTTP 409, "an equivalent entry already exists in the transparency log"). npm's retry-on-transient-network-error re-submits a byte-identical `--provenance` attestation after a flaky response, and Rekor rejects the duplicate with a 409. Previously only the registry-PUT edition of that race (`E403` "cannot publish over the previously published versions", matched by `looksLikePublishOverRace`) was tolerated, so the attestation edition fell through to the generic `throw` and killed the publish job — for a multi-platform (`napi` / `bundled-cli`) package that meant the first sub-package published, the next 409'd, and the remaining sub-packages **and the main package** were left unpublished (a partial release; observed on a 3-language repo's first `0.0.1`). Unlike the E403 shape, a 409 from Rekor does **not** by itself prove the artifact landed (the first submit may have written the transparency-log entry but still failed the registry upload), so both the main-package (`src/handlers/npm.ts`) and per-platform (`src/handlers/npm-platform.ts`) publish catches now re-probe `npm view` to disambiguate: present on the registry ⇒ treated as a benign duplicate (`already-published` / counted as published, and the cascade's `optionalDependencies` rewrite still runs); absent ⇒ a genuine partial publish, surfaced as an actionable error telling the operator to re-run the release so a fresh attestation (new `runID`/attempt ⇒ new Rekor entry) gets past the dedupe. The engine cannot retry the attestation in-process. See [MIGRATIONS.md](./MIGRATIONS.md#npm-tlog_create_entry_error-409-provenance-retry-race). #399. (verified by: unit/ubuntu-latest) - Fixed: `kind = "npm"` `build = "bundled-cli"` packages that omit `[package.bundle_cli]` no longer crash the release at the `write-launcher` step. #298 kept the table opt-in — a bundled-cli npm package may either declare it (the reusable workflow cross-compiles the Rust binary and the engine authors `bin/.js`) or ship its own `scripts/build.cjs` + hand-authored launcher (the legacy path, which the cross-compile step skips by gating on `matrix.bundle_cli`). But #299 moved launcher generation into the engine and ran it on every bundled-cli main row while unconditionally dereferencing the optional table (`bundle_cli.bin`), so any package without it died with `TypeError: Cannot read properties of undefined (reading 'bin')` on the reusable workflow's `write-launcher` step — including any freshly-scaffolded consumer that hasn't adopted the declarative block, and the config shape carried by putitoutthere's own `polyglot-everything` fixture (the e2e mirror never runs `write-launcher`, so CI never caught it). `writeLauncherFromConfig` now no-ops launcher generation when `[package.bundle_cli]` is absent (the gate lives in the engine because the main row the step runs on never carries `bundle_cli` — `plan.ts` attaches it only to per-target bundled-cli rows), leaving the consumer's committed launcher and `package.json#bin` untouched; declarative consumers still get the generated launcher. See [MIGRATIONS.md](./MIGRATIONS.md#bundled-cli-launcher-generation-no-ops-without-packagebundle_cli). #299. (verified by: unit/ubuntu-latest) - Fixed: `kind = "npm"` `build = "bundled-cli"` `bundle_cli — add Rust target`, `cargo build`, and `stage binary` steps now correctly map npm-flavor triples (`linux-x64-gnu`, `darwin-arm64`, `win32-x64-msvc`, …) to their Rust equivalents (`x86_64-unknown-linux-musl`, `aarch64-apple-darwin`, `x86_64-pc-windows-msvc`, …) before calling `rustup target add` and `cargo build --target`. Previously the bare `${TARGET//-linux-gnu/-linux-musl}` substitution was a no-op on npm-flavor triples — the substring `-linux-gnu` does not appear in `linux-x64-gnu` — so `rustup` received the raw npm triple and all five bundle_cli matrix rows failed immediately: `error: toolchain '...' does not support target 'linux-x64-gnu'`. (verified by: unit/bundle-cli-musl-target) - Fixed: `kind = "npm"` `build = "bundled-cli"` Linux binary is now always statically linked (musl). The engine's `bundle_cli — stage binary` step previously ran **before** `npm run build --if-present`. A consumer build script that also runs `cargo build --target $TARGET` (the raw `-linux-gnu` triple) and copies the result to `build//` would overwrite the engine's musl binary with a glibc-linked one; the `bundle_cli — verify` existence check passed silently and the glibc artifact was uploaded to the registry. The stage step now runs **after** `npm run build --if-present` in both the reusable workflow (`_matrix.yml`) and the e2e mirror (`e2e-fixture-job.yml`), so the engine's musl binary always wins. The `bundle_cli — verify` step in `_matrix.yml` also now asserts static linking (mirrors the check present in `e2e-fixture-job.yml`). (verified by: e2e/js-bundled-cli, unit/bundle-cli-musl-target) ### Added - **`putitoutthere status` — a read-only command that reports registry-vs-tag drift, per package.** The registry is the source of truth and the planner derives "last released version" from git tags, so a half-failed run that publishes a version but never tags it strands the package: it then looks unreleased, skips its already-live `first_version` forever, can never bump, and its dependents drift ahead into unflagged version skew. `status` reconciles each package's latest tag against the registry's latest published version over the public registry APIs (crates.io `/api/v1/crates/`, npm `registry.npmjs.org/

`, PyPI `/pypi/

/json`; no auth) and flags drift — `published, untagged`, `tagged, unpublished`, `version mismatch`, plus `in sync` / `unreleased` / `registry unreachable` (a network blip, reported but never gated). `--check` exits non-zero on any drift state (a CI gate); `--json` emits the rows as machine-readable JSON. It reuses the same `lastTag` resolver the release path runs, so the report cannot disagree with what a real release would see. See [README → Release health](./README.md#release-health) and [MIGRATIONS.md](./MIGRATIONS.md#status-registry-vs-tag-drift-report). #403. (verified by: integration, unit/ubuntu-latest) - **`putitoutthere reconcile` — backfill the missing git tag for every package that is live on its registry but untagged.** The on-demand companion to the publish-path auto-heal (#407): auto-heal writes the tag on the already-published skip branch, but only fires for a package that is *in a publish run*, so a package whose globs never change again never enters one and stays stuck forever (no baseline tag → falls back to `first_version` → already published → skips forever → can never bump). `reconcile` heals it without a release, runnable in CI with the release job's permissions — replacing the manual `release_packages` bump or hand-rolled `git tag` ref-surgery that was the only prior recovery. It is a thin reader over the existing engine (the anti-drift rule in [non-goal #7](./notes/design-commitments.md#non-goals)): the same `computeStatus` (#403) that `status` reports *detects* the `published, untagged` drift, and the same idempotent `ensureTag` (#407) the publish path heals with *writes* the tag — so reconcile can never act on a drift `status` wouldn't show. The backfilled tag points at a sibling package already tagged at that version (the real release commit — e.g. a crate left untagged while its npm/PyPI siblings tagged the same merge) when one exists, else `HEAD`, so piot's "changed since last release" diff stays honest. Idempotent (a re-run is a no-op); `--dry-run` previews without writing; `--json` emits the actions. `--dry-run` is now accepted on `reconcile` while remaining rejected on `plan` / `publish` (unchanged from #244). See [README → Release health](./README.md#release-health) and [MIGRATIONS.md](./MIGRATIONS.md#reconcile-backfill-missing-tags). #403. (verified by: integration, unit/ubuntu-latest) - **`putitoutthere plan` now reports a per-package PUBLISH/SKIP verdict and a dependency-skew warning — the authoritative "what would a release from this ref ship?"** Previously `plan` emitted only the build matrix; the publish-vs-skip decision lived in the publish path (`isPublished`) and surfaced nowhere until a release actually ran. Now `plan` always layers a verdict over each planned package — `PUBLISH` (version not yet on the registry), `SKIP` (already published), or `UNKNOWN` (registry unreachable — reported, never fatal) — via the same `isPublished` the publish path dispatches through, and flags **version skew** when a package would `PUBLISH` while a `depends_on` dependency `SKIP`s (the motivating incident's forward consequence: dependents shipping ahead of a stuck dependency). It is a thin reader over the real planner + the real publish-path check (the anti-drift rule in [non-goal #7](./notes/design-commitments.md#non-goals)), so the preview can't disagree with what a release would do. Always-on (no flag to remember); the read degrades to `UNKNOWN` and the matrix is still emitted, so an unreachable registry never aborts the plan. **`plan --json` now emits `{ matrix, verdicts, skew }`** — the `matrix` field is byte-identical to the previous bare array, and the reusable workflow's `outputs.matrix` (what consumers gate on) is unchanged; only a direct `plan --json` stdout reader sees the new envelope. See [README → Release health](./README.md#release-health) and [MIGRATIONS.md](./MIGRATIONS.md#plan-publish-skip-verdict-and-skew). #403. (verified by: integration, unit/ubuntu-latest) - **`putitoutthere verify` — per-package publish/trust posture (OIDC trusted publisher vs token), from public registry data, no secrets.** Answers the secure-by-default question piot exists to make easy: "do I still need the long-lived registry token, or is OIDC trusted publishing active?" For each package, `verify` reads the latest published version (the same `latestVersion` `status` uses) and then that release's trust attribution, classifying `oidc` (a trusted-publisher / provenance attestation is present — safe to drop the token), `token` (no such attestation — token-dependent), `unpublished` (no release to attribute), or `unreachable` (a registry blip, reported never gated). The trust signal is read straight from each registry's public surface: crates.io's `version.trustpub_data`, npm's provenance attestations endpoint, and PyPI's PEP 740 integrity/provenance endpoint — confirmed against piot's own fixtures. A new per-handler `trustPosture` primitive (parallel to `latestVersion`, same registry-name resolution as `isPublished`) keeps the read aligned with the publish path. `--check` exits non-zero when any package is still token-dependent (a CI gate to enforce the zero-secret OIDC steady state); `--json` emits the rows. See [README → Release health](./README.md#release-health) and [MIGRATIONS.md](./MIGRATIONS.md#verify-publish-trust-posture). #403. (verified by: integration, unit/ubuntu-latest) - **Preflight check: every cascaded `kind = "npm"` package's `package.json` `name` must match the configured `[[package]].name` (or the `npm` override).** Completes the #301 name-shape parity: pypi (`PIOT_PYPI_NAME_MISMATCH`) and crates (`PIOT_CRATES_NAME_MISMATCH`) already enforced this, but npm did not — an npm `package.json` whose `name` diverged from the configured identifier (with no `npm` override) was silently accepted. `npm publish` packs the manifest `name`, but the engine's idempotency probe (`npm view `) and the tag / release-URL bookkeeping use the configured name (`npm` override ?? `[[package]].name`), so a divergence silently breaks idempotency and can publish under an unexpected name — the npm analogue of the pypi mismatch that failed a downstream release ~12 minutes in. The new `requirePackageJsonShape` runs alongside `requirePyprojectShape` / `requireCargoShape` in `src/publish.ts` before any side effects, and also via `runChecks` at PR time (`check.yml`); findings aggregate across every failing package so consumers fix them all in one round-trip. Scoped names are compared verbatim (`npm = "@scope/foo"` matches `package.json` `name = "@scope/foo"`). A missing or malformed `package.json` is skipped (a separate failure surface). Surfaces a new stable error code, `PIOT_NPM_NAME_MISMATCH`. Documented in [README → `kind = "npm"`](./README.md#kind--npm). See [MIGRATIONS.md](./MIGRATIONS.md#preflight-npm-package-name-must-match-configured-name). #301. (verified by: unit/ubuntu-latest, integration) - **Manual release: the reusable workflow accepts a `release_packages` input that releases an explicit list of packages, bypassing change detection.** Releases were change-driven only — `plan` (`src/plan.ts`) diffs each package's `globs` against its last tag, so a repo with no new commits produced an empty matrix and could not release. That left no path for the re-release case: putitoutthere ships a release-pipeline bug, the bug is fixed, and downstream consumers must publish the affected packages again even though their own repos have no new code. `release.yml` now declares an optional `release_packages` `workflow_call` input — a comma-separated list of `name[@]` entries, e.g. `lib-core@minor, lib-py@1.4.0, lib-js`. Each entry is a package name optionally suffixed with `@` (a bump applied to the last tag) or an explicit `@` semver (used verbatim); a bare name defaults to a patch bump. When the input is non-empty, `plan` skips change detection and `depends_on` cascade entirely and emits a matrix for exactly the named packages — no incidental change-detected package is pulled in. The spec is plumbed through `_matrix.yml` to the `plan` job and through `release.yml`'s `publish` job (which re-runs `plan` internally and so must see the same spec) so both phases agree on the matrix. Consumers wire the input to a `workflow_dispatch` trigger in their own caller workflow to get a manual "release these packages" button. An explicit version is intentionally not compared against the last tag — re-releasing the same version is valid when a prior publish failed before reaching the registry, and the publish-phase already-published check is the right place to refuse a genuine collision. A named package absent from `putitoutthere.toml` is a hard error. Empty (the default) leaves the normal change-detected release path byte-identical. See [README → Manual release](./README.md#manual-release) and [MIGRATIONS.md](./MIGRATIONS.md#manual-release-via-release_packages). (verified by: unit/ubuntu-latest) - **`kind = "pypi"` builds a wheel for every supported Python version, inferred from `requires-python`.** Previously every pypi package built exactly one wheel — for the single `python_version` workflow input (default `3.12`) — so a consumer whose `pyproject.toml` declared `requires-python = ">=3.10"` shipped a cp312-only wheel and `uvx ` failed on every other interpreter (the bug observed on `dirsql` 0.3.5). The planner (`src/plan.ts`) now resolves a per-package CPython version set and fans the pypi build matrix across it: each pypi matrix row carries a new `python_version` field, and `maturin` per-target wheel rows are emitted once per resolved version. The set is resolved by `src/python-versions.ts` — an explicit `python_versions` array in `putitoutthere.toml` wins; otherwise `[project].requires-python` is read from the package's `pyproject.toml` and expanded against the released CPython set discovered by the reusable workflow (`>=3.10` now includes `3.14`, supporting `>=`/`>`/`<=`/`<`/`==`/`!=`/`~=` clauses and `==3.*` wildcards); otherwise a single default (`3.12`) preserves the prior single-wheel behavior. The common case needs **zero configuration**. `_matrix.yml` feeds each row's `python_version` to `actions/setup-python` and, for `maturin`, to the build's `--interpreter` selection. Multi-version maturin wheel rows gain a `-py` artifact-name suffix (`-wheel--py3.10`) so per-version wheels for the same triple don't collide on upload; a single planned version keeps the historical unsuffixed `-wheel-` name. The sdist and a pure-Python `hatch` wheel are version-agnostic and still built once. The `python_version` input on `release.yml` / `build.yml` / `_matrix.yml` is now **deprecated** — it no longer affects pypi builds and is retained only so existing callers don't break. See [README → `kind = "pypi"`](./README.md#kind--pypi) and [MIGRATIONS.md](./MIGRATIONS.md#pypi-multi-version-wheels). #369. (verified by: unit/ubuntu-latest) - **Pre-merge crate-size check: `putitoutthere check` flags a `kind = "crates"` package whose `cargo package` `.crate` exceeds crates.io's 10 MiB upload limit.** crates.io rejects any `.crate` over `10485760` bytes with `413 Payload Too Large`, but `cargo publish` surfaces that only mid-release — after the verification build has compiled the crate and every transitive dep — which is exactly the "release surprise" the [no-surprises design commitment](./notes/design-commitments.md) exists to eliminate. `runChecks` now runs `cargo package --no-verify` for every cascaded `kind = "crates"` package at PR time and reports a finding carrying a new stable error code, `PIOT_CRATES_PACKAGE_TOO_LARGE`, when the resulting tarball is over the limit — so a tracked symlink dragging a build tree into the crate, or a missing `[package].exclude`, is caught on the PR that introduces it rather than on a release run weeks later. The check degrades to a no-op when `cargo` is unavailable or rejects the manifest; it never invents a size finding. See [MIGRATIONS.md](./MIGRATIONS.md#pre-merge-crate-size-check). #362. (verified by: integration, unit/ubuntu-latest) - **Preflight checks: every cascaded package's manifest repository URL must resolve to the same `owner/repo` as `GITHUB_REPOSITORY`, and the GitHub repository running the workflow must be public.** Two new `requireRepoUrlMatch` / `requireRepoPublic` checks live in `src/preflight.ts`, fire alongside the existing `requireAuth` / `requireProvenanceMetadata` / `requireCratesMetadata` chain in `src/publish.ts` before any side effects, and the URL-match check also runs via `runChecks` at PR time (`check.yml`). The URL-match check parses `package.json#repository` (object + legacy string form), `Cargo.toml [package].repository`, and `pyproject.toml [project.urls]` against the GHA-provided `GITHUB_REPOSITORY` env var, normalising common GitHub URL shapes (`git+https://`, `https://`, `git@github.com:owner/repo`, with/without `.git`, with/without trailing slash). Catches the exact npm provenance 422 (`"repository.url is X, expected to match Y from provenance"`) that surfaces after artifact upload. The visibility check calls `https://api.github.com/repos/{owner}/{repo}` and hard-fails when the API reports `private: true` (or 404, which is indistinguishable from "private and the token can't see it" for our purposes); private repos break npm provenance attestation visibility for consumers and degrade the trusted-publisher story across the registries. Both checks no-op when `GITHUB_REPOSITORY` is unset (local CLI runs outside GHA) so `putitoutthere check` from a developer machine doesn't false-positive. Two new stable error codes: `PIOT_REPO_URL_MISMATCH`, `PIOT_REPO_PRIVATE`. (verified by: unit/ubuntu-latest) - **Engine detects crates.io's first-publish Trusted-Publishing rejection and surfaces a `CARGO_REGISTRY_TOKEN` bootstrap hint.** crates.io's Trusted Publishing binds to an already-published crate: the OIDC mint succeeds and the exchanged token reaches cargo, but the registry returns 404 ("crate `` does not exist or you do not have permission to publish to it") on the very first release of a new crate name. The previous fallthrough to "cargo publish failed" pushed consumers down a credentials rabbit-hole when the actual fix is one bootstrap publish via the classic-token fallback (already wired in #283). The crates handler now anchors on the 404-status line plus the registry's prose, surfaces a new stable error code `PIOT_CRATES_FIRST_PUBLISH_TP_REJECTED`, and prints the bootstrap hint inline alongside cargo's full stderr. Suppressed under the e2e seam (`PIOT_CRATES_REGISTRY_PRIMARY`) — the alt-registry doesn't model TP, so a 404 there is a different bug. The companion fixture catalogue ([`notes/upstream-behaviors.md`](./notes/upstream-behaviors.md)) indexes the response shape; the [`test/integration/fixtures/registry-responses/crates-io/publish-first-publish-tp-rejected.txt`](./test/integration/fixtures/registry-responses/crates-io/publish-first-publish-tp-rejected.txt) fixture pins the contract. See [MIGRATIONS.md](./MIGRATIONS.md#crates-first-publish-tp-rejection-detected). #284 (parent #296). - **Registry-auth response fixtures + replay tests catalogue.** A new `test/integration/fixtures/registry-responses/` directory captures sanitised real-world response shapes for crates.io, npm, and PyPI auth flows the engine depends on (crates.io first-publish TP rejection #284, npm E403 over-publish race #281, npm 422 missing-repository rejection #281, PyPI mint-token invalid-publisher #252). Each fixture has a matching test in [`test/integration/registry-auth.integration.test.ts`](./test/integration/registry-auth.integration.test.ts) that replays the response and asserts the engine's reaction (or its architectural avoidance, in PyPI's case). The catalogue lives at [`notes/upstream-behaviors.md`](./notes/upstream-behaviors.md) and is the source of truth for "which upstream quirk forced this bit of engine code." New auth-related response shapes land as fixture + test + catalogue row; the existing publish-shape suites (#293/#294/#295) continue to cover what the registry *receives*. No consumer-visible behaviour change beyond the crates.io detector above. #296. - **`kind = "npm"` `build = "bundled-cli"` consumers no longer need to author `bin/.js` — the reusable workflow generates it at build time.** The launcher's only per-consumer inputs are the package name and the configured `targets` list, both of which the engine has at plan time, so every consumer's hand-authored launcher was byte-identical modulo two values. `_matrix.yml`'s build job now invokes a new internal `putitoutthere write-launcher` CLI subcommand on the main row of each bundled-cli npm package (before `npm run build --if-present` runs); the subcommand writes `bin/.js` and adds `package.json#bin` in place. Both writes are guarded by an "only if absent" check — existing consumer-authored launchers and existing `bin` fields are preserved, so the override path is the same file you'd already have committed. The generated launcher inlines the resolved platform-package name template (`{name}`, `{scope}`, `{base}` substituted at generation time; `{triple}` substituted at install time) into a backtick template literal; the Node `${platform}-${arch}` → triple table is the only per-install branching. Same shape as the README example bundled-cli consumers wrote by hand pre-#299, just authored by the workflow. Together with #298 (which absorbed the build script), bundled-cli npm's consumer surface is now: declare the package in `putitoutthere.toml`, register Trusted Publishers, push. The README's bundled-cli recipe loses its launcher code block; it gains a one-line "the workflow generates this" note plus the override hint. The CLI subcommand is internal (consumers compose with the reusable workflow, not directly with the CLI). See [README → Recipes → Bundled-CLI npm family](./README.md#bundled-cli-npm-family) and [MIGRATIONS.md](./MIGRATIONS.md#bundled-cli-launcher-generated-by-the-workflow). #299. - **`kind = "npm"` `build = "bundled-cli"` now supports `[package.bundle_cli]` so the reusable workflow runs the Rust cross-compile itself.** Mirror of the pypi `[package.bundle_cli]` recipe shipped in #282 — the reusable workflow now, for every per-target row with `matrix.kind == 'npm' && matrix.build == 'bundled-cli' && matrix.bundle_cli && matrix.target != 'main'`: (1) `rustup target add ${{ matrix.target }}`, (2) `cargo build --release --target ${{ matrix.target }} --bin ${{ matrix.bundle_cli.bin }}` against `crate_path` (with optional `--features` / `--no-default-features` for crates that gate the CLI behind a Cargo feature), (3) copies the resulting binary (`.exe` on Windows) into the per-target staging directory the engine's npm-platform handler reads (`${{ matrix.artifact_path }}`, which is `/build/` for single-mode rows and `/build/-` for multi-mode rows), and (4) runs a permanent defense-in-depth build-content guard that asserts the staged binary exists before `actions/upload-artifact` runs. Schema: `bin` (required), `crate_path` (default `"."`), `features` (default `[]`), `no_default_features` (default `false`). The block is only valid when at least one bundled-cli entry exists in `build` and `targets` is non-empty. Existing consumers who authored a `scripts/build.cjs` keep working — the workflow runs first, then `npm run build --if-present` runs the consumer's script (which sees `build//` already populated and probably no-ops); migrating to the declarative block is opt-in. Every bundled-cli npm consumer to date has written essentially the same cross-compile script and hit bugs at the seam between that script and the engine (#287 was the most recent). Absorbing the script closes the largest remaining piece of the consumer integration surface that exists for no architectural reason. See [README → Recipes → Bundled-CLI npm family](./README.md#bundled-cli-npm-family) and [MIGRATIONS.md](./MIGRATIONS.md#npm-bundle_cli-absorbed-into-the-reusable-workflow). #298. - **Preflight checks: every cascaded `kind = "pypi"` package's `pyproject.toml` and every cascaded `kind = "crates"` (plus `bundle_cli` on `kind = "pypi"`) `Cargo.toml` must match the configured shape.** Mirrors the #280 / #290 pattern. The maturin / setuptools / hatchling / cargo CLIs surface these mismatches 10-20 minutes into a release run, deep into the verification build, with errors that don't name the precondition that failed; the new `requirePyprojectShape` + `requireCargoShape` checks live in `src/preflight.ts`, fire alongside `requireAuth` / `requireProvenanceMetadata` / `requireCratesMetadata` in `src/publish.ts` before any side effects, and also run via `runChecks` at PR time (`check.yml`). Findings aggregate across every failing package so consumers fix them all in one round-trip. Eight new stable error codes: `PIOT_PYPI_NAME_MISMATCH`, `PIOT_PYPI_BUILD_BACKEND_MISMATCH`, `PIOT_PYPI_DYNAMIC_VERSION_NO_BACKEND`, `PIOT_PYPI_MATURIN_INCLUDE_MISSING`, `PIOT_CRATES_NAME_MISMATCH`, `PIOT_CRATES_MISSING_BIN`, `PIOT_CRATES_FEATURE_NOT_DECLARED`, `PIOT_CRATES_WORKSPACE_VERSION_MISMATCH`. The `[build-system].build-backend` check only fires when the field is set *and* disagrees — a missing `[build-system]` table is left to pip's fallback behavior. The workspace-version walk bounds at `cwd` so it never escapes the repo. See [README → `kind = "crates"`](./README.md#kind--crates), [README → `kind = "pypi"`](./README.md#kind--pypi), and [MIGRATIONS.md](./MIGRATIONS.md#preflight-pyproject--cargo-shape). #301. - **Reusable workflow `.github/workflows/check.yml` (`workflow_call`).** PR-time config-sanity gate. Wires into a consumer's `pull_request:` CI in one line: `uses: thekevinscott/putitoutthere/.github/workflows/check.yml@v0`. Drives the same `putitoutthere check` engine entry the publish path already uses (no parallel diagnostic code path — the operative rule from the [refreshed non-goal #8](./notes/design-commitments.md#non-goals)), so the consumer-observable check list is whatever `check` reports: `putitoutthere.toml` parse + schema + common-mistakes detector, unique package names, `depends_on` cycle / dangling-ref detection, `[[package]].path` directory exists, `globs` match a tracked file, `tag_format` collisions, npm `repository` field, crates `description` / `license`, pypi `pyproject.toml` + `bundle_cli` binary declaration, npm target triple mapping. Findings are aggregated into one round-trip rather than failing on the first. A few seconds per PR, no per-target build, no `setup-python` / `setup-rust`. Permissions: `contents: read` only — the workflow holds no publishable artifact and cannot mint a registry token. Complementary to `build.yml`: `check.yml` is the cheap config-sanity gate, `build.yml` is the heavier per-target build gate; wire both. See [README → check](./README.md#1b-recommended-drop-in-githubworkflowscheckyml) and [MIGRATIONS.md](./MIGRATIONS.md#new-checkyml-reusable-workflow-for-pr-time-config-sanity). #317 (workflow shell) + #319 (check list, shipped via #321). - **Internal-only: `cargo-http-registry` alt-registry for e2e crates.io 429 fallback and first-publish coverage.** `e2e (polyglot-everything) / publish` reliably 429s on `cargo publish` for the polyglot rust fixture once a few PRs have burned crates.io's 24h-per-crate quota (the fixture publishes a fresh `0.0.` version on every run; the per-crate quota gets exhausted faster than it resets). `e2e-fixture-job.yml`'s publish job now installs and runs [`cargo-http-registry`](https://github.com/d-e-s-o/cargo-http-registry) (an off-the-shelf, auth-free cargo alt-registry — the "Verdaccio for cargo" the workflow has needed) as a background process on every crates-bearing matrix row. Two engine seams in `src/handlers/crates.ts` consume it: `PIOT_CRATES_REGISTRY_FALLBACK` retries `cargo publish --index ` against the alt-registry on a 429-rate-limit-shaped failure from real crates.io and emits a `::warning::` workflow command naming the URL so reviewers see the path was taken; `PIOT_CRATES_REGISTRY_PRIMARY` routes the publish *only* at the alt-registry (no real-crates.io attempt, no fallback) — reserved for any future `*-first-publish` crates fixture, symmetric with #304's npm/Verdaccio shape. Non-429 failures (auth, network, validation) surface verbatim — the fallback predicate is scoped narrowly to rate-limit prose. The handler now passes `--token ` alongside `--index ` because cargo's CLI parser refuses to invoke publish with `--index` unless a token is explicitly named (a wart of the cargo CLI surface; cargo-http-registry accepts any token value). **Why `cargo-http-registry` and not a hand-rolled mock or another off-the-shelf registry**: an earlier iteration on this PR wired Kellnr; three CI rounds all 403'd because Kellnr (and alexandrie, ktra, cratery) are multi-tenant-shaped and reject fixture-style unrecognized identities. `cargo-http-registry`'s README explicitly disclaims auth ("if being asked to cargo login to the registry, any string may be used") and has a lightweight dep tree (tokio rt-only + warp + git2, no openssl-sys / sqlite-sys / aws-lc-sys), making `cargo install --locked` a ~70s cold cost — well inside the e2e job's existing budget. **No consumer surface changes**: both env vars are read only by the engine; the dogfood `release-rust.yml` continues to publish to real crates.io, and consumer `release.yml` flows that don't set either env var keep the publish path they have today byte-identical. See [MIGRATIONS.md](./MIGRATIONS.md#internal-cargo-http-registry-alt-registry-for-crates-e2e). #331. - **Internal-only: first-publish e2e coverage via Verdaccio.** A new `js-vanilla-first-publish` fixture and matrix row in `.github/workflows/e2e-fixture.yml` exercises the empty-packument path (`npm view ` returns 404 before publish) that consumer-reported bugs cluster around. `e2e-fixture-job.yml`'s publish job now runs an in-job Verdaccio service container (`verdaccio/verdaccio:5`); per-fixture job isolation gives per-scenario wipe implicitly. The new fixture carries a `-placeholder` suffix on its package name that the workflow rewrites to `-${run_id}-${run_attempt}` at materialize time, so each scenario publishes against a packument that's truly empty. The post-publish tarball-verification step is now registry-agnostic — `REGISTRY_URL` env steers `npm view` at either real npm or Verdaccio with adapted retry/backoff. **No consumer surface changes**: `PIOT_NPM_REGISTRY` is an internal e2e seam read by `src/handlers/npm.ts` and `src/handlers/npm-platform.ts`; setting it bypasses provenance and the bootstrap-hint path. Steady-state e2e fixtures and consumer `release.yml` flows are untouched. See [MIGRATIONS.md](./MIGRATIONS.md#internal-verdaccio-e2e-coverage). #304 (parent #293). - **`[package.bundle_cli]` accepts `features` and `no_default_features` for crates that gate the CLI behind a Cargo feature.** The lib-with-optional-CLI pattern — `[[bin]] required-features = ["cli"]` so `cargo add ` doesn't drag the CLI's deps onto library consumers — is the standard shape for crates that fit `bundle_cli`'s use case (ruff, uv, pydantic-core, biome, swc, dirsql). v0.2.0's wiring shipped `cargo build --release --target $TARGET --bin $BIN` with no `--features` path, which made the recipe inert for exactly the consumers it targets. The reusable workflow's cargo build step now appends `--features ` when the schema's new `features: list[string]` is non-empty and `--no-default-features` when the new `no_default_features: bool` is true. Defaults are `[]` and `false`, so existing `[package.bundle_cli]` blocks keep building byte-identically. Empty-string entries inside `features` are rejected at config load. The "crates that gate the CLI behind a Cargo feature are not currently supported" caveat in the v0.2.0 MIGRATIONS note has been corrected. See [README → Recipes → Rust CLI inside a PyPI wheel](./README.md#rust-cli-inside-a-pypi-wheel) and [MIGRATIONS.md](./MIGRATIONS.md#bundle_cli-features-and-no_default_features). #300. - **Reusable workflow accepts a caller-provided `NPM_TOKEN` via `secrets:`.** OIDC trusted publishers remain the default and recommended path, but Trusted Publishing on npm binds to an *already-published* package — so the first publish of a brand-new npm package has no OIDC path available, and consumers were forced into a manual 6+ package `0.0.0-bootstrap` stub bootstrap (documented nowhere, only discoverable by reading commit history of dirsql or by hitting the failure). The `workflow_call` surface now declares an optional `NPM_TOKEN` secret; when set AND the planned matrix contains an npm row, the secret is exported to `$GITHUB_ENV` as `NODE_AUTH_TOKEN` and the npm CLI prefers the long-lived token over the OIDC path. Callers without a token keep the OIDC path unchanged. For bundled-cli / napi families the same secret authenticates publishes of all per-platform sub-packages on first publish; once those exist, each one needs its own Trusted Publisher registration (the bypass is a one-time bootstrap, not a permanent path). Mirrors the #283 crates fallback in shape. Hit in the wild on the maintainer's own dirsql project (first version of `@dirsql/cli-linux-x64-gnu` on npm is `0.0.0-bootstrap`, 2026-04-30; real `0.2.8` lands the next day) and on `darkfactory`'s first publish. See [README → Trusted publishers → npm](./README.md#npm) and [MIGRATIONS.md](./MIGRATIONS.md#npm-token-fallback). #302. - **Reusable workflow accepts a caller-provided `CARGO_REGISTRY_TOKEN` via `secrets:`.** OIDC trusted publishers remain the default and recommended path, but Trusted Publishing on crates.io binds to an *already-published* crate — so the first publish of a brand-new crate has no OIDC path available, and consumers were forced to either fork the workflow or run `cargo publish` outside it. The `workflow_call` surface now declares an optional `CARGO_REGISTRY_TOKEN` secret; when set, the `rust-lang/crates-io-auth-action` OIDC exchange is skipped and the caller-provided token is exported to `$GITHUB_ENV` for the engine's crates handler to read. Callers without a token keep the OIDC path unchanged. The "Auth: OIDC trusted publishers ... Long-lived registry tokens are explicitly NOT supported" framing in `release.yml`'s header has been softened to match. See [README → Trusted publishers → crates.io](./README.md#cratesio) and [MIGRATIONS.md](./MIGRATIONS.md#crates-token-fallback). #283. - **Preflight check: every cascaded `kind = "crates"` package's `Cargo.toml` must declare `[package].description` and either `[package].license` or `[package].license-file`.** crates.io rejects publish with `400 Bad Request: missing or empty metadata fields: ...` after `cargo publish`'s verification build has compiled the crate and every transitive dep — wasting the entire publish job on a precondition checkable in milliseconds. The new `requireCratesMetadata` runs alongside `requireAuth` / `requireProvenanceMetadata` in `src/publish.ts`, before any side effects, and reports every failing package + every missing field in one error rather than failing on the first. Surfaces a new stable error code, `PIOT_CRATES_MISSING_METADATA`. Whitespace-only field values are treated as empty. Hit in the wild on `thekevinscott/darkfactory`'s first crate publish; same shape as #280 (npm `repository`). See [MIGRATIONS.md](./MIGRATIONS.md#crates-cargo-toml-must-declare-description-and-license). #290. - **Preflight check: every cascaded `kind = "npm"` package must declare a non-empty `repository` field in `package.json`.** `putitoutthere` invokes `npm publish --provenance` on the OIDC trusted-publisher path, and the npm CLI hard-requires this field so the registry can verify the artifact was built from the repo the trusted publisher declares. A missing or empty field previously surfaced as a confusing tail-end npm error after the runner had spun up, OIDC had been negotiated, and the artifact had been built — wasting a full release run on a precondition checkable in milliseconds. The new `requireProvenanceMetadata` runs alongside `requireAuth` in `src/publish.ts`, before any side effects, and reports every failing package in one error rather than failing on the first. Surfaces a new stable error code, `PIOT_NPM_MISSING_REPOSITORY`. Both the canonical object form (`{ type, url, directory? }`) and the legacy single-string form are accepted; only an empty `url` (or no `url` at all) fails. The npm handler's inline backstop is also tightened to match the same predicate (previously `!pkg.repository` slipped `{}`, `{ type: 'git' }`, and whitespace strings through). Documented in [README → `kind = "npm"`](./README.md#kind--npm). See [MIGRATIONS.md](./MIGRATIONS.md#npm-package-json-must-declare-repository). #280. - **Reusable workflow `.github/workflows/build.yml` (`workflow_call`).** PR-time build verification: runs the same plan + build matrix that `release.yml` runs, calling a shared internal `_matrix.yml` reusable workflow so action pins, per-target build steps, and runner selection cannot drift between the two paths. `build.yml` declares only `permissions: contents: read` and contains no publish job, no `id-token: write`, no OIDC trusted-publisher exchange, and no registry auth — the bytes required to publish do not exist on this code path. Two optional inputs forwarded to `_matrix.yml`: `node_version` (default `24`), `python_version` (default `3.12`). Concurrency is keyed on `github.ref` with `cancel-in-progress: true` so PR pushes supersede stale runs (release.yml's repository-keyed group with `cancel-in-progress: false` is unchanged). An `actionlint`-job grep assertion rejects any future patch that adds `id-token: write` to `build.yml` or `_matrix.yml`. See [README → Build check](./README.md#1b-optional-drop-in-githubworkflowsbuild-checkyml) and [MIGRATIONS.md](./MIGRATIONS.md#new-buildyml-reusable-workflow-for-pr-time-build-verification). - **`PIOT_PUBLISH_EMPTY_PLAN` error code.** Surfaced when `publish` is invoked with an empty matrix. Joins `PIOT_AUTH_NO_TOKEN` in the stable error-code vocabulary; foreign agents debugging a failed publish can fingerprint on the code without parsing prose. - **`kind = "npm"` `build` accepts an array of entries with consumer-defined platform-package name templates.** For packages that ship both a napi-rs Node addon and a CLI binary from the same npm package (the `@swc/core` shape), declare `build = [{ mode = "napi", name = "@scope/lib-{triple}" }, { mode = "bundled-cli", name = "@scope/cli-{triple}" }]`. Each entry contributes its own per-platform package family; the main package's `optionalDependencies` spans both. Entries can be bare mode strings (`"napi"`, defaults to `{name}-{triple}` template) or `{ mode, name }` objects. Variables surfaced in `name` templates: `{name}`, `{scope}`, `{base}`, `{triple}`, `{mode}`. `{version}` is intentionally not surfaced. Single-mode string form (`build = "napi"`) preserved byte-for-byte — same artifact-name layout, same platform-package names, no migration pressure on existing consumers. See [README → Recipes → Multi-mode npm family](./README.md#multi-mode-npm-family) and [MIGRATIONS.md](./MIGRATIONS.md#npm-build-accepts-array-of-entries). ### Changed - **`v0` floating tag now tracks main HEAD, not the latest registry release.** A new workflow [`advance-v0.yml`](./.github/workflows/advance-v0.yml) fires on every push to main, builds the action bundle, folds it into a tag-only commit (the same Fold shape `release-npm.yml` already uses — `dist-action/` is gitignored on main, so `v0` must point at a synthesized bundle commit for `uses: thekevinscott/putitoutthere@v0` to resolve to a runnable action), and force-moves `v0` to that commit. Trailer-gated registry releases still fire `release-npm.yml`, which cuts the permanent `putitoutthere-v0.x.y` tags; `v0` is the floating ref consumers compose against, and it stays fresh on every main commit instead of waiting for the next trailered release. Consumer impact: any commit on main — including test-only, docs, and dep-bump commits that don't carry a `release:` trailer — is immediately visible to `@v0` consumers on their next workflow resolve. Issue #199's original contract was "latest released commit in major line"; this PR flips it to "latest main HEAD bundle." Shared concurrency group `release` serializes the new workflow with `release-npm.yml` so they don't race when both fire on the same push. See [MIGRATIONS.md](./MIGRATIONS.md#v0-tracks-main-head). (verified by: unit/ubuntu-latest) - **Default runner for `x86_64-pc-windows-msvc` is now `windows-2022` (was `windows-latest`).** GitHub is migrating `windows-latest` to Visual Studio 2026 between 2026-06-08 and 2026-06-15 ([changelog](https://github.blog/changelog/2026-05-14-github-actions-upcoming-image-migrations/), [runner-images #14016](https://github.com/actions/runner-images/issues/14016)); tracking the floating label would silently move every consumer's Windows release runs onto VS2026 on the cutover, with no opportunity to verify the new toolchain first. `defaultRunsOn` in `src/plan.ts` now returns `windows-2022` for any Windows-shaped triple (`x86_64-pc-windows-msvc`, `*-win32-*`, `*-msvc-*`); consumers who want a different image — `windows-2025` for VS2022-on-Server-2025, `windows-2025-vs2026` to surface VS2026 breakage early, or `windows-latest` to continue tracking the floating label — opt in per-target via the existing `{ triple, runner }` override in `putitoutthere.toml` (see [`README → kind = "npm"`](./README.md#kind--npm) `targets`). No consumer-side change required; existing per-target overrides keep winning. See [MIGRATIONS.md](./MIGRATIONS.md#windows-default-runner-pinned-to-windows-2022). #354. (verified by: unit/ubuntu-latest) - **BREAKING: `kind = "pypi"` packages must declare `[project].dynamic = ["version"]` in `pyproject.toml`.** Static `[project].version = "..."` literals are now rejected at PR time by `putitoutthere check` and again at publish-time preflight, both surfacing the new stable error code `PIOT_PYPI_STATIC_VERSION`. The most common Python-publishing footgun: putitoutthere does not edit `pyproject.toml` at release time (per the [no version computation](./notes/design-commitments.md#non-goals) design commitment), so a literal silently shipped the previous release's wheel/sdist because the build backend read whatever was on disk. The fix is the same across the three supported backends: `hatch-vcs` (recommended), `setuptools-scm`, and maturin's Cargo.toml-driven path — all accept `dynamic = ["version"]`. `pypi.writeVersion` and the `putitoutthere write-version` CLI subcommand also reject the literal shape (previously they rewrote it in place). The `replacePyProjectVersion` helper, the "preserves comments around the literal" carve-out, and the sibling-pyproject-and-Cargo.toml double-rewrite path are removed; under the dynamic contract `Cargo.toml` alone is the bump target for maturin and `SETUPTOOLS_SCM_PRETEND_VERSION` is the handoff for hatch-vcs / setuptools-scm. See [README → Python version source](./README.md#python-version-source--required-shape) and [MIGRATIONS.md](./MIGRATIONS.md#pypi-pyproject-toml-must-declare-dynamic--version). #333. - **BREAKING: `publish` throws on an empty matrix instead of exiting clean.** The reusable workflow's `publish` step now fails red when the plan is empty, with `PIOT_PUBLISH_EMPTY_PLAN` in the message. Previously, an empty plan logged `info: publish: plan is empty; nothing to release` and returned `ok: true` — leaving consumers with green release runs that hadn't published anything. Skips belong at the workflow gate (the `if:` on the publish job that reads the plan job's matrix output), not in the publish step. See [MIGRATIONS.md](./MIGRATIONS.md#publish-throws-on-empty-matrix). ### Deprecated - _nothing yet_ ### Removed - _nothing yet_ ### Fixed - **`bundle_cli` Linux musl builds now install `musl-tools` (the C cross-compiler) before `cargo build`.** `rustup target add` registers the Rust musl target but does not install `x86_64-linux-musl-gcc`. Crates that compile C source at build time — e.g. `libsqlite3-sys` with `features = ["bundled"]`, `openssl-sys` with `features = ["vendored"]` — invoke the C cross-compiler directly; without it, `cargo build` fails with `failed to find tool "x86_64-linux-musl-gcc": No such file or directory`. `_matrix.yml` and `e2e-fixture-job.yml` now run `sudo apt-get install -y musl-tools` and export `CC_=musl-gcc` to `$GITHUB_ENV` before the `cargo build` step, gated on Linux targets (`contains(matrix.target, 'linux')`). No consumer-side change required. See [MIGRATIONS.md](./MIGRATIONS.md#bundle_cli-musl-tools-c-cross-compiler). (verified by: unit/ubuntu-latest) - **`bundle_cli` Linux binaries are now compiled as static musl regardless of the declared package target triple, so the staged binary runs on any modern Linux instead of requiring the build runner's glibc version.** Previously `cargo build --target $TARGET` ran directly on the GitHub-hosted runner. `ubuntu-latest` currently resolves to Ubuntu 24.04 (glibc 2.39), so the produced binary carried a `GLIBC_2.39` symbol requirement and failed at runtime on any older Linux (`./bin: /lib/x86_64-linux-gnu/libc.so.6: version 'GLIBC_2.39' not found`). This affected both the PyPI wheel path (where the `.so` is built inside a manylinux container but the `bundle_cli` binary was staged from outside it) and the npm path (where the binary is compiled directly on the runner). `_matrix.yml` and `e2e-fixture-job.yml` now derive a `BINARY_TARGET` from `matrix.target` by swapping `-linux-gnu*` → `-linux-musl*` and use that for the three steps that touch the binary's compile triple (`rustup target add`, `cargo build --target`, and the stage step's `src=…/target//…` path); `matrix.target` itself is unchanged everywhere else, so npm platform-package names, napi build, wheel tags, and artifact names stay on the original `*-linux-gnu*` triple. Statically-linked musl binaries have no glibc floor and run on any Linux ≥ kernel 3.2 (Alpine, scratch containers, minimal distros included). Non-Linux and already-musl triples pass through unchanged (`-linux-gnu` substring absent → no-op substitution). Hit in the wild on `dirsql` 0.3.8: `pip install dirsql` and `pnpm dlx dirsql` both failed on Ubuntu 22.04 / Debian 12 / Amazon Linux 2. Consumers whose CLI crate dynamic-links system C libraries (`openssl-sys` without `vendored`, `libgit2-sys` without `vendored`, `libsqlite3-sys` without `bundled`, `libpq-sys`, `mysqlclient-sys`) will see a linker error on first release after upgrade — the fix is a one-line Cargo.toml change per case, documented in [README → Recipes → Bundled-CLI npm family](./README.md#bundled-cli-npm-family). See [MIGRATIONS.md](./MIGRATIONS.md#bundle_cli-linux-binaries-compiled-as-static-musl). #381. (verified by: unit/ubuntu-latest) - **pypi `[package.bundle_cli]` binaries now report the planned release version.** The reusable workflow already bumped the maturin package's version source before building wheels, but it did not rewrite the separate CLI crate at `matrix.bundle_cli.crate_path` before the pypi bundle_cli `cargo build`. That meant the wheel metadata could be correct while the staged binary's `CARGO_PKG_VERSION` still came from the stale on-disk crate literal. `_matrix.yml` now runs the same internal `write-crate-version` step used by npm bundled-cli rows immediately before the pypi bundle_cli cargo build, so both PyPI wheels and npm platform packages compile their bundled CLI binaries against `matrix.version`. See [MIGRATIONS.md](./MIGRATIONS.md#pypi-bundle_cli-binary-embeds-the-release-version). #374. (verified by: unit/ubuntu-latest) - **Open-ended `requires-python` inference now includes CPython 3.14.** The checked-in released-CPython list now runs through 3.14, so a package declaring `requires-python = ">=3.11"` plans cp311, cp312, cp313, and cp314 wheel rows instead of silently omitting cp314. Explicit `python_versions` overrides keep winning. See [MIGRATIONS.md](./MIGRATIONS.md#pypi-requires-python-includes-cpython-314). #375. (verified by: unit/ubuntu-latest) - **npm `build = "bundled-cli"` packages now ship a binary that reports the planned release version.** The reusable workflow's `_matrix.yml` cross-compiled the bundled CLI by running `cargo build` against un-rewritten crate source, so the binary's `CARGO_PKG_VERSION` — what ` --version` prints — was baked from whatever literal sat in the crate's `Cargo.toml`. A `@scope/cli-@0.3.5` platform package therefore shipped a binary that reported `0.2.7` (the on-disk crate literal) instead of the published version. The pypi/maturin path already solves the equivalent skew with a pre-build `write-version` step, but `write-version` is tied to maturin's dynamic-version contract — it requires a `pyproject.toml` declaring `dynamic = ["version"]`, which the npm bundled-cli crate has no reason to carry. `_matrix.yml` now runs a new internal `putitoutthere write-crate-version` CLI subcommand against `matrix.bundle_cli.crate_path` immediately before the npm bundled-cli `cargo build`, rewriting `[package].version` to `matrix.version` so the compiled binary embeds the planned version; the internal `e2e-fixture-job.yml` mirrors the step so the fixture suite exercises the same pre-build write a real consumer's release does. `write-crate-version` rewrites only `Cargo.toml`'s `[package].version` and fails loud when the manifest is missing or has no version field. The CLI subcommand is internal — consumers compose with the reusable workflow, not directly with the CLI. See [MIGRATIONS.md](./MIGRATIONS.md#npm-bundled-cli-binary-embeds-the-release-version). #366. (verified by: unit/ubuntu-latest) - **`build = "bundled-cli"` npm platform packages now ship the CLI binary with the executable bit set.** The cross-compiled binary is staged into a per-triple platform package (`@scope/cli-`) and referenced via `package.json#main` — not as a `bin` entry — so npm never `chmod`s it, and the executable bit it had on the build runner is stripped crossing the GitHub Actions artifact upload/download boundary. The staged binary therefore packed as `0644`, and at runtime the generated launcher's `spawnSync` of the resolved binary failed with `EACCES` (`spawnSync .../node_modules/@scope/cli-/ EACCES`). `synthesizePlatformPackage` now `chmod +x`es the staged binary for every non-Windows target before the package is packed/published, so `npm pack` preserves an executable `0755` mode. Hit in the wild on `thekevinscott/dirsql`. See [MIGRATIONS.md](./MIGRATIONS.md#bundled-cli-staged-binary-is-executable). #365. (verified by: unit/ubuntu-latest) - **The repository-visibility preflight check no longer hard-fails a publish when the GitHub API call is rate-limited.** The `requireRepoPublic` check (added the same release) calls `https://api.github.com/repos/{owner}/{repo}` to confirm the repo is public. `checkRepoPublic` previously threw on any non-200/404 status, and no release-path workflow exposed `GITHUB_TOKEN` to the publish step — so the call always went out unauthenticated against the 60-requests/hour shared-IP limit. A multi-fixture e2e run (or any busy runner) exhausts that budget, the API returns `403`, and the publish aborted on a check that has nothing to say about visibility. Two changes: (1) `release.yml`, `release-npm.yml`, and `e2e-fixture-job.yml` now set `GITHUB_TOKEN: ${{ github.token }}` on the publish step, so the call is authenticated (5000 requests/hour); (2) `checkRepoPublic` now treats any non-200/404 response — and a network error — as indeterminate and non-fatal, emitting a `::warning::` instead of throwing. A genuinely private repo still returns `200` with `private: true` (or `404`) and is still hard-failed; only "we could not reach the API" stops blocking releases. (verified by: unit/ubuntu-latest) - **`[package.bundle_cli]` now works end-to-end for crates that live in a cargo workspace.** Two bugs collided to make the standard workspace layout (a workspace root `Cargo.toml` with `[[bin]]` in a member crate — what `cargo new --workspace` and the polyglot Rust/Python recipe both produce) unsatisfiable: any `crate_path` value that made the PR-time check pass broke the build's stage step, and vice versa. (1) `putitoutthere check` parsed `/Cargo.toml` literally and never walked `[workspace].members`, so `crate_path = "."` (the default) on a workspace root reported the bin as missing even though `cargo build --bin ` resolves it transparently from anywhere in the workspace. (2) The reusable workflow's bundle_cli cargo-build step ran with `working-directory: ${{ matrix.bundle_cli.crate_path }}` but read the produced binary from `${{ matrix.bundle_cli.crate_path }}/target/...` — and cargo writes to the workspace-rooted target dir by default, so `crate_path = "packages/rust"` (the value that satisfied the check) couldn't satisfy the build. The check now walks `[workspace].members` (honoring `[workspace.package]` name inheritance for the implicit-binary rule) and the cargo invocation pins `--target-dir target` so the output path is deterministic by construction. Hit in the wild on `thekevinscott/dirsql`. See [MIGRATIONS.md](./MIGRATIONS.md#bundle_cli-cargo-workspace). #337. - **The `[package.bundle_cli]` config check now resolves a member crate whose `[workspace].members` entry is a glob.** #337 taught `putitoutthere check` (and the pre-publish `PIOT_CRATES_MISSING_BIN` preflight) to walk `[workspace].members` so a workspace-root `crate_path = "."` resolves a member crate's `[[bin]]` — but it only handled *literal* member entries. cargo `members` entries are globs, and `members = ["packages/*"]` (a Rust core crate under `packages/rust` wrapped by sibling Python / npm packages) is the standard polyglot-repo shape. A glob entry never resolved to a literal `/Cargo.toml`, so the member's `[[bin]]` went unseen and `crate_path = "."` was still rejected with `bundle_cli.bin "X" is not declared as a [[bin]]`. Both check tiers now expand `[workspace].members` globs against the filesystem the way cargo resolves them. No new error codes; no config-surface change. See [MIGRATIONS.md](./MIGRATIONS.md#bundle_cli-glob-workspace-members). #361. (verified by: unit/ubuntu-latest) - **`check` no longer false-positives `PIOT_CRATES_MISSING_METADATA` on crates whose `Cargo.toml` inherits `description` / `license` / `license-file` from `[workspace.package]`.** Cargo's recommended pattern for shared crate metadata is `[workspace.package]` in the workspace root plus `.workspace = true` in each member; `cargo publish` resolves the inheritance and embeds the literal value into `Cargo.toml.orig` before upload, so crates.io receives the resolved field. The check previously parsed the member `Cargo.toml` in isolation and treated `{ workspace: true }` as a missing string, flagging well-formed workspaces as if they would fail publish. `checkCratesMetadata` now walks up from each crate's `path` to find the nearest parent `Cargo.toml` with a `[workspace]` table and, when a field is declared as `.workspace = true`, resolves it from `[workspace.package]` before deciding it's missing. Genuinely-missing fields (workspace root has no value for the inherited key, or no `[workspace.package]` block at all) still report through `PIOT_CRATES_MISSING_METADATA`. Hit in the wild in `thekevinscott/dirsql#177`. See [MIGRATIONS.md](./MIGRATIONS.md#crates-metadata-check-resolves-workspace-package-inheritance). #328. - **`kind = "pypi"` + `build = "hatch"` now publishes a wheel alongside the sdist.** A pure-Python hatch package previously planned only a `target = "sdist"` row, so PyPI ended up with sdist-only and downstream `pip install` / `uvx ...` had to provision hatchling and run `python -m build` on a cold cache (several seconds per invocation) instead of doing a sub-second download-and-extract. `pypa/build`'s default on a pure-Python tree is to produce both an sdist and an any-platform wheel; the planner just wasn't asking for the wheel. The matrix now emits a second row per hatch package — `target = "any"`, `artifact_name = -wheel-any` — and the reusable workflow's build step runs `python -m build --wheel --outdir dist` (with `SETUPTOOLS_SCM_PRETEND_VERSION` set, mirroring the sdist row's contract). Scoped to hatch per the issue: `build = "setuptools"` stays sdist-only (consumers who want a wheel there can switch to hatch or supply their own wheel), and `build = "maturin"` keeps its per-target wheel rows. Consumers' existing `pypi-publish` job picks up the new wheel automatically: the recommended `actions/download-artifact@v8` step in the README already uses `pattern: '*-wheel-*'`. Hit in the wild on `repo-name-checker` 0.1.0. See [MIGRATIONS.md](./MIGRATIONS.md#hatch-wheel-any-row). #324. - **Single-artifact publish jobs no longer fail completeness with `missing artifact directory /`.** `actions/download-artifact@v8` is count-sensitive: with `path: artifacts` and no `name`/`pattern` filter, multiple artifacts each get their own `artifacts//` subdir (the documented multi-case the engine relies on), but a single artifact extracts directly into `artifacts/` with no per-artifact subdir. Consumers whose plan emits exactly one expected artifact — canonical case: pure-Python `[[package]]` with `build = "hatch"`, which emits an sdist row only — therefore aborted at the engine's completeness check before any side effect ran. The reusable workflow's publish job now normalizes the layout in-process before completeness: when the plan expects a single staged artifact and the documented subdir is absent, files in `artifacts/` are moved into `artifacts//` so the rest of the engine sees the contract it was written against. No-op in the multi-artifact case, when the subdir already exists, or when nothing was downloaded (crates-only / vanilla-npm plans). No consumer-side change required. Hit in the wild on a pypi-only consumer with a single sdist row; multi-artifact consumers (pypi + npm, or sdist + wheels) were unaffected. See [MIGRATIONS.md](./MIGRATIONS.md#single-artifact-publish-layout-normalization). #311. - **Bundled-CLI / napi npm consumers' `npm run build` step now sees `TARGET` and `BUILD` env vars on every matrix row.** The reusable workflow's `_matrix.yml` and `release.yml` previously ran the npm build step with no env block, so consumers' build scripts that read `process.env.TARGET` to know which triple to cross-compile saw `undefined` and either crashed or silently no-oped. Every per-platform matrix row then uploaded an empty `build//` directory and `actions/upload-artifact@v7` flagged `No files were found with the provided path: ...`. The internal `e2e-fixture-job.yml` already passed `TARGET` / `BUILD` correctly — meaning the fixture suite passed but a real consumer's first publish still failed; an integration-tier divergence rather than a behavior bug per se. Both the build matrix step and the publish-job rebuild step in `release.yml` now set the env block. `_matrix.yml` exposes `TARGET=${{ matrix.target }}` / `BUILD=${{ matrix.build }}` per row; `release.yml`'s rebuild loop sets `TARGET=main BUILD=` per iteration (the publish-time rebuild only fires for the main package's row, since per-platform sub-packages stage from `artifacts/` via the engine's npm-platform handler). The README's [Bundled-CLI npm family](./README.md#bundled-cli-npm-family) recipe gained the consumer-side build-script contract that was previously missing — TARGET/BUILD vocabulary and a minimal `scripts/build.cjs` covering the simple single-workspace case. Hit in the wild on `thekevinscott/darkfactory`'s first release; tracked at #287. See [MIGRATIONS.md](./MIGRATIONS.md#npm-build-step-target-build-env-vars). - **First-publish bundled-cli / napi npm builds no longer fail on lockfile drift.** Consumers of the bundled-cli / napi shape declare `optionalDependencies` for `-@` platform packages that this pipeline publishes. On the very first publish those entries 404 on the registry; pnpm 10 silently drops 404'd optionals from the lockfile when it is regenerated locally; a subsequent CI run with `pnpm install --frozen-lockfile` (or `npm ci`) refuses because lockfile and `package.json` disagree. Both install steps in the reusable workflow — `_matrix.yml`'s build-matrix install and `release.yml`'s publish-job rebuild step (added in #256) — now self-heal: a failed strict install falls back to its non-strict form (`pnpm install --no-frozen-lockfile` / `npm install`) with a `::warning::` line in the run log naming the recovery. No consumer-side change required; lockfiles can stay committed and `optionalDependencies` can stay declared. Hit in the wild on `thekevinscott/darkfactory`'s first release (#integration-2026-05-bundled-cli). The README's [Bundled-CLI npm family](./README.md#bundled-cli-npm-family) recipe grew a `[!NOTE]` callout documenting the chicken-and-egg and the workflow's transparent recovery. See [MIGRATIONS.md](./MIGRATIONS.md#first-publish-bundled-cli-lockfile-self-heal). - **Platform-package npm publishes (bundled-cli / napi) now honor the consumer's `.npmrc` for registry auth.** Earlier versions ran `npm publish` for each synthesized per-triple platform package with `cwd: ` and no folder arg, so npm — which reads `.npmrc` from cwd upward — never saw the auth file the consumer's release path writes alongside the package. On real npm this was masked because OIDC trusted publishing supplies auth via `ACTIONS_ID_TOKEN_REQUEST_TOKEN` in the environment rather than from `.npmrc`. The gap surfaced for: (a) the `NPM_TOKEN` bootstrap path (#310) on a bundled-cli / napi family's first publish, where the consumer's `.npmrc` carries the long-lived token that platform PUTs needed too; (b) the internal Verdaccio e2e seam (#304), where the per-fixture `.npmrc` carries the Verdaccio auth token. Symptom in both cases: platform PUTs went out unauthenticated, registry returned a 4xx, the engine reported `npm publish (platform) failed` with a stderr that didn't always make the auth shape obvious. The fix runs `npm publish ` from `cwd: pkg.path` instead — matching what `src/handlers/npm.ts`'s main-package publish already does. No change to the OIDC happy path: platform publishes still get the same `--access=...`, `--tag=...`, `--provenance` (when `ACTIONS_ID_TOKEN_REQUEST_TOKEN` is set), and `--registry=...` (when `PIOT_NPM_REGISTRY` is set) flags they had before. Surfaced by the new `js-bundled-cli-first-publish` e2e fixture. See [MIGRATIONS.md](./MIGRATIONS.md#platform-publish-npmrc-lookup). #305. - **pypi/maturin `[package.bundle_cli]` now actually ships the bundled binary inside published wheels.** The recipe was advertised as shipped in v0.2.0 (#217) — config parsing accepted `[package.bundle_cli]`, the planner attached it to per-target wheel rows, and `MIGRATIONS.md` named the two scaffolded build steps consumers should expect. None of those steps existed in `.github/workflows/_matrix.yml`. Consumers who declared the block (us, in `thekevinscott/dirsql`) shipped wheels missing the binary; `pip install && ...` failed at runtime with `FileNotFoundError`. The reusable workflow's build job now, for every per-target wheel row that carries `matrix.bundle_cli`: (1) `rustup target add ${{ matrix.target }}`, (2) `cargo build --release --target ${{ matrix.target }} --bin ${{ matrix.bundle_cli.bin }}` against `crate_path`, (3) copies the resulting binary into `${{ matrix.path }}/${{ matrix.bundle_cli.stage_to }}/` so maturin's `[tool.maturin].include` glob picks it up as wheel data, and (4) runs a permanent post-build wheel-content guard that opens the produced `.whl` and refuses to upload-artifact if `/` is missing. The guard is independent of staging — it catches any future regression where the cross-compile silently routes the binary to the wrong path. Consumers do not need to change their existing `[package.bundle_cli]` config or their `[tool.maturin].include` glob; the recipe just starts working. The cross-compile assumes the binary is buildable with a vanilla `cargo build --release --bin ` (no `--features`, no env, no special flags); crates that gate the CLI behind a Cargo feature are not yet supported. The `.exe` suffix on Windows is handled. See [README → Recipes → Rust CLI inside a PyPI wheel](./README.md#rust-cli-inside-a-pypi-wheel) and [MIGRATIONS.md](./MIGRATIONS.md#bundle_cli-now-actually-stages-the-binary). #282. - **`putitoutthere.toml` validation now names common typos in the failure message.** A consumer integration shipped a config with `version` at the file root, `[[packages]]` (plural), `registry =` instead of `kind =`, and `files =` instead of `globs =`. The raw zod errors (`Invalid input: expected object, received undefined; ...; Unrecognized keys: "version", "packages"`) were opaque enough that the engine source had to be re-read to recover. A pre-pass in `parseConfig` now detects each of those four mistakes by name and emits a hint that pairs the wrong shape with the right one, e.g. `top-level table is \`[[packages]]\` (plural) but should be \`[[package]]\` (singular)`. README's [Drop in `putitoutthere.toml`](./README.md#2-drop-in-putitoutthere-toml) section grew a four-row "wrong → right" table covering the same four traps so the docs and the engine name them the same way; a new `[!IMPORTANT]` callout in [Drop in `.github/workflows/release.yml`](./README.md#1-drop-in-githubworkflowsreleaseyml) warns consumers off `push: branches: [main]` triggers on lane CI workflows (which fire duplicate runs against the merge commit and contend for runners with `release.yml`); `1b.` was promoted from "Optional" to "Recommended" since `build.yml` is the cheapest place to catch a malformed config before merge. See [MIGRATIONS.md](./MIGRATIONS.md#friendly-config-error-hints). - **pypi/maturin wheels now ship at the planned version instead of the literal in `pyproject.toml`.** The reusable workflow's build matrix (`_matrix.yml`) now bumps `[project].version` in `pyproject.toml` (or `[package].version` in `Cargo.toml` when `pyproject.toml` declares `dynamic = ["version"]`) to `matrix.version` before each `PyO3/maturin-action@v1` invocation. Maturin reads its version source from disk at build time and honors no env override — `SETUPTOOLS_SCM_PRETEND_VERSION` is a setuptools-scm / hatch-vcs feature, not a maturin one. Without the bump, wheels left the build runner at whatever literal happened to be in the consumer's manifest — diverging from the planned version, tripping PyPI's "file already exists" rejection at upload, and turning otherwise-clean release runs red even when crates and npm shipped correctly. The bump is implemented as a new internal `putitoutthere write-version` CLI subcommand and exposed through the JS action's existing surface (new `version:` input on `action.yml`); the e2e fixture harness now verifies wheel `METADATA: Version:` matches `matrix.version` post-build. The CLI subcommand and action input are internal — consumers compose with the reusable workflow, not directly with the CLI. Hit in the wild on `dirsql`'s 0.2.8 release (issue #276). See [MIGRATIONS.md](./MIGRATIONS.md#pypi-maturin-version-bump-at-build). - **npm publish no longer fails red when npm CLI retries a successful PUT.** When the registry acks a publish but the response comes back flaky to the client (timeout / 502 / connection reset), the npm CLI retries with the same payload. The retry lands on a registry that already has the new version and exits `E403 "cannot publish over the previously published versions: "`. Both the platform-package handler (`src/handlers/npm-platform.ts:npmPublish`) and the main publish path (`src/handlers/npm.ts:publishImpl`) now detect that exact stderr shape and treat it as success — the package is already on the registry at the requested version. Hit in the wild on PR #257's polyglot-everything multi-mode publish (10 platform packages × ~1 in 10 chance per request meant the race was nearly guaranteed). - **Crates publish no longer refuses on workflow-managed install state in sibling packages.** Before, the engine's pre-publish dirty-workspace check (`scanDirtyOutsideManifest`) flagged anything dirty outside the package's `Cargo.toml`. For polyglot consumers (rust + js in one repo), the reusable workflow's `Build npm packages` step (#256) creates `node_modules/`, `package-lock.json`, and `dist/` inside each npm package's path before cargo publish runs — these are workflow scratch, not stray edits, and cargo can't pack them anyway (it only packs files inside the crate's own dir). The check now whitelists every other configured package's path, similar to how it already whitelists `artifacts/`. Stray edits elsewhere in the repo (a `README.md` change, etc.) still fail the check. Hit in the wild on the polyglot-everything e2e fixture; would also have hit any consumer with the same shape. - **Dogfood release workflow no longer silently downgrades `release: minor` to `patch`.** `release-npm.yml`'s "Fold action bundle into release commit" step now forwards the parent commit's body into the bundle commit, so any `release:` trailer the operator wrote in the merge commit survives into the new HEAD. Without the forward, `putitoutthere`'s publish-time plan re-derivation read HEAD (the bundle commit), saw no trailer on a single-parent commit, and defaulted the bump to `patch` — silently downgrading a `release: minor` to `0.x.(y+1)`. Hit in the wild on the 0.1.51 → 0.2.0 attempt that landed as 0.1.52. The reusable consumer-facing `release.yml` was unaffected (it never adds a commit between plan and publish). Internal-seam fix; no consumer-side action required. ## v0.1.51 → v0.2.0 ### Added - **`workflow_call` output `has_pypi`.** The reusable workflow now emits a string `'true'`/`'false'` indicating whether the planned matrix contains any `kind = "pypi"` rows. Consumers gate their caller-side `pypi-publish` job on this so non-PyPI repos paste the canonical template verbatim without paying any runtime cost. Computed in the `plan` job from the matrix output. - **Reusable workflow `.github/workflows/release.yml` (`workflow_call`).** The single user-facing surface. Consumer integration is one `uses: thekevinscott/putitoutthere/.github/workflows/release.yml@v0` line in their own `release.yml`; pinned action versions, plan/build/publish orchestration, and GitHub Release creation all live inside. Three optional inputs: `environment` (default `release`), `node_version` (default `24`), `python_version` (default `3.12`). No `dry_run`, `working_directory`, or `config` inputs — the plan job is already side-effect-free, the config file is `putitoutthere.toml` at the repo root, period. The engine is invoked via `uses: thekevinscott/putitoutthere@v0` so the workflow file and the engine always agree on a single git ref. - **`[package.bundle_cli]` recipe for maturin pypi packages** (#217). Opt-in declarative shape for libraries that ship a Rust CLI inside each wheel (the `ruff` / `uv` / `pydantic-core` pattern). Declare `bin`, `stage_to`, and optional `crate_path`; the reusable workflow cross-compiles the binary per target, stages it into the package source tree, and maturin picks it up via `[tool.maturin].include`. Requires `build = "maturin"` and non-empty `targets`. See [README → Recipes → Rust CLI inside a PyPI wheel](./README.md#rust-cli-inside-a-pypi-wheel). No behavior change for existing packages that don't declare the block. ### Changed - **Reusable workflow + `action.yml` bumped to Node 24-compatible action majors.** GitHub deprecated Node 20 actions in September 2025; the runner forces Node 24 starting June 2, 2026 and removes Node 20 entirely on September 16, 2026. Bumps inside `.github/workflows/release.yml`: `actions/checkout@v4 → @v6`, `actions/setup-node@v4 → @v6`, `actions/setup-python@v5 → @v6`, `actions/upload-artifact@v4 → @v7`, `actions/download-artifact@v4 → @v8`. `action.yml` `runs.using: node20 → node24`. The canonical `pypi-publish` template in the README also bumps `actions/download-artifact@v4 → @v8` so consumers can paste a warning-free template; existing `@v4` copies in consumer workflows keep working but emit the deprecation warning in the caller's context. Artifact contract is unchanged: `download-artifact@v8` preserves the per-name subdirectory layout for downloads-by-name and `upload-artifact@v7`'s default behavior still produces zipped uploads keyed by `name:`. See [MIGRATIONS.md](./MIGRATIONS.md#reusable-workflow--actionyml-move-to-node-24-actions). (#253) - **BREAKING: PyPI uploads moved to a caller-side `pypi-publish` job.** PyPI's Trusted Publisher matching filters candidates by `repository_owner` + `repository_name` *before* checking `job_workflow_ref` ([Warehouse implementation](https://github.com/pypi/warehouse/blob/main/warehouse/oidc/models/github.py)); since the OIDC `repository` claim always reflects the caller's repo even inside a reusable workflow, a TP registered against `thekevinscott/putitoutthere` is filtered out before the workflow_ref is ever checked. PyPI explicitly documents this as unsupported ([troubleshooting](https://docs.pypi.org/trusted-publishers/troubleshooting/)). Tracked at [pypi/warehouse#11096](https://github.com/pypi/warehouse/issues/11096), no timeline. The engine still does plan + build + version-rewrite + git tag for PyPI; the actual upload (`pypa/gh-action-pypi-publish`) now runs in the consumer's workflow file as a second job, gated on `needs.release.outputs.has_pypi`. The canonical template grew from ~12 → ~30 lines but remains a single copy-paste — the `if:` skips the job for non-PyPI repos. See [MIGRATIONS.md](./MIGRATIONS.md#pypi-uploads-moved-to-caller-side-job) and [`notes/audits/2026-04-28-pypi-tp-reusable-workflow-constraint.md`](./notes/audits/2026-04-28-pypi-tp-reusable-workflow-constraint.md). - **Public consumer surface collapsed to the README + the reusable workflow.** The CLI, the JS action (`action.yml`), and the diagnostic subcommands (`doctor`, `preflight`) are internal seams the reusable workflow invokes; consumers do not call them. The entire `docs/` directory is removed — `README.md` is the single user-facing surface. See [MIGRATIONS.md](./MIGRATIONS.md#public-surface-collapsed-to-a-reusable-workflow) for the before/after. - **Auth is OIDC trusted publishers only.** The reusable workflow does not pass long-lived registry tokens (`NPM_TOKEN`, `PYPI_API_TOKEN`, `CARGO_REGISTRY_TOKEN`) as secrets. The engine's env-var fallback code paths still exist (in `src/auth.ts`); they're just not reachable through the reusable workflow. - **Repository renamed `put-it-out-there` → `putitoutthere`.** GitHub auto-redirects the old slug, but consumers with the old URL pinned in `package.json`, `Cargo.toml`, `pyproject.toml`, or workflow files should update them. See [MIGRATIONS.md](./MIGRATIONS.md#repository-renamed-put-it-out-there--putitoutthere). - **BREAKING: `[[package]].paths` renamed to `[[package]].globs`.** The `path`/`paths` pair was confusing — singular and plural differed only in a trailing `s` while meaning two unrelated things (the package working directory vs. the cascade-trigger globs). Configs declaring `paths` now fail validation. See [MIGRATIONS.md](./MIGRATIONS.md#package-paths-renamed-to-globs). ### Deprecated - _nothing yet_ ### Removed - **The entire `docs/` directory** (VitePress site, all guide pages, all shape walkthroughs). README is the single user-facing surface. Engine contracts that were documented in `docs/guide/{artifact-contract,runner-prerequisites}.md` moved to `notes/internals/` — internal references the reusable workflow honors so consumers don't have to know them. - **`.github/workflows/docs.yml` and `docs-test.yml`** — the docs site no longer exists. - **`build_workflow:` config field.** Removed from the schema in `src/config.ts`; configs declaring it now fail validation. - **`putitoutthere init` subcommand** and the templates it scaffolded. Source removed (`src/init.ts`, `src/templates.ts`, plus tests, plus `--force` and `--cadence` flag plumbing). - **`migrations/` directory** moved to `notes/migrations-pre-rewrite/`. Stale plans drafted against the prior hand-written-`release.yml` model. - **`[package.trust_policy]` config block + the OIDC trust-policy diff machinery.** Removed: `src/oidc-policy.ts`, `src/registries/crates-trust.ts`, the `trust_policy` schema field, and the matching README section. The check was opt-in defensive validation — for npm + PyPI it could only verify "the workflow file you declared exists locally" (no public read API for trust-policy config), making it a typo-catcher rather than a real drift detector. crates.io's registry cross-check was the only path with real bug-catching power and required a separate `CRATES_IO_DOCTOR_TOKEN`. Net: false security; renaming `release.yml` still produces an HTTP 400 from the registry, which is the same UX as before and what every other tool gives you. - **`putitoutthere doctor` subcommand + `src/doctor.ts`.** Its main job was the trust-policy validation phases above. With those gone, doctor was checking workflow structural details that are already validated by the reusable workflow's own shape. Internal seam, no consumer use case. - **`putitoutthere preflight` subcommand + `src/preflight-run.ts`.** Standalone diagnostic CLI surface. The internal `requireAuth` check that gates `publish` is preserved (`src/preflight.ts` stays). - **`putitoutthere token` subcommand + `src/token.ts`, `src/token-scope.ts`.** The `token list` / `token inspect` operator-debugging surface was built when long-lived registry tokens were the norm. Under OIDC-only there's nothing to enumerate or scope-check at the env level. ~1,100 lines removed. - **`putitoutthere auth login/logout/status` subcommand + `src/auth.ts`, `src/keyring.ts`.** The GitHub App device-flow login existed solely to power `token list --secrets` (which fetched secret names via the GitHub API). With `token` removed, the App + keyring + device-flow plumbing is dead weight. ~500 lines removed. The `putitoutthere-cli` GitHub App registration is no longer used. - **`src/release.ts` (engine-side GitHub Release creation).** Duplicated by the reusable workflow's `gh release create --generate-notes` step. The engine cuts the tag and stops; the workflow owns the Release. ~129 lines removed. - **Dead config fields**: `cadence`, `agents_path`, `smoke`, `wheels_artifact`. Defined in the schema; never read anywhere in the engine. - **`--preflight-check` flag on `publish`.** The deep token-scope check it gated was the sole consumer of `src/token-scope.ts`. Same reason as the `token` subcommand removal: under OIDC-only there's no long-lived token to scope-check. ### Fixed - **Reusable workflow's publish job now installs and builds npm packages before `npm publish` runs.** (#256) For vanilla npm rows the plan emits `artifact_path: package.json`, so the build job's `dist/` was never carried through to the publish job — which then ran `npm publish` from a fresh checkout and shipped tarballs missing the compiled output. Any consumer whose `package.json` declared `"files": ["dist", ...]` would publish a broken artifact (caught in the wild on a downstream consumer). The publish job now mirrors what a developer running `npm publish` locally would do: detect the lockfile, install deps, run `npm run build --if-present` per npm package path. napi / bundled-cli platform packages stage from `artifacts/` and were unaffected; this fix only changes the main-package path. - **PyPI artifact discovery now matches the documented `{name}-sdist` and `{name}-wheel-{target}` shapes exactly.** (#244) Previously the handler used a bare prefix match (`entry.startsWith("{name}-")`), which silently picked up sibling packages whose names extended the same prefix (e.g. `foo`'s discovery matched `foo-extras-sdist`). The handler now matches the sdist directory exactly and the wheel directories by `{name}-wheel-` prefix only. Affects multi-package repos where one pypi package's name is a prefix of another's. - **Reusable workflow's maturin sdist row now uses `command: sdist`.** (#244) `maturin build --sdist` builds a wheel AND an sdist; the sdist row's artifact tarball ended up containing a manylinux wheel that collided with the per-target wheel rows at upload time, causing twine to abort with `400 File already exists`. Splitting the sdist invocation to use `command: sdist` (sdist-only) eliminates the collision. - **Synthesized npm platform packages now inherit `repository`, `license`, and `homepage` from the main `package.json`.** (#244) npm's provenance verifier rejected platform tarballs with `E422 Error verifying sigstore provenance bundle: Failed to validate repository information: package.json: "repository.url" is "", expected to match "https://github.com//"`. The synthesizer used to write only `name`/`version`/`os`/`cpu`/`files`/`main`/`libc`; the empty repository URL didn't match the publishing repo baked into the sigstore bundle. Identity fields are now copied from the main package so per-target tarballs validate. Affects `build = "napi"` and `build = "bundled-cli"` packages. - **Reusable workflow's npm build step now forces `shell: bash`.** (#244) The build matrix can target Windows runners, where GitHub Actions defaults to `pwsh` for `run:` blocks. The npm build's `if [ -f package-lock.json ]` branch is bash syntax, which PowerShell parsed as a malformed expression and aborted with `ParserError`. Adding `shell: bash` makes the step portable across Linux, macOS, and Windows runners. No config changes required for consumers; pure JS-on-ubuntu setups were unaffected. - **Reusable workflow now exchanges OIDC ID-token for a `CARGO_REGISTRY_TOKEN` before invoking the engine.** (#244) `cargo publish` was failing with `error: no token found, please run cargo login` because the publish job's env had no `CARGO_REGISTRY_TOKEN`. PyPI uploads (twine) and npm publish both consume the OIDC ID-token directly via registry-side acceptance; cargo doesn't — it needs a registry-issued bearer token, which `rust-lang/crates-io-auth-action@v1` produces from the OIDC ID-token. The workflow now runs that action conditionally (only when the plan contains a `kind = "crates"` row) and exports the resulting token to `$GITHUB_ENV` for the engine subprocess. No config or workflow changes required for consumers. - **Crates publish's pre-cargo dirty-tree check now ignores the reusable workflow's `artifacts/` scratch directory.** (#244) The pre-publish guard scans `git status --porcelain` for stray edits outside the managed `Cargo.toml` (the engine passes `--allow-dirty` to cargo to permit the writeVersion bump, then re-imposes a narrower check). Reusable workflow's `actions/download-artifact@v4` step always creates `artifacts/` under cwd, even for crates-only fixtures with nothing to download — the pre-check was rejecting with `unexpected dirty files in the working tree outside ... - artifacts/`. The scan now treats `${ctx.artifactsRoot}` (the dir the engine itself populates) as engine-managed and skips it. No config or workflow changes required. - **Crates publish no longer fails the completeness check.** (#244) `cargo publish` packages and uploads from source on the registry side, so the reusable workflow never produces a `-crate/` artifact directory. The pre-publish completeness check was demanding a `.crate` file that nothing in the pipeline ever creates, which made any consumer with a `kind = "crates"` package fail with `missing artifact directory -crate/` before cargo was ever invoked. Crates rows now skip the completeness check (same reasoning as vanilla npm rows). No config or workflow changes required. - **Scaffolded `release.yml` now forwards `GITHUB_TOKEN` to the publish step.** piot has cut GitHub Releases alongside tag pushes since #26, but Actions doesn't auto-mount the runner token as an env var, so the scaffolded workflow's publish step ran without `GITHUB_TOKEN` and silent-skipped Release creation — leaving consumers with tags but no Releases page entries. The publish job's `env:` block now includes `GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}`. Existing `release.yml` files need a one-line patch — see [MIGRATIONS.md](./MIGRATIONS.md#scaffolded-releaseyml-now-forwards-github_token). - **`/` in `[[package]].name` is now safe — planner encodes it for `actions/upload-artifact@v4`** (#230). Polyglot-monorepo grouping shapes (e.g. `name = "py/foo"`, `"js/foo"`) used to produce `artifact_name` values containing `/`, which `actions/upload-artifact@v4` rejects with `The artifact name is not valid: ... Contains the following character: Forward slash /`, failing the build job before piot ever ran. The planner now encodes each `/` to `__` in `artifact_name` (so `py/foo` → `py__foo-sdist`) and config validation reserves `__` in `pkg.name` so the round-trip stays unambiguous. Other upload-artifact-forbidden characters (`\`, `:`, `<`, `>`, `|`, `*`, `?`, `"`) are now rejected at config load. Read sites (`publish`, `doctor`, `preflight`, `completeness`) consume `artifact_name` verbatim and need no changes; consumers running a prior `/`-encoding workaround should remove it once they upgrade. See [MIGRATIONS.md](./MIGRATIONS.md#package-names-with--no-longer-need-an-encode-decode-workaround) and [Artifact contract → notes](./notes/internals/artifact-contract.md#naming-convention-reference). - **Documentation accuracy pass** (#231). A docs-vs-code audit caught several places where reference material lagged behind shipped behavior. No code paths changed beyond a stale help-text line; existing configs and workflows are unaffected. - `putitoutthere --help` text for `--json` no longer claims "(plan only)" — the flag has worked across every command that emits a result since their respective additions. `docs/api/cli.md` now lists the supported commands explicitly. - `docs/api/cli.md` documents short flags (`-h`, `-v`, `--version`) and exit codes (`0` / `1` / `4`). - `docs/api/action.md` documents the `outputs.matrix` contract (output key omitted when empty, not "empty string"), the matrix-row field schema, and the GitHub Release body shape. - `action.yml`'s `command` description and `docs/api/action.md` clarify that the action shells through to any putitoutthere CLI subcommand, not just `plan` / `publish` / `doctor`. - `docs/guide/configuration.md` adds the previously-shape-only `pypi` field to the central `kind = "pypi"` table. - `docs/guide/trailer.md` documents the package-name character grammar, leading-whitespace tolerance, and last-wins semantics. - `README.md`'s scaffolding description now correctly mentions both workflow files written by `putitoutthere init`. - **Publish path works end-to-end for slash-containing `pkg.name`** (#237). Two follow-up bugs that #230 didn't catch: (1) `pypi.ts.collectArtifacts` and `npm-platform.ts.synthesizePlatformPackage` both built directory lookups from raw `pkg.name`, so a package called `py/foo` couldn't match the encoded on-disk directory `py__foo-sdist/` and the publish step reported `pypi: no artifacts found for py/foo under `. (2) The planner emitted glob `artifact_path` values (`${pkg.path}/dist/*.tar.gz`, `${pkg.path}/dist/*.whl`, `${pkg.path}/target/package/*.crate`), which `actions/upload-artifact@v4` treats differently from a directory `path:` — it preserves the workspace-relative path, so the file lands at `/packages/python/dist/foo.tar.gz` instead of `/foo.tar.gz`. Both bugs fixed: handlers now encode `pkg.name` via `sanitizeArtifactName` and walk the artifact directory recursively for the expected file extensions; planner emits directory-shaped `artifact_path` values for the three slots that previously used a glob. Consumers using `${{ matrix.artifact_path }}` verbatim see no required workflow changes; consumers who hand-coded a glob path should switch to the directory shape — see [MIGRATIONS.md](./MIGRATIONS.md#publish-path-works-end-to-end-for-slash-containing-pkgname).