putitoutthere 0.2.29 → 0.2.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/MIGRATIONS.md +1311 -0
  3. package/README.md +373 -41
  4. package/action.yml +9 -0
  5. package/dist/action.d.ts.map +1 -1
  6. package/dist/action.js +28 -6
  7. package/dist/action.js.map +1 -1
  8. package/dist/cascade.js +14 -7
  9. package/dist/cascade.js.map +1 -1
  10. package/dist/check-crate-size.d.ts +26 -0
  11. package/dist/check-crate-size.d.ts.map +1 -0
  12. package/dist/check-crate-size.js +99 -0
  13. package/dist/check-crate-size.js.map +1 -0
  14. package/dist/check.d.ts.map +1 -1
  15. package/dist/check.js +116 -16
  16. package/dist/check.js.map +1 -1
  17. package/dist/cli.d.ts +17 -4
  18. package/dist/cli.d.ts.map +1 -1
  19. package/dist/cli.js +173 -22
  20. package/dist/cli.js.map +1 -1
  21. package/dist/completeness.js +6 -3
  22. package/dist/completeness.js.map +1 -1
  23. package/dist/config.d.ts +2 -0
  24. package/dist/config.d.ts.map +1 -1
  25. package/dist/config.js +20 -5
  26. package/dist/config.js.map +1 -1
  27. package/dist/ensure-tag.d.ts +17 -0
  28. package/dist/ensure-tag.d.ts.map +1 -0
  29. package/dist/ensure-tag.js +28 -0
  30. package/dist/ensure-tag.js.map +1 -0
  31. package/dist/env.js +6 -3
  32. package/dist/env.js.map +1 -1
  33. package/dist/error-codes.d.ts +89 -0
  34. package/dist/error-codes.d.ts.map +1 -1
  35. package/dist/error-codes.js +102 -0
  36. package/dist/error-codes.js.map +1 -1
  37. package/dist/git.d.ts +5 -0
  38. package/dist/git.d.ts.map +1 -1
  39. package/dist/git.js +19 -6
  40. package/dist/git.js.map +1 -1
  41. package/dist/glob.d.ts +9 -0
  42. package/dist/glob.d.ts.map +1 -1
  43. package/dist/glob.js +29 -1
  44. package/dist/glob.js.map +1 -1
  45. package/dist/handlers/crates.d.ts +15 -0
  46. package/dist/handlers/crates.d.ts.map +1 -1
  47. package/dist/handlers/crates.js +84 -9
  48. package/dist/handlers/crates.js.map +1 -1
  49. package/dist/handlers/npm-platform.d.ts +11 -0
  50. package/dist/handlers/npm-platform.d.ts.map +1 -1
  51. package/dist/handlers/npm-platform.js +91 -8
  52. package/dist/handlers/npm-platform.js.map +1 -1
  53. package/dist/handlers/npm.d.ts.map +1 -1
  54. package/dist/handlers/npm.js +59 -9
  55. package/dist/handlers/npm.js.map +1 -1
  56. package/dist/handlers/pypi.d.ts +0 -6
  57. package/dist/handlers/pypi.d.ts.map +1 -1
  58. package/dist/handlers/pypi.js +28 -10
  59. package/dist/handlers/pypi.js.map +1 -1
  60. package/dist/log.js +16 -8
  61. package/dist/log.js.map +1 -1
  62. package/dist/normalize-artifacts.js +8 -4
  63. package/dist/normalize-artifacts.js.map +1 -1
  64. package/dist/plan-skew.d.ts +14 -0
  65. package/dist/plan-skew.d.ts.map +1 -0
  66. package/dist/plan-skew.js +28 -0
  67. package/dist/plan-skew.js.map +1 -0
  68. package/dist/plan-status-types.d.ts +42 -0
  69. package/dist/plan-status-types.d.ts.map +1 -0
  70. package/dist/plan-status-types.js +10 -0
  71. package/dist/plan-status-types.js.map +1 -0
  72. package/dist/plan-status.d.ts +23 -0
  73. package/dist/plan-status.d.ts.map +1 -0
  74. package/dist/plan-status.js +66 -0
  75. package/dist/plan-status.js.map +1 -0
  76. package/dist/plan.d.ts +8 -0
  77. package/dist/plan.d.ts.map +1 -1
  78. package/dist/plan.js +125 -25
  79. package/dist/plan.js.map +1 -1
  80. package/dist/preflight.d.ts +79 -0
  81. package/dist/preflight.d.ts.map +1 -1
  82. package/dist/preflight.js +715 -33
  83. package/dist/preflight.js.map +1 -1
  84. package/dist/publish.d.ts +7 -0
  85. package/dist/publish.d.ts.map +1 -1
  86. package/dist/publish.js +49 -18
  87. package/dist/publish.js.map +1 -1
  88. package/dist/python-versions.d.ts +40 -0
  89. package/dist/python-versions.d.ts.map +1 -0
  90. package/dist/python-versions.js +142 -0
  91. package/dist/python-versions.js.map +1 -0
  92. package/dist/reconcile-types.d.ts +36 -0
  93. package/dist/reconcile-types.d.ts.map +1 -0
  94. package/dist/reconcile-types.js +9 -0
  95. package/dist/reconcile-types.js.map +1 -0
  96. package/dist/reconcile.d.ts +21 -0
  97. package/dist/reconcile.d.ts.map +1 -0
  98. package/dist/reconcile.js +54 -0
  99. package/dist/reconcile.js.map +1 -0
  100. package/dist/release-packages.d.ts +33 -0
  101. package/dist/release-packages.d.ts.map +1 -0
  102. package/dist/release-packages.js +74 -0
  103. package/dist/release-packages.js.map +1 -0
  104. package/dist/resolve-tag-commit.d.ts +22 -0
  105. package/dist/resolve-tag-commit.d.ts.map +1 -0
  106. package/dist/resolve-tag-commit.js +26 -0
  107. package/dist/resolve-tag-commit.js.map +1 -0
  108. package/dist/retry.js +14 -7
  109. package/dist/retry.js.map +1 -1
  110. package/dist/status-classify.d.ts +17 -0
  111. package/dist/status-classify.d.ts.map +1 -0
  112. package/dist/status-classify.js +37 -0
  113. package/dist/status-classify.js.map +1 -0
  114. package/dist/status-format.d.ts +9 -0
  115. package/dist/status-format.d.ts.map +1 -0
  116. package/dist/status-format.js +19 -0
  117. package/dist/status-format.js.map +1 -0
  118. package/dist/status-types.d.ts +31 -0
  119. package/dist/status-types.d.ts.map +1 -0
  120. package/dist/status-types.js +8 -0
  121. package/dist/status-types.js.map +1 -0
  122. package/dist/status.d.ts +28 -0
  123. package/dist/status.d.ts.map +1 -0
  124. package/dist/status.js +72 -0
  125. package/dist/status.js.map +1 -0
  126. package/dist/tag-template.js +2 -1
  127. package/dist/tag-template.js.map +1 -1
  128. package/dist/trailer.js +16 -9
  129. package/dist/trailer.js.map +1 -1
  130. package/dist/types.d.ts +11 -0
  131. package/dist/types.d.ts.map +1 -1
  132. package/dist/types.js +4 -2
  133. package/dist/types.js.map +1 -1
  134. package/dist/verbose.js +7 -4
  135. package/dist/verbose.js.map +1 -1
  136. package/dist/wheel-abi.d.ts +37 -0
  137. package/dist/wheel-abi.d.ts.map +1 -0
  138. package/dist/wheel-abi.js +118 -0
  139. package/dist/wheel-abi.js.map +1 -0
  140. package/dist/write-crate-version.d.ts +32 -0
  141. package/dist/write-crate-version.d.ts.map +1 -0
  142. package/dist/write-crate-version.js +52 -0
  143. package/dist/write-crate-version.js.map +1 -0
  144. package/dist/write-launcher.d.ts +82 -0
  145. package/dist/write-launcher.d.ts.map +1 -0
  146. package/dist/write-launcher.js +236 -0
  147. package/dist/write-launcher.js.map +1 -0
  148. package/dist/write-version.js +2 -1
  149. package/dist/write-version.js.map +1 -1
  150. package/package.json +4 -2
package/MIGRATIONS.md CHANGED
@@ -21,6 +21,1225 @@ Each section covers five things, in order:
21
21
 
22
22
  ## Unreleased
23
23
 
24
+ ### `status`: registry-vs-tag drift report
25
+
26
+ **Summary.** New read-only command `putitoutthere status` reports, per
27
+ package, whether the latest git tag matches the registry's latest
28
+ published version, flagging drift — notably `published, untagged` (a
29
+ version live on the registry but missing its tag, which strands the
30
+ package). Reads public registry metadata only; no auth.
31
+
32
+ **Required changes.** None. Additive — a new command.
33
+
34
+ **Deprecations removed.** None.
35
+
36
+ **Behavior changes without code changes.** None — new surface.
37
+
38
+ **Verification.** Run `status` over a repo with a published-but-untagged
39
+ package: that package shows `published, untagged` and `status --check`
40
+ exits non-zero; a fully in-sync repo shows every package `in sync` and
41
+ exits zero. `--json` emits the same rows as JSON.
42
+
43
+ ### plan: publish-skip verdict and skew
44
+
45
+ **Summary.** `putitoutthere plan` now always reports, per planned package,
46
+ whether a release from this ref would `PUBLISH` (the version is not yet on
47
+ the registry), `SKIP` (already published), or `UNKNOWN` (the registry
48
+ couldn't be reached) — and flags **version skew** when a package would
49
+ `PUBLISH` while a `depends_on` dependency `SKIP`s. It reuses the real
50
+ planner and the same `isPublished` the publish path runs, so the preview
51
+ matches reality. Always-on — there is no flag.
52
+
53
+ **Required changes.** None for consumers of the reusable workflow. The
54
+ matrix the workflow consumes (`outputs.matrix`, from the JS action's
55
+ `$GITHUB_OUTPUT`) is **unchanged** — a bare array of build rows, byte for
56
+ byte. Only a caller that parses `plan --json` **stdout** directly is
57
+ affected (see below).
58
+
59
+ **Deprecations removed.** None.
60
+
61
+ **Behavior changes without code changes.**
62
+
63
+ | | Before | After |
64
+ |---|---|---|
65
+ | `plan --json` stdout | `[ …matrix rows… ]` | `{ "matrix": [ …matrix rows… ], "verdicts": [ … ], "skew": [ … ] }` |
66
+ | `plan` human output | matrix rows only | matrix rows + a `publish plan:` section + a `⚠ version skew` line when applicable |
67
+ | reusable-workflow `outputs.matrix` | bare array | bare array (unchanged) |
68
+
69
+ The `matrix` field is identical to the old top-level array; a direct
70
+ stdout reader migrates by reading `.matrix`. A registry blip degrades a
71
+ verdict to `UNKNOWN` and still emits the matrix — `plan` never aborts on
72
+ an unreachable registry.
73
+
74
+ **Verification.** Run `plan` on a ref where one package's planned version
75
+ is already published and a dependent's is not: the published one shows
76
+ `SKIP`, the dependent `PUBLISH`, and a `version skew` warning names the
77
+ pair. `plan --json` carries the same under `verdicts` / `skew`. The build
78
+ matrix (and a real release) are unaffected.
79
+
80
+ ### reconcile: backfill missing tags
81
+
82
+ **Summary.** New command `putitoutthere reconcile` backfills the missing
83
+ git tag for every package that is live on its registry but untagged
84
+ (`status`'s `published, untagged` drift). It is the on-demand companion to
85
+ the publish-path auto-heal: auto-heal only fires for a package already in a
86
+ publish run, so a package whose globs never change again stays stuck;
87
+ `reconcile` heals it without a release. It reuses the same `computeStatus`
88
+ detection `status` reports and the same idempotent `ensureTag` the publish
89
+ path heals with — no parallel logic.
90
+
91
+ **Required changes.** None. Additive — a new command.
92
+
93
+ **Deprecations removed.** None.
94
+
95
+ **Behavior changes without code changes.** `--dry-run` is now accepted on
96
+ `reconcile` (it previews the heal without writing). It remains rejected on
97
+ `plan` / `publish`, unchanged from the #244 removal.
98
+
99
+ **Verification.** On a repo with a published-but-untagged package, run
100
+ `putitoutthere reconcile`: the missing tag is created (per the package's
101
+ `tag_format`) — pointed at a sibling package's tag commit for that version
102
+ when one exists, else `HEAD` — and pushed; `git tag` / GitHub show it, and
103
+ `status` then reports the package `in sync`. A second `reconcile` run is a
104
+ no-op. `reconcile --dry-run` reports what it would create without writing;
105
+ `--json` emits the actions.
106
+
107
+ ### publish-path auto-heal: missing tags
108
+
109
+ **Summary.** `putitoutthere publish` now self-heals a missing git tag: when
110
+ a version is already live on the registry but has no tag, publish writes the
111
+ tag instead of skipping silently. Previously the already-published branch
112
+ returned before tagging, so a version that reached the registry on a
113
+ half-failed run (published, then the run died before the tag step) was
114
+ stranded published-but-untagged — and because piot derives "last released"
115
+ from tags, it skipped forever and could never bump, while dependents drifted
116
+ ahead into unflagged version skew.
117
+
118
+ **Required changes.** None. Purely additive behavior on the publish path.
119
+
120
+ **Deprecations removed.** None.
121
+
122
+ **Behavior changes without code changes.** On the next release run after
123
+ upgrading, any package that is live on a registry but missing its tag has the
124
+ tag created (and pushed) at the release commit, automatically. Idempotent:
125
+ packages already correctly tagged are untouched.
126
+
127
+ **Verification.** Re-run a release on a repo with a published-but-untagged
128
+ package: the run now creates the missing tag (per the package's `tag_format`;
129
+ visible via `git tag` / on GitHub) and the package resumes normal version
130
+ bumping on later releases.
131
+
132
+ ### pypi version-independent wheels build once
133
+
134
+ **Summary.** A `kind = "pypi"` `build = "maturin"` package whose wheel is
135
+ Python-version-independent — `[tool.maturin].bindings = "bin"` (a
136
+ Rust-binary `py3-none` wheel) or a pyo3 `abi3` / `abi3-pyXY` extension (a
137
+ `cp3x-abi3` stable-ABI wheel) — used to be built once per CPython version
138
+ in the resolved set (inferred from `[project].requires-python`, or pinned
139
+ via `python_versions`). Because such a wheel is byte-identical no matter
140
+ which interpreter built it, the fan produced N duplicate wheels, and the
141
+ documented `pypi-publish` recipe's `merge-multiple: true` download then
142
+ race-corrupted the identical wheel filenames extracted onto one path
143
+ (`twine check` → `zipfile.BadZipFile`). The planner now collapses the fan
144
+ to a single wheel per target for these packages.
145
+
146
+ **Required changes.** None. Detection is automatic from your existing
147
+ `pyproject.toml` / `Cargo.toml`; no `putitoutthere.toml`, `release:`
148
+ trailer, or reusable-workflow input changes. The consumer `pypi-publish`
149
+ job is unchanged — its `pattern: '*-wheel-*'` still matches the (now
150
+ single) wheel artifact.
151
+
152
+ **Deprecations removed.** None.
153
+
154
+ **Behavior changes without code changes.** For a version-independent
155
+ maturin wheel resolving to more than one CPython version:
156
+
157
+ | | Before | After |
158
+ |---|--------|-------|
159
+ | Wheel build rows per target | one per resolved version (e.g. 6 for `>=3.9`) | one |
160
+ | Wheel artifact name | `<pkg>-wheel-<triple>-py<ver>` (per version) | `<pkg>-wheel-<triple>` (unsuffixed) |
161
+ | Build interpreter | each resolved version | newest resolved version only |
162
+
163
+ Ordinary per-version extension modules (no abi3, no `bindings = "bin"`)
164
+ are unaffected — they still fan and keep their `-py<ver>` suffixes. The
165
+ sdist row is unchanged. Detection is conservative: an abi3 setup the
166
+ engine doesn't recognize (a workspace-inherited `pyo3` dependency, a
167
+ `[target.'cfg(...)'.dependencies]` table) falls back to the prior fanning
168
+ behavior.
169
+
170
+ **Verification.** Run `putitoutthere plan` (or release) a maturin package
171
+ with `bindings = "bin"` or a pyo3 `abi3` feature and a `requires-python`
172
+ spanning multiple minors. The build matrix now shows a single
173
+ `<pkg>-wheel-<triple>` artifact per target instead of one per version, and
174
+ the `pypi-publish` job's `twine check` no longer fails with `BadZipFile`.
175
+
176
+ ### npm `TLOG_CREATE_ENTRY_ERROR` (409) provenance retry race
177
+
178
+ **Summary.** `kind = "npm"` releases that publish with provenance
179
+ (`--provenance`, the OIDC trusted-publisher path) could abort mid-matrix
180
+ when npm's internal retry-on-transient-network-error re-submitted a
181
+ byte-identical attestation and Sigstore/Rekor rejected the duplicate with
182
+ `TLOG_CREATE_ENTRY_ERROR` (HTTP 409, "an equivalent entry already exists in
183
+ the transparency log"). Only the registry-PUT edition of that race (the
184
+ `E403` "cannot publish over the previously published versions" shape) was
185
+ tolerated; the attestation edition fell through to a hard failure, which
186
+ for a multi-platform (`napi` / `bundled-cli`) package left the remaining
187
+ sub-packages and the main package unpublished — a partial release. The
188
+ engine now recognizes the 409 and, because a 409 from Rekor does not by
189
+ itself prove the artifact reached the registry, re-probes `npm view` to
190
+ decide: present ⇒ benign duplicate (success); absent ⇒ a genuine partial
191
+ publish, reported as an actionable error.
192
+
193
+ **Required changes.** None. The behavior is automatic and applies to every
194
+ `kind = "npm"` package; no `putitoutthere.toml`, `release:` trailer, or
195
+ reusable-workflow input changes.
196
+
197
+ **Deprecations removed.** None.
198
+
199
+ **Behavior changes without code changes.** A `kind = "npm"` publish that
200
+ hits `TLOG_CREATE_ENTRY_ERROR` (409) no longer fails unconditionally:
201
+
202
+ | Situation | Before | After |
203
+ |-----------|--------|-------|
204
+ | 409 raised, package **is** on the registry | publish job fails (`npm publish (platform) failed: …`) | treated as `already-published` / counted as published; the cascade continues |
205
+ | 409 raised, package is **absent** from the registry | publish job fails with a bare npm stderr dump | publish job still fails, but with an actionable message: re-run the release to mint a fresh attestation |
206
+
207
+ In the genuine partial-publish case the engine cannot recover in-process —
208
+ an orphaned attestation needs a new `runID`/attempt to produce a fresh
209
+ Rekor entry — so re-running the release is the documented remedy.
210
+
211
+ **Verification.** Re-run a release whose previous attempt died on
212
+ `TLOG_CREATE_ENTRY_ERROR`. If the sub-package already landed, the run now
213
+ reports it as already-published and proceeds to the remaining packages
214
+ instead of aborting; if it never landed, the error names the package and
215
+ tells you to re-run (which mints a new attestation and gets past the
216
+ dedupe).
217
+
218
+ ### Bundled-CLI launcher generation no-ops without `[package.bundle_cli]`
219
+
220
+ **Summary.** #299 moved npm bundled-cli launcher generation into the
221
+ engine and invoked it on every bundled-cli package's main row.
222
+ `writeLauncherFromConfig` assumed `[package.bundle_cli]` was always
223
+ present and dereferenced `bundle_cli.bin` unconditionally, so a
224
+ bundled-cli npm package that omits the table — the legacy
225
+ "bring-your-own `scripts/build.cjs` + hand-authored `bin/<bin>.js`"
226
+ shape that #298 explicitly kept opt-in — crashed the release at the
227
+ `write-launcher` step with `Cannot read properties of undefined
228
+ (reading 'bin')`. Launcher generation now no-ops when the table is
229
+ absent, mirroring the cross-compile step's existing `matrix.bundle_cli`
230
+ gate. The gate lives in the engine, not the workflow `if:`, because the
231
+ main row the step runs on never carries `bundle_cli` (`plan.ts` attaches
232
+ it only to per-target bundled-cli rows).
233
+
234
+ **Required changes.** None. A bundled-cli package that declares
235
+ `[package.bundle_cli]` is unchanged — the engine still generates
236
+ `bin/<bin>.js` and the `package.json#bin` entry. A package that omits
237
+ the table and ships its own launcher (the legacy path) no longer crashes;
238
+ the engine leaves its launcher alone. A consumer that *intended* the
239
+ declarative path and simply forgot the table should add it — the
240
+ cross-compile and launcher generation both key off it:
241
+
242
+ | Before | After |
243
+ |--------|-------|
244
+ | `build = "bundled-cli"`, `targets = [...]`, no `[package.bundle_cli]` → `write-launcher` TypeError | release succeeds; add `[package.bundle_cli]` with at least `bin` to opt into engine cross-compile + launcher generation |
245
+
246
+ **Deprecations removed.** None.
247
+
248
+ **Behavior changes without code changes.** A bundled-cli npm package
249
+ without `[package.bundle_cli]` previously failed its release at the
250
+ `write-launcher` step (as of #299); it now completes, with the engine
251
+ authoring no launcher for it.
252
+
253
+ **Verification.** Trigger a release for a bundled-cli npm package that
254
+ omits `[package.bundle_cli]`: the run completes (previously it failed at
255
+ the `write-launcher` step). A package that declares the table still gets
256
+ its engine-generated launcher.
257
+
258
+ ### Preflight npm package name must match configured name
259
+
260
+ **Summary.** Preflight gains `requirePackageJsonShape`, the npm analogue
261
+ of `requirePyprojectShape` / `requireCargoShape` (#301). Every cascaded
262
+ `kind = "npm"` package's `package.json` `name` must equal the configured
263
+ `[[package]].name` (or the `npm` override). `npm publish` packs the
264
+ manifest `name`, but the engine's idempotency probe (`npm view <name>`)
265
+ and the tag / release-URL bookkeeping use the configured name — so a
266
+ divergence silently breaks idempotency and can publish under an
267
+ unexpected name. pypi and crates already enforced the equivalent
268
+ (`PIOT_PYPI_NAME_MISMATCH`, `PIOT_CRATES_NAME_MISMATCH`); this closes the
269
+ gap for npm. The check fires at publish time alongside the existing
270
+ `require*` family and at PR time via `check.yml`. Findings aggregate
271
+ across every failing package.
272
+
273
+ **Required changes.** None for well-formed manifests — `package.json`
274
+ `name` already matches the configured name in the common case. The repos
275
+ that trip the new check are those that used a path-style identifier as
276
+ `[[package]].name` (e.g. `js/foo`) without setting the `npm` override
277
+ while shipping `package.json` `name = "foo"`; those previously published
278
+ with a broken `npm view` idempotency check and a wrong reported URL, and
279
+ now get a fast preflight red instead. Fix by aligning the names or
280
+ declaring the override:
281
+
282
+ | Before | After |
283
+ |--------|-------|
284
+ | `name = "js/foo"` (no `npm` override), `package.json` `name = "foo"` | add `npm = "foo"` to the `[[package]]` entry (or rename one side so the two agree) |
285
+
286
+ The new error code:
287
+
288
+ | Code | Fires when |
289
+ |------|------------|
290
+ | `PIOT_NPM_NAME_MISMATCH` | `package.json`'s `name` differs from `[[package]].name` (or the `npm` override). |
291
+
292
+ **Deprecations removed.** None.
293
+
294
+ **Behavior changes without code changes.** An npm package with a name
295
+ divergence that previously published (under the manifest name, with a
296
+ broken `npm view` idempotency check and a wrong reported URL) now fails
297
+ at preflight with a fingerprintable `PIOT_NPM_NAME_MISMATCH` before any
298
+ side effect. Scoped names are compared verbatim (`npm = "@scope/foo"`
299
+ matches `package.json` `name = "@scope/foo"`). A missing or malformed
300
+ `package.json` is left to the other checks / the publish step.
301
+
302
+ **Verification.** Set an npm package's `package.json` `name` to something
303
+ other than its configured `[[package]].name` (with no `npm` override),
304
+ run `pnpm putitoutthere check` (or open a PR with `check.yml` wired), and
305
+ see `PIOT_NPM_NAME_MISMATCH` surface in seconds instead of mid-release.
306
+
307
+ ### `_matrix.yml` build job primes a cargo cache (#391)
308
+
309
+ **Summary.** `_matrix.yml`'s build job previously ran every per-target
310
+ matrix cell without any Cargo cache. Each cell cold-compiled the full
311
+ Rust dep graph on every PR — even PRs that touched nothing Rust-side —
312
+ because `~/.cargo/registry` and the per-package `target/` dir started
313
+ empty on every runner. On a wide bundle_cli / napi / maturin matrix
314
+ this dominated wall-clock: 4-6 min per cell, ~8 min end-to-end on a
315
+ typical downstream consumer's `release-precheck.yml` run, with no
316
+ headroom against a 10-min PR CI gate.
317
+
318
+ The build job now runs `Swatinem/rust-cache@v2` immediately after
319
+ `actions/checkout`, gated on rows that actually invoke cargo:
320
+
321
+ - `pypi/maturin` non-sdist (maturin shells out to cargo)
322
+ - `npm/napi` (the consumer's `napi build` script calls cargo)
323
+ - `npm/bundled-cli` non-main (the engine's `cargo build` for the
324
+ staged CLI binary)
325
+
326
+ The cache is partitioned by `matrix.target` via `shared-key` so a
327
+ write to one target's slot doesn't blow away the next cell's, and
328
+ `workspaces` enumerates both `matrix.path` (the consumer's package
329
+ crate, where maturin / napi / single-crate bundled-cli compile) and
330
+ `matrix.bundle_cli.crate_path` (the bundle_cli crate when it lives
331
+ in a separate dir from `matrix.path` — the dirsql shape). Rows that
332
+ produce no cargo work (pypi sdist, pure-Python hatch wheels, npm
333
+ vanilla, bundled-cli `main`) skip the cache step entirely.
334
+
335
+ **Required changes.** None — the cache is internal to the reusable
336
+ workflow. No consumer config, YAML, or scripts need to change.
337
+
338
+ **Deprecations removed.** None.
339
+
340
+ **Behavior changes without code changes.** Per-target matrix cells
341
+ that ran cargo cold previously will now restore their dep graph from
342
+ GitHub Actions cache storage on the second and subsequent matching
343
+ runs (matching = same `matrix.target` + same Cargo.lock contents).
344
+ Cache storage accrues against the consumer's repository quota — the
345
+ same accounting as any other `actions/cache` consumer, no separate
346
+ billing. First run after a `Cargo.lock` change recompiles cold and
347
+ takes the same wall-clock as before. The acceptance criterion: a
348
+ second matrix run with no `Cargo.lock` change finishes the Rust
349
+ compile step in well under one minute per cell.
350
+
351
+ **Verification.** A `build.yml` (or `release.yml`) run's logs now
352
+ show a `cargo cache (#391)` step between `Set up job` and the first
353
+ Rust-touching step, on every row that invokes cargo. The step logs
354
+ either `Cache hit` (subsequent run, no `Cargo.lock` change) or
355
+ `Cache not found` (first run, or after a `Cargo.lock` change) and
356
+ records a `~/.cargo` + `target/` save at job end. Pure-Python sdist
357
+ and npm vanilla rows do not show the step at all.
358
+
359
+ ### npm bundled-cli: npm-flavor triples now mapped to Rust triples
360
+
361
+ **Summary.** The `bundle_cli — add Rust target`, `cargo build`, and `stage binary` steps in both `_matrix.yml` and `e2e-fixture-job.yml` previously applied `${TARGET//-linux-gnu/-linux-musl}` directly to `matrix.target`. For `kind = "pypi"` rows `matrix.target` is already a Rust triple (`x86_64-unknown-linux-gnu`), so the substitution worked. For `kind = "npm"` `build = "bundled-cli"` rows `matrix.target` is an napi-rs-flavor triple (`linux-x64-gnu`, `darwin-arm64`, `win32-x64-msvc`, …); the substring `-linux-gnu` does not appear in any of these, so the substitution was a no-op and `rustup target add` received the raw npm triple and failed:
362
+
363
+ ```
364
+ error: toolchain 'stable-x86_64-unknown-linux-gnu' does not support target 'linux-x64-gnu'
365
+ ```
366
+
367
+ Each affected step now contains an explicit `case` statement mapping napi-rs npm triples to their Rust equivalents before the `gnu→musl` swap.
368
+
369
+ **Required changes.** None — the mapping is inside the reusable workflow. No consumer config, YAML, or scripts need to change.
370
+
371
+ **Deprecations removed.** None.
372
+
373
+ **Behavior changes without code changes.** `bundle_cli — add Rust target` now calls `rustup target add x86_64-unknown-linux-musl` (etc.) and succeeds. `cargo build --target` and the `stage binary` path-derivation use the correct Rust triple throughout.
374
+
375
+ **Verification.** A `bundle_cli` npm build job now shows `bundle_cli — add Rust target` completing without error, `bundle_cli — cargo build for linux-x64-gnu` compiling to `x86_64-unknown-linux-musl/release/<bin>`, and `bundle_cli — stage binary` logging `staged: target/x86_64-unknown-linux-musl/release/<bin> -> …`.
376
+
377
+ ### `bundle_cli` stage step runs after consumer `npm run build`
378
+
379
+ **Summary.** The engine's `bundle_cli — stage binary` step previously ran
380
+ **before** `npm run build --if-present`. A consumer build script that also
381
+ runs `cargo build --target $TARGET` (the raw `-linux-gnu` triple) and copies
382
+ the result to `build/<triple>/` would overwrite the engine's musl binary with
383
+ a glibc-linked one; the existence-only verify check passed and the
384
+ dynamically-linked artifact shipped to the registry. The stage step now runs
385
+ **after** `npm run build --if-present` so the engine's statically-linked musl
386
+ binary always wins. The `bundle_cli — verify` step in `_matrix.yml` now also
387
+ asserts static linking (`file`/`ldd` check), mirroring the check already
388
+ present in `e2e-fixture-job.yml`.
389
+
390
+ **Required changes.** None — this is a pure step-reordering inside the
391
+ reusable workflow. No consumer-side YAML, config, or scripts need to change.
392
+
393
+ **Deprecations removed.** None.
394
+
395
+ **Behavior changes without code changes.** Consumer build scripts that stage
396
+ a binary to `build/<triple>/` as part of `npm run build` will have that binary
397
+ overwritten by the engine's musl binary. This was always the intended behavior;
398
+ the old ordering was a bug. Consumer scripts that only compile TypeScript or
399
+ do non-binary work are unaffected.
400
+
401
+ **Verification.** A `bundle_cli` Linux build job's logs now show:
402
+ 1. `bundle_cli — cargo build for <triple>` (musl build, unchanged)
403
+ 2. `npm run build --if-present` (consumer build step)
404
+ 3. `bundle_cli — stage binary into build/<triple>` (engine stages musl binary)
405
+ 4. `bundle_cli — verify … is statically linked` (passes)
406
+
407
+ ### `bundle_cli` musl builds install `musl-tools` C cross-compiler
408
+
409
+ **Summary.** `rustup target add x86_64-unknown-linux-musl` registers the
410
+ Rust musl target but does not install the C cross-compiler
411
+ (`x86_64-linux-musl-gcc`). Crates that compile C source at build time —
412
+ `libsqlite3-sys` with `features = ["bundled"]`, `openssl-sys` with
413
+ `features = ["vendored"]`, and similar — invoke the C compiler directly
414
+ during `cargo build`; without `musl-gcc` present, the build fails with:
415
+
416
+ ```
417
+ failed to find tool "x86_64-linux-musl-gcc": No such file or directory
418
+ ```
419
+
420
+ The `musl-tools` apt package provides `musl-gcc` and is not pre-installed
421
+ on `ubuntu-latest`. `_matrix.yml` and `e2e-fixture-job.yml` now run
422
+ `sudo apt-get install -y musl-tools` and export `CC_<triple>=musl-gcc` to
423
+ `$GITHUB_ENV` before `cargo build`, gated on Linux targets.
424
+
425
+ **Required changes.** None — the step is added automatically by the
426
+ reusable workflow.
427
+
428
+ **Deprecations removed.** None.
429
+
430
+ **Behavior changes without code changes.** CLI crates that compile C
431
+ source and previously had to run a custom `build` script to install
432
+ `musl-tools` may be able to remove that script.
433
+
434
+ **Verification.** On a bundle_cli Linux build, the workflow now prints
435
+ `sudo apt-get install -y musl-tools` before the `cargo build` step.
436
+ Crates with C deps (e.g. `features = ["bundled"]` on `rusqlite`) compile
437
+ successfully without consumer-side workarounds.
438
+
439
+ ### `bundle_cli` Linux binaries compiled as static musl
440
+
441
+ **Summary.** `bundle_cli`'s Linux cross-compile step previously ran
442
+ `cargo build --target $TARGET` directly on the GitHub-hosted runner.
443
+ That runner's glibc (currently 2.39 on Ubuntu 24.04) got baked into
444
+ the produced binary as a hard runtime requirement, so any older Linux
445
+ at install time failed with `./bin: /lib/x86_64-linux-gnu/libc.so.6:
446
+ version 'GLIBC_2.39' not found`. The fix derives a `BINARY_TARGET`
447
+ from `matrix.target` by substituting `-linux-gnu*` → `-linux-musl*`
448
+ and uses that for the three workflow steps that touch the binary's
449
+ compile triple. Statically-linked musl binaries have no glibc floor.
450
+ The package's declared target triple (used for npm platform-package
451
+ names, napi builds, wheel tags, artifact names) is unchanged; only
452
+ the binary inside switches compile triple.
453
+
454
+ **Required changes.** None for the common case — the workflow makes
455
+ the swap automatically. The exception: CLI crates that
456
+ dynamic-link a system C library through default cargo features will
457
+ see a linker error on the first release after upgrade. The static
458
+ musl build cannot satisfy a dynamic link against the host's glibc-world
459
+ libraries. The fix is a one-line `Cargo.toml` change per case:
460
+
461
+ | Symptom (cargo error mentions) | Fix in your CLI's `Cargo.toml` |
462
+ |--------------------------------|--------------------------------|
463
+ | `openssl-sys`, `libssl.so`, OpenSSL | Switch to `rustls` (`reqwest = { default-features = false, features = ["rustls-tls"] }`), or pin `openssl = { features = ["vendored"] }` |
464
+ | `git2`, `libgit2` | `git2 = { features = ["vendored-openssl", "vendored-libgit2"] }` |
465
+ | `libsqlite3-sys`, SQLite | `rusqlite = { features = ["bundled"] }` (or `libsqlite3-sys = { features = ["bundled"] }`) |
466
+ | `libpq`, Postgres client | Swap `postgres-native-tls` for `postgres-rustls`, or use `sqlx` with the `rustls` feature |
467
+ | `libmysqlclient`, MySQL client | Same — prefer a pure-Rust client; `mysqlclient-sys` has no clean static path |
468
+
469
+ The musl build fails loudly at release time when one of these is
470
+ missed — there is no silent broken-binary failure mode. A blocked
471
+ release is the worst outcome.
472
+
473
+ **Deprecations removed.** None.
474
+
475
+ **Behavior changes without code changes.** The bundled binary inside
476
+ a Linux package built before this release required the build
477
+ runner's glibc (currently 2.39); after this release it has no glibc
478
+ requirement and runs on any Linux ≥ kernel 3.2, including Alpine,
479
+ NixOS in musl mode, scratch containers, and any host whose glibc is
480
+ older than the build runner's. Package identifiers (npm platform-
481
+ package names like `@scope/cli-linux-x64-gnu`, PyPI wheel tags,
482
+ artifact upload names) are unchanged — the substitution applies only
483
+ to the binary's compile triple.
484
+
485
+ **Verification.** Install a `bundle_cli`-enabled package from any
486
+ pre-Ubuntu-24.04 Linux (Ubuntu 22.04, Debian 12, Amazon Linux 2,
487
+ Alpine) and run the CLI — no `GLIBC_2.x not found` error. On the
488
+ build side, the cargo invocation in CI now prints
489
+ `cargo build --release --target x86_64-unknown-linux-musl ...`
490
+ instead of `-gnu` for Linux rows; `file` on the produced binary
491
+ reports `statically linked` instead of `dynamically linked`.
492
+
493
+ ### pypi `requires-python` includes CPython 3.14
494
+
495
+ **Summary.** Open-ended `requires-python` inference now expands against
496
+ putitoutthere's checked-in released-CPython list through CPython 3.14.
497
+ This fixes the stale-tail failure mode where `requires-python = ">=3.11"`
498
+ emitted cp311/cp312/cp313 wheels but omitted cp314 after Python 3.14 was
499
+ released.
500
+
501
+ **Required changes.** None.
502
+
503
+ | Before | After |
504
+ |--------|-------|
505
+ | `requires-python = ">=3.11"` planned wheels only through cp313. | `requires-python = ">=3.11"` plans cp311, cp312, cp313, and cp314, unless `python_versions` is explicitly set. |
506
+
507
+ **Deprecations removed.** None.
508
+
509
+ **Behavior changes without code changes.** Consumers with open-ended
510
+ `requires-python` ranges may see an extra cp314 wheel row. Explicit
511
+ `python_versions` overrides are unchanged and still pin the exact wheel
512
+ set.
513
+
514
+ **Verification.** Push a release for a `kind = "pypi"` package whose
515
+ `pyproject.toml` declares `requires-python = ">=3.11"`; the build matrix
516
+ should include a `python_version: "3.14"` row, and the published PyPI
517
+ release should include a cp314 wheel.
518
+
519
+ ### Manual release via `release_packages`
520
+
521
+ **Summary.** `release.yml` gained an optional `release_packages`
522
+ `workflow_call` input. When set, it triggers a manual release of an
523
+ explicit list of packages, bypassing change detection entirely. The
524
+ motivating case: putitoutthere ships a release-pipeline bug, the bug is
525
+ fixed, and downstream consumers must re-release the affected packages
526
+ even though their own repos have no new commits since the last tag —
527
+ the change-detected path emits an empty matrix in that state and cannot
528
+ release. The input value is a comma-separated list of
529
+ `name[@<bump|version>]` entries; each entry is a package name optionally
530
+ suffixed with `@<patch|minor|major>` (bump the last tag) or an explicit
531
+ `@<X.Y.Z>` semver (used verbatim). A bare name defaults to a patch bump.
532
+ Only the named packages are planned — no `depends_on` cascade, no
533
+ change-detected packages pulled in.
534
+
535
+ **Required changes.** None — the input is optional and defaults to
536
+ empty, which leaves the normal change-detected release path unchanged.
537
+ To get a manual-release button, wire the input to a `workflow_dispatch`
538
+ trigger in your caller `release.yml`:
539
+
540
+ | Before | After |
541
+ |--------|-------|
542
+ | <pre>on:<br> push: { branches: [main] }<br><br>jobs:<br> release:<br> uses: thekevinscott/putitoutthere/.github/workflows/release.yml@v0</pre> | <pre>on:<br> push: { branches: [main] }<br> workflow_dispatch:<br> inputs:<br> release_packages:<br> description: 'Comma-separated name[@bump\|version] list'<br> required: true<br><br>jobs:<br> release:<br> uses: thekevinscott/putitoutthere/.github/workflows/release.yml@v0<br> with:<br> release_packages: ${{ inputs.release_packages }}</pre> |
543
+
544
+ The push-triggered run passes an empty `release_packages` (the `inputs`
545
+ context is empty outside `workflow_dispatch`), so the normal path keeps
546
+ working.
547
+
548
+ **Deprecations removed.** None.
549
+
550
+ **Behavior changes without code changes.** None. The new behavior is
551
+ gated entirely on the new input being non-empty.
552
+
553
+ **Verification.** Trigger the workflow from the Actions tab with
554
+ `release_packages` set to a known package (e.g. `lib-core@patch`).
555
+ Confirm the plan job's matrix contains exactly that package and the
556
+ publish job tags and publishes it at the expected version.
557
+
558
+ ### pypi multi-version wheels
559
+
560
+ **Summary.** `kind = "pypi"` packages now build a wheel for every
561
+ CPython version they support, instead of a single wheel for the
562
+ `python_version` workflow input. The version set is resolved per
563
+ package: an explicit `python_versions` array in `putitoutthere.toml`
564
+ wins; otherwise it is inferred from `[project].requires-python` in the
565
+ package's `pyproject.toml`; otherwise a single default (`3.12`) is
566
+ used. The build matrix fans `maturin` per-target wheel rows across the
567
+ resolved set. This closes the incomplete-coverage bug where a package
568
+ declaring `requires-python = ">=3.10"` shipped a cp312-only wheel and
569
+ failed to install on every other interpreter.
570
+
571
+ **Required changes.** None for the default path — `requires-python`
572
+ inference is automatic. Optionally pin a subset:
573
+
574
+ | Before | After |
575
+ |--------|-------|
576
+ | _(no knob; one wheel at `python_version`)_ | `python_versions = ["3.12", "3.13"]` under a `[[package]]` with `kind = "pypi"` |
577
+
578
+ Consumers whose caller-side `pypi-publish` job collects wheels from the
579
+ downloaded artifacts directory need no change as long as it globs all
580
+ wheel artifacts (the recommended recipe already does); multi-version
581
+ `maturin` wheel artifacts are now named
582
+ `<pkg>-wheel-<triple>-py<ver>` rather than `<pkg>-wheel-<triple>`. A
583
+ single planned version keeps the unsuffixed name.
584
+
585
+ **Deprecations removed.** None. The `python_version` input on
586
+ `release.yml`, `build.yml`, and `_matrix.yml` is now deprecated — it no
587
+ longer affects pypi builds — but is retained so existing callers do not
588
+ break. Remove it from your `with:` block at leisure.
589
+
590
+ **Behavior changes without code changes.** A pypi package whose
591
+ `requires-python` spans multiple versions now produces multiple wheels
592
+ where it previously produced one. The `python_version` workflow input
593
+ is inert for pypi builds.
594
+
595
+ **Verification.** Push a release for a `kind = "pypi"` package whose
596
+ `pyproject.toml` declares `requires-python = ">=3.11"`; the build job
597
+ fans into one wheel row per released version (for example `3.11`,
598
+ `3.12`, `3.13`, `3.14`), and the
599
+ published PyPI release carries a wheel for each.
600
+
601
+ ### pypi bundle_cli binary embeds the release version
602
+
603
+ **Summary.** The reusable workflow's pypi `[package.bundle_cli]` path
604
+ already rewrote the maturin package's version source before building
605
+ wheels, but the cross-compiled CLI binary comes from the separate crate
606
+ at `bundle_cli.crate_path`. `cargo build` bakes `CARGO_PKG_VERSION`
607
+ from that crate's on-disk `Cargo.toml`, so a wheel could publish as
608
+ `0.3.6` while its bundled CLI reported an older literal such as
609
+ `0.2.7`. The build matrix now rewrites the bundle_cli crate's
610
+ `[package].version` to `matrix.version` immediately before the pypi
611
+ bundle_cli `cargo build`, matching the npm bundled-cli fix.
612
+
613
+ **Required changes.** None. Consumers who declare `kind = "pypi"`,
614
+ `build = "maturin"`, and `[package.bundle_cli]` get the corrected
615
+ behavior on their next release run against `@v0`; no
616
+ `putitoutthere.toml`, trailer, or consumer-side YAML change is needed.
617
+
618
+ **Deprecations removed.** None.
619
+
620
+ **Behavior changes without code changes.** Same config, different
621
+ artifact: the CLI binary staged into each wheel now reports the
622
+ planned release version from `--version` (and any other
623
+ `CARGO_PKG_VERSION`-derived output) instead of the stale literal in
624
+ the CLI crate manifest.
625
+
626
+ **Verification.** Release a pypi maturin package that declares
627
+ `[package.bundle_cli]`, install one of the published wheels, and run
628
+ the bundled CLI's `--version`: it reports `<version>`, matching the
629
+ published wheel metadata.
630
+
631
+ ### npm bundled-cli binary embeds the release version
632
+
633
+ **Summary.** The reusable workflow's npm `build = "bundled-cli"` path
634
+ cross-compiled the bundled CLI from un-rewritten crate source, so the
635
+ binary's `CARGO_PKG_VERSION` (what `<bin> --version` prints) was baked
636
+ from the literal `[package].version` in the crate's `Cargo.toml`
637
+ rather than the planned release version. A `@scope/cli-<triple>@0.3.5`
638
+ platform package could ship a binary that reported `0.2.7`. The build
639
+ matrix now rewrites the crate's `[package].version` to `matrix.version`
640
+ before `cargo build` runs, mirroring the pre-build `write-version`
641
+ step the pypi/maturin path already uses.
642
+
643
+ **Required changes.** None. The fix lives entirely inside the reusable
644
+ workflow's build matrix. Consumers who declare a `kind = "npm"`
645
+ `build = "bundled-cli"` package with `[package.bundle_cli]` get the
646
+ corrected behavior on their next release run against `@v0`; no
647
+ `putitoutthere.toml`, trailer, or consumer-side YAML change is needed.
648
+
649
+ **Deprecations removed.** None.
650
+
651
+ **Behavior changes without code changes.** Same config, different
652
+ artifact: the cross-compiled binary inside each per-platform package
653
+ now reports the planned release version from `--version` (and any
654
+ other `CARGO_PKG_VERSION`-derived output) instead of the stale literal
655
+ on disk in the crate manifest.
656
+
657
+ **Verification.** Release a `kind = "npm"` `build = "bundled-cli"`
658
+ package, extract one per-platform package
659
+ (`@scope/cli-<triple>@<version>`), and run the bundled binary's
660
+ `--version`: it reports `<version>`, matching the published package.
661
+ ### Bundled-cli staged binary is executable
662
+
663
+ **Summary.** For `kind = "npm"` packages with a `build = "bundled-cli"`
664
+ entry, the reusable workflow cross-compiles the CLI and stages it into
665
+ a per-triple platform package (`@scope/cli-<triple>`). The staged
666
+ binary was packed with mode `0644` — no executable bit. npm only sets
667
+ the executable bit on `bin` entries; the bundled binary is referenced
668
+ via `package.json#main`, so npm never `chmod`s it, and the bit it had
669
+ on the build runner is stripped crossing the GitHub Actions artifact
670
+ upload/download boundary. At runtime the generated launcher's
671
+ `spawnSync` of the resolved binary failed with `EACCES`. The workflow
672
+ now `chmod +x`es the staged binary for non-Windows targets before the
673
+ platform package is packed/published.
674
+
675
+ **Required changes.** None. The fix is internal to the reusable
676
+ workflow's npm bundled-cli publish path.
677
+
678
+ **Deprecations removed.** None.
679
+
680
+ **Behavior changes without code changes.** Per-triple platform
681
+ packages published for non-Windows targets now ship the CLI binary
682
+ with mode `0755` instead of `0644`. Consumers who already worked
683
+ around the bug (a `postinstall` `chmod`, or a launcher that `chmod`s
684
+ before `spawnSync`) can drop that workaround; leaving it in place is
685
+ harmless.
686
+
687
+ **Verification.** Publish a `build = "bundled-cli"` npm family, then
688
+ `npm install` it and run the CLI — `npx <bin> --version` succeeds
689
+ instead of failing with `spawnSync ... EACCES`. Inspecting the
690
+ published platform tarball
691
+ (`tar -tvzf` on the `@scope/cli-<triple>` `.tgz`) shows the binary as
692
+ `-rwxr-xr-x`.
693
+
694
+ ### Pre-merge crate-size check
695
+
696
+ **Summary.** `putitoutthere check` gained a check that runs
697
+ `cargo package --no-verify` for every `kind = "crates"` package and
698
+ fails when the resulting `.crate` is larger than crates.io's 10 MiB
699
+ (`10485760`-byte) upload limit. Previously an oversized crate — most
700
+ often caused by a tracked symlink dragging a build tree into the
701
+ package — surfaced only mid-release as a `413 Payload Too Large` from
702
+ `cargo publish`, after the verification build. The new check moves
703
+ that failure to PR time, before merge.
704
+
705
+ **Required changes.** None. The check is additive and runs
706
+ automatically wherever `putitoutthere check` already runs (the
707
+ `check.yml` reusable workflow). For the check to actually measure a
708
+ crate, a Rust toolchain (`cargo`) must be on `PATH` in that job; when
709
+ `cargo` is absent the check degrades to a no-op rather than failing,
710
+ so a check job without Rust set up sees no behavior change.
711
+
712
+ **Deprecations removed.** None.
713
+
714
+ **Behavior changes without code changes.** A PR that would produce an
715
+ oversized `.crate` now fails `putitoutthere check` with the new
716
+ `PIOT_CRATES_PACKAGE_TOO_LARGE` error code, instead of passing the
717
+ check and failing later inside the release run's publish job.
718
+
719
+ **Verification.** Add a `kind = "crates"` package and run
720
+ `putitoutthere check` (or open a PR against a repo wired to
721
+ `check.yml`) in an environment with `cargo` on `PATH`: an oversized
722
+ crate reports `PIOT_CRATES_PACKAGE_TOO_LARGE` naming the `.crate`
723
+ size and the 10 MiB limit, while a normally-sized crate reports
724
+ nothing.
725
+
726
+ ### v0 tracks main HEAD
727
+
728
+ **Summary.** Until this release, the floating `v0` tag advanced only
729
+ when a `release:` trailer fired the dogfood publish pipeline
730
+ (`release-npm.yml`), which then moved `v0` to the latest
731
+ `putitoutthere-v0.x.y` release commit. Commits that landed on main
732
+ without a trailer — test-only changes, docs edits, dependency bumps,
733
+ internal refactors, and one-off bug fixes whose author forgot the
734
+ trailer — left `v0` stale relative to main. The behavior was
735
+ explicitly chosen in issue #199 (`v0` = "latest released commit in
736
+ major line") and is now explicitly reversed: `v0` tracks main HEAD,
737
+ not the latest release.
738
+
739
+ A new workflow `.github/workflows/advance-v0.yml` fires on every
740
+ push to main, builds the action bundle, folds it into a tag-only
741
+ commit (mirroring `release-npm.yml`'s existing Fold step —
742
+ `dist-action/` is gitignored on main, so `v0` must point at a
743
+ synthesized bundle commit for `uses:
744
+ thekevinscott/putitoutthere@v0` to resolve to a runnable action),
745
+ and force-moves `v0` to that commit. The new workflow shares the
746
+ `release` concurrency group with `release-npm.yml`, so when both
747
+ fire on the same push (a trailer-bearing commit), the registry
748
+ publish runs first and `v0` is then advanced on top.
749
+
750
+ The permanent per-release tags (`putitoutthere-v0.x.y`) are
751
+ unchanged — they're cut by the dogfood publish pipeline on
752
+ trailer-fire and remain the canonical version history.
753
+
754
+ **Required changes.** None on the consumer side. The change is in
755
+ how `@v0` resolves over time, not in what the workflow at that ref
756
+ does.
757
+
758
+ **Deprecations removed.** None.
759
+
760
+ **Behavior changes without code changes.** A commit that lands on
761
+ `main` of `thekevinscott/putitoutthere` is, on the next consumer
762
+ workflow resolve, the workflow code the consumer runs. Previously
763
+ consumers had to wait for a release to be cut to pick up engine
764
+ changes; now they pick them up on the next push to main. Consumers
765
+ who want pinning to a known-released version use a
766
+ `putitoutthere-v0.x.y` tag (or a SHA) instead of `@v0`.
767
+
768
+ **Verification.** After this change merges and the first push to
769
+ main fires `advance-v0.yml`, the `v0` tag points at a fresh bundle
770
+ commit whose parent is the merge commit on main. Confirm with:
771
+
772
+ ```
773
+ $ git ls-remote --tags https://github.com/thekevinscott/putitoutthere.git v0
774
+ <sha> refs/tags/v0
775
+ $ git log <sha> -1 --format='%H %s'
776
+ <sha> chore(v0): bundle action
777
+ $ git log <sha>^ -1 --format='%H %s' # parent is the merge commit on main
778
+ <parent-sha> <merge commit subject>
779
+ ```
780
+
781
+ ### Preflight: manifest repository URL must match GITHUB_REPOSITORY; private repos rejected
782
+
783
+ **Summary.** Two new preflight checks address the
784
+ "surprise-at-publish" failure mode where a manifest's declared
785
+ `repository` URL silently disagrees with the GitHub repository the
786
+ workflow is actually running from. npm's provenance verification
787
+ returns a 422 (`"package.json: repository.url is X, expected to
788
+ match Y from provenance"`) **after** the artifact has been uploaded
789
+ and the registry has done OIDC negotiation — the kind of mid-publish
790
+ surprise this engine's "no release surprises" design commitment
791
+ exists to prevent. The same risk lives on the crates.io / PyPI
792
+ trusted-publisher paths against `Cargo.toml [package].repository`
793
+ and `pyproject.toml [project.urls]`. Both checks now fire at the
794
+ preflight stage before any side effects.
795
+
796
+ A second new check refuses to publish from a **private** GitHub
797
+ repository entirely. Provenance attestations embed a public
798
+ source-ref pointer that consumers cannot dereference when the repo
799
+ is private; the same source-visibility expectation underpins the
800
+ trusted-publisher story across all three registries. Hard-failing
801
+ at preflight beats silently shipping a verification-broken artifact.
802
+
803
+ **Required changes.** None for any consumer whose manifest URLs
804
+ already point at the correct `owner/repo` on GitHub and whose
805
+ repository is public. The check is opt-out only by fixing the
806
+ underlying disagreement.
807
+
808
+ | Failure mode | Fix |
809
+ | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
810
+ | `PIOT_REPO_URL_MISMATCH` on a renamed repo, manifests stale. | Update the manifest URL to match the GitHub repo (recommended), or rename the GitHub repo so the slugs line up. Re-run the release. |
811
+ | `PIOT_REPO_URL_MISMATCH` on a manifest that points at a fork or mirror. | Point the manifest URL at the canonical GitHub repo the workflow runs from. Trusted-publisher records on every registry bind to the workflow source, not the fork. |
812
+ | `PIOT_REPO_PRIVATE` on a repository that is intentionally private. | This engine cannot publish from a private repository. Either flip the repo to public before releasing or use a different release path. There is no opt-out flag — provenance attestations require public source. |
813
+
814
+ **Deprecations removed.** None.
815
+
816
+ **Behavior changes without code changes.** None — both checks are
817
+ new gates.
818
+
819
+ **Verification.** From a PR branch with a deliberately-wrong
820
+ `repository.url`, the PR-time `check.yml` job now reports
821
+ `[PIOT_REPO_URL_MISMATCH]` naming both the declared and expected
822
+ `owner/repo` slugs and the manifest path. From a private repo,
823
+ `publish` aborts before any side effect with `[PIOT_REPO_PRIVATE]`.
824
+ Both checks no-op outside a GHA context (when `GITHUB_REPOSITORY`
825
+ is unset), so a local `putitoutthere check` from a developer
826
+ machine does not false-positive.
827
+
828
+ ### Windows default runner pinned to windows-2022
829
+
830
+ **Summary.** GitHub is migrating `windows-latest` (and `windows-2025`)
831
+ to Visual Studio 2026 between 2026-06-08 and 2026-06-15 — see the
832
+ [GitHub Actions image-migration changelog](https://github.blog/changelog/2026-05-14-github-actions-upcoming-image-migrations/)
833
+ and [actions/runner-images#14016](https://github.com/actions/runner-images/issues/14016).
834
+ Until that date, `windows-latest` is Windows Server 2025 + VS2022; on
835
+ the cutover, `windows-latest` redirects to `windows-2025-vs2026` and
836
+ every consumer release run lands on a fresh toolchain with no
837
+ opportunity to verify it first.
838
+
839
+ `defaultRunsOn` in `src/plan.ts` previously returned `windows-latest`
840
+ for any Windows-shaped triple, so every consumer release plan that
841
+ included `x86_64-pc-windows-msvc` (the standard windows triple for
842
+ napi, bundled-cli, and maturin builds) inherited that floating label.
843
+ The default is now `windows-2022`: stable, VS2022, no surprise
844
+ migration. Consumers who want to track the floating label or adopt
845
+ VS2026 early opt in via the per-target `{ triple, runner }` override
846
+ that already exists.
847
+
848
+ **Required changes.** None for consumers who want to stay on a stable
849
+ VS2022 toolchain — the new default does that for them. Consumers who
850
+ want a different image opt in per target:
851
+
852
+ Before (relied on the floating `windows-latest` default):
853
+
854
+ ```toml
855
+ [[package]]
856
+ name = "lib-napi"
857
+ kind = "npm"
858
+ build = "napi"
859
+ targets = [
860
+ "x86_64-unknown-linux-gnu",
861
+ "x86_64-pc-windows-msvc",
862
+ ]
863
+ ```
864
+
865
+ After (no change required — `x86_64-pc-windows-msvc` now resolves to
866
+ `windows-2022` by default):
867
+
868
+ ```toml
869
+ [[package]]
870
+ name = "lib-napi"
871
+ kind = "npm"
872
+ build = "napi"
873
+ targets = [
874
+ "x86_64-unknown-linux-gnu",
875
+ "x86_64-pc-windows-msvc",
876
+ ]
877
+ ```
878
+
879
+ After (opt in to a different image — for example, surface VS2026
880
+ breakage now rather than on the cutover date):
881
+
882
+ ```toml
883
+ targets = [
884
+ "x86_64-unknown-linux-gnu",
885
+ { triple = "x86_64-pc-windows-msvc", runner = "windows-2025-vs2026" },
886
+ ]
887
+ ```
888
+
889
+ Other valid choices for the `runner` value include `windows-2025`
890
+ (Server 2025 + VS2022 until the cutover, then VS2026), `windows-2022`
891
+ (matches the new default explicitly), and `windows-latest` (preserves
892
+ the previous floating-label behavior).
893
+
894
+ **Deprecations removed.** None.
895
+
896
+ **Behavior changes without code changes.** Every Windows-shaped
897
+ matrix row's `runs_on` field now resolves to `windows-2022` instead
898
+ of `windows-latest` when no per-target `runner` override is set.
899
+ Per-target overrides win exactly as before — the bare-string-vs-object
900
+ precedence in `defaultRunsOn` is unchanged. The redirect notice
901
+ GitHub injects into every `windows-latest`-targeted run
902
+ (`NOTICE: windows-latest requests are being redirected to
903
+ windows-2025-vs2026 by June 15, 2026`) stops appearing on plans
904
+ generated by the new engine.
905
+
906
+ **Verification.** Run a release that includes any Windows triple and
907
+ confirm the build job runs on `windows-2022`:
908
+
909
+ - In the GitHub Actions UI, the per-target build job's "Set up job"
910
+ step lists `Runner: GitHub Actions <n>` followed by an OS-image
911
+ block whose `Image: windows-2022` line names the pinned image. The
912
+ banner `windows-latest requests are being redirected to
913
+ windows-2025-vs2026 by June 15, 2026` no longer appears in the
914
+ job log.
915
+ - The published artifact's `runs_on` value, surfaced in the plan
916
+ step's job summary, is `windows-2022`.
917
+
918
+ To opt in to a different image, set
919
+ `{ triple = "x86_64-pc-windows-msvc", runner = "<choice>" }` on the
920
+ relevant target and re-release; the build job moves to the named
921
+ image on the next run.
922
+
923
+ ### Crates first-publish TP rejection detected
924
+
925
+ **Summary.** crates.io's Trusted Publishing feature binds to an
926
+ already-published crate name. The very first publish of a brand-new
927
+ crate cannot use the TP path — the OIDC mint succeeds, the exchanged
928
+ token reaches cargo, but the registry rejects the publish with a 404
929
+ ("crate `<name>` does not exist or you do not have permission to
930
+ publish to it"). The engine previously surfaced this as a generic
931
+ `cargo publish failed` block, sending consumers down a credentials
932
+ rabbit-hole when the real fix is one bootstrap publish via the
933
+ classic-token fallback shipped in #283.
934
+
935
+ The crates handler now detects this exact response shape and throws
936
+ with the new stable error code
937
+ `PIOT_CRATES_FIRST_PUBLISH_TP_REJECTED`, prefixed onto a message
938
+ that names the crate, explains the TP-binds-to-published-crate
939
+ constraint, and points at `CARGO_REGISTRY_TOKEN` as the bootstrap
940
+ path. Cargo's full stderr is preserved at the bottom of the error
941
+ for debuggability. Companion work landed a registry-auth response
942
+ fixtures catalogue at
943
+ [`notes/upstream-behaviors.md`](./notes/upstream-behaviors.md) that
944
+ indexes this and three other response shapes the engine handles or
945
+ architecturally avoids — see #296.
946
+
947
+ **Required changes.** None. Consumers who never hit the
948
+ first-publish path see no change. Consumers whose first release
949
+ fails on a brand-new crate now see a clearer error pointing at the
950
+ fix; the fix itself (set `CARGO_REGISTRY_TOKEN` as a workflow
951
+ secret for one publish, then remove it) has been available since
952
+ #283 and is unchanged.
953
+
954
+ **Deprecations removed.** None.
955
+
956
+ **Behavior changes without code changes.** A `cargo publish`
957
+ failure whose stderr matches the first-publish-TP-rejection shape
958
+ now throws with `PIOT_CRATES_FIRST_PUBLISH_TP_REJECTED` instead of
959
+ the generic `cargo publish failed` shape. The full cargo stderr
960
+ remains in the error message. The detector is suppressed under the
961
+ `PIOT_CRATES_REGISTRY_PRIMARY` e2e seam (alt-registry doesn't model
962
+ TP, so a 404 there is a different bug).
963
+
964
+ **Verification.** On a brand-new crate name where Trusted
965
+ Publishing is the only auth configured, run the release. The
966
+ release run fails with a message starting
967
+ `[PIOT_CRATES_FIRST_PUBLISH_TP_REJECTED] cargo publish: crates.io
968
+ rejected publishing "<name>" because the crate has never been
969
+ published.` followed by the bootstrap hint. Set
970
+ `CARGO_REGISTRY_TOKEN` in the workflow's `secrets:` block (per
971
+ #283), re-run, and the publish should succeed. Subsequent releases
972
+ can drop the secret and rely on Trusted Publishing.
973
+
974
+ ### Bundled-CLI launcher generated by the workflow
975
+
976
+ **Summary.** Bundled-CLI npm consumers used to author `bin/<bin>.js` —
977
+ a Node launcher that detects the host platform, maps it to a triple,
978
+ resolves the corresponding `<name>-<triple>` (or templated) platform
979
+ package, and execs the binary. The launcher's only per-consumer
980
+ inputs are the package name and the configured `targets` list. Both
981
+ are in the engine's hands at plan time. Every consumer's launcher
982
+ was byte-identical modulo those two values.
983
+
984
+ `_matrix.yml`'s build job now invokes a new internal `putitoutthere
985
+ write-launcher` CLI subcommand on the main row of each `kind = "npm"
986
+ && build = "bundled-cli"` package (before `npm run build --if-present`
987
+ runs). The subcommand writes `bin/<bundle_cli.bin>.js` and adds the
988
+ matching `package.json#bin` entry in place. Both writes are guarded
989
+ by an "only if absent" check — existing consumer-authored launchers
990
+ and existing `bin` fields are preserved, so the override path is the
991
+ same file you'd already have committed.
992
+
993
+ The generated launcher's shape mirrors the README example bundled-cli
994
+ consumers wrote by hand pre-#299: hashbang, a Node
995
+ `${platform}-${arch}` → triple table, `require.resolve` against the
996
+ platform package, `spawnSync` with `stdio: 'inherit'`. The platform
997
+ package's name template (`{name}-{triple}` by default, or whatever the
998
+ consumer set under `build = [{ mode = "bundled-cli", name = "..." }]`
999
+ in the multi-mode array form) has every placeholder except `{triple}`
1000
+ resolved at generation time; `{triple}` becomes a backtick template
1001
+ substitution at install time. The launcher imports nothing from
1002
+ putitoutthere at runtime — it's a self-contained Node script with no
1003
+ published-package dependencies.
1004
+
1005
+ Together with #298 (which absorbed the cross-compile build script),
1006
+ bundled-cli npm's consumer surface is now: declare the package in
1007
+ `putitoutthere.toml`, register Trusted Publishers, push.
1008
+
1009
+ **Required changes.** None for new consumers — declaring the package
1010
+ in `putitoutthere.toml` is sufficient.
1011
+
1012
+ For consumers who already shipped a hand-authored launcher and want
1013
+ to migrate to the generated one, delete `bin/<bin>.js` from the source
1014
+ tree. The build job will regenerate it on the next release run.
1015
+ Leaving the file in place is fully supported; the workflow only
1016
+ writes when the file is absent.
1017
+
1018
+ ```diff
1019
+ // packages/my-cli/package.json
1020
+ {
1021
+ "name": "my-cli",
1022
+ - "bin": { "my-cli": "bin/my-cli.js" }
1023
+ }
1024
+ ```
1025
+
1026
+ Removing the `bin` field is optional too: when the workflow sees an
1027
+ existing `bin` field it leaves it alone. The diff above is only
1028
+ necessary if the consumer wants the workflow to author the field
1029
+ shape from scratch (`{ "<bin>": "bin/<bin>.js" }`).
1030
+
1031
+ **Deprecations removed.** None — the legacy hand-authored launcher
1032
+ path is still supported and the workflow respects the override.
1033
+
1034
+ **Behavior changes without code changes.**
1035
+
1036
+ - The published main package's `package.json#bin` now contains
1037
+ `{ "<bundle_cli.bin>": "bin/<bundle_cli.bin>.js" }` for consumers
1038
+ who previously had no `bin` field. Consumers with a pre-existing
1039
+ `bin` field see no change.
1040
+ - The published main package's tarball now contains
1041
+ `bin/<bundle_cli.bin>.js` for consumers who previously did not
1042
+ commit the file. Consumers who committed the file see their version
1043
+ shipped unchanged.
1044
+
1045
+ **Verification.** After a release run, inspect the published main
1046
+ package's tarball:
1047
+
1048
+ ```sh
1049
+ npm pack <main-pkg-name>@<version>
1050
+ tar -xvf <main-pkg-name>-<version>.tgz package/bin/<bundle_cli.bin>.js -O \
1051
+ | head -20
1052
+ ```
1053
+
1054
+ The first line is `#!/usr/bin/env node`; the file declares a `triples`
1055
+ object whose keys match the Node `${platform}-${arch}` strings the
1056
+ package's `targets` resolve to, and whose values are the configured
1057
+ triples. `package/package.json`'s `bin` field is
1058
+ `{ "<bundle_cli.bin>": "bin/<bundle_cli.bin>.js" }`.
1059
+
1060
+ ### `bundle_cli` wheel guard respects `python-source`
1061
+
1062
+ **Summary.** Maturin's standard mixed-project layout
1063
+ (`maturin new --mixed` generates `[tool.maturin].python-source = "python"`)
1064
+ declares a package source root that maturin strips from on-disk paths
1065
+ when rewriting them into the wheel's distribution layout. A binary
1066
+ staged on disk at `<pkg.path>/<stage_to>/<bin>` — e.g.
1067
+ `packages/python/python/dirsql/_binary/dirsql` — ends up in the wheel
1068
+ at `dirsql/_binary/dirsql`, with `python/` stripped. The reusable
1069
+ workflow's `bundle_cli` wheel-content guard previously asserted a
1070
+ literal `<stage_to>/<bin>` suffix inside the produced wheel; the regex
1071
+ never matched the stripped path, so the guard fired red on every
1072
+ per-target build row even when the binary was correctly bundled.
1073
+
1074
+ The guard now reads `[tool.maturin].python-source` (and the legacy
1075
+ `python_source` spelling — both forms are accepted by maturin across
1076
+ versions) from `<matrix.path>/pyproject.toml` and subtracts that
1077
+ prefix from `stage_to` before constructing the suffix regex. Consumers
1078
+ with an implicit-root layout (no `python-source` key, or an empty
1079
+ value) keep the previous behavior byte-for-byte; consumers with the
1080
+ explicit-root layout start passing the guard. Tracked at #338.
1081
+
1082
+ **Required changes.** None.
1083
+
1084
+ **Deprecations removed.** None.
1085
+
1086
+ **Behavior changes without code changes.**
1087
+
1088
+ - For a consumer with `[tool.maturin].python-source = "python"` and
1089
+ `[package.bundle_cli].stage_to = "python/dirsql/_binary"`, the
1090
+ reusable workflow's wheel-content guard now resolves the in-wheel
1091
+ suffix to `dirsql/_binary/<bin>` (matching what maturin actually
1092
+ produces) instead of asserting the unstripped `python/dirsql/_binary/<bin>`.
1093
+ - A `python-source` value that isn't actually a prefix of `stage_to`
1094
+ is left alone — the guard reverts to asserting the unstripped
1095
+ `stage_to` so the consumer's misconfiguration surfaces with the same
1096
+ diagnostic it does today.
1097
+ - An unset or empty `python-source` value resolves to the empty
1098
+ string and `stage_suffix` is unchanged. No behavior change for
1099
+ consumers who don't use the explicit-root layout.
1100
+
1101
+ **Verification.** With a maturin package whose `pyproject.toml`
1102
+ declares `[tool.maturin].python-source = "python"` and whose
1103
+ `[package.bundle_cli]` sets `stage_to = "python/<pkg>/_binary"`,
1104
+ a release run should produce wheels whose `unzip -l` listing contains
1105
+ `<pkg>/_binary/<bin>` and the wheel-content guard step should log
1106
+ `ok bundle_cli: <pkg>/_binary/<bin> present in <wheel>`.
1107
+
1108
+ ### `bundle_cli` cargo workspace
1109
+
1110
+ **Summary.** Two collided bugs made `[package.bundle_cli]`
1111
+ unsatisfiable for the standard cargo-workspace layout: a single
1112
+ workspace root `Cargo.toml` with `[workspace] members = [...]` and the
1113
+ `[[bin]]` declared in a member crate (the shape `cargo new --workspace`
1114
+ produces, and what the polyglot Rust/Python recipe in the README
1115
+ implies). With `crate_path = "."` (the default), `putitoutthere check`
1116
+ parsed the workspace root `Cargo.toml` literally, saw no `[[bin]]`, and
1117
+ emitted `bundle_cli.bin "X" is not declared as a [[bin]]`. With
1118
+ `crate_path = "packages/rust"`, the check passed but the reusable
1119
+ workflow's bundle_cli stage step couldn't find the produced binary —
1120
+ cargo writes to the workspace-rooted target dir by default
1121
+ (`<repo-root>/target/...`), not to the working-directory-rooted one
1122
+ (`packages/rust/target/...`) the stage step assumed. There was no
1123
+ `crate_path` value that satisfied both halves.
1124
+
1125
+ The check now walks `[workspace].members` and aggregates each member's
1126
+ declared bins (honoring the implicit-binary rule, including
1127
+ `[package].name = { workspace = true }` inheritance from
1128
+ `[workspace.package].name`). The reusable workflow's cargo build step
1129
+ pins `--target-dir target` so the produced binary is deterministically
1130
+ at `${{ matrix.bundle_cli.crate_path }}/target/<triple>/release/<bin>`
1131
+ regardless of whether the crate participates in a workspace. Tracked at
1132
+ #337.
1133
+
1134
+ **Required changes.** None.
1135
+
1136
+ Consumers whose `[package.bundle_cli]` block already worked (single-
1137
+ crate layouts, or workspaces where `crate_path` pointed directly at the
1138
+ member crate and the consumer's project structure happened to make the
1139
+ workspace target dir line up with the member target dir) keep building
1140
+ byte-identically. Consumers whose workspace layout previously failed
1141
+ the check or stage step start working without touching their config.
1142
+
1143
+ **Deprecations removed.** None.
1144
+
1145
+ **Behavior changes without code changes.**
1146
+
1147
+ - `putitoutthere check` accepts the cargo-workspace shape:
1148
+ ```toml
1149
+ # /Cargo.toml
1150
+ [workspace]
1151
+ members = ["packages/rust"]
1152
+
1153
+ # /packages/rust/Cargo.toml
1154
+ [package]
1155
+ name = "my-cli"
1156
+ description = "..."
1157
+ license = "MIT"
1158
+
1159
+ [[bin]]
1160
+ name = "my-cli"
1161
+ path = "src/main.rs"
1162
+
1163
+ # /putitoutthere.toml
1164
+ [package.bundle_cli]
1165
+ bin = "my-cli"
1166
+ stage_to = "python/dirsql/_binary"
1167
+ # crate_path defaults to "."
1168
+ ```
1169
+ previously reported `bundle_cli.bin "my-cli" is not declared as a [[bin]]`,
1170
+ now reports zero findings.
1171
+ - The reusable workflow's bundle_cli build step now passes
1172
+ `--target-dir target` to `cargo build`. The produced binary is at
1173
+ `${{ matrix.bundle_cli.crate_path }}/target/<triple>/release/<bin>`
1174
+ regardless of workspace membership, and the stage step's `src=` path
1175
+ resolves correctly by construction.
1176
+ - Members declared as glob patterns (`members = ["packages/*"]`) are
1177
+ expanded against the filesystem by the check — see
1178
+ [`bundle_cli` glob workspace members](#bundle_cli-glob-workspace-members).
1179
+
1180
+ **Verification.** With the workspace layout above, the consumer should
1181
+ see:
1182
+
1183
+ - `putitoutthere check` reports zero findings.
1184
+ - A maturin release run produces a wheel whose `unzip -l` includes the
1185
+ staged binary (the existing wheel-content guard asserts this).
1186
+
1187
+ ### `bundle_cli` glob workspace members
1188
+
1189
+ **Summary.** The `bundle_cli` cargo-workspace fix above taught
1190
+ `putitoutthere check` (and the pre-publish preflight) to walk
1191
+ `[workspace].members` and aggregate each member crate's declared
1192
+ `[[bin]]` entries, so `crate_path = "."` resolves a `[[bin]]` that
1193
+ lives in a member crate. That walk only handled *literal* member
1194
+ entries. cargo `members` entries are globs, and `members =
1195
+ ["packages/*"]` — a Rust core crate under `packages/rust` wrapped by
1196
+ sibling Python / npm packages — is the standard polyglot-repo shape. A
1197
+ glob entry never resolved to a literal `<member>/Cargo.toml`, so the
1198
+ member crate's `[[bin]]` went unseen and `crate_path = "."` was
1199
+ rejected with `bundle_cli.bin "X" is not declared as a [[bin]]`.
1200
+
1201
+ The check now expands `[workspace].members` glob entries against the
1202
+ filesystem the way cargo resolves them; a member crate behind a glob is
1203
+ found like any literal member.
1204
+
1205
+ **Required changes.** None.
1206
+
1207
+ Consumers whose workspace declares `members` with literal paths are
1208
+ unaffected. Consumers who declared `members` with a glob and worked
1209
+ around the rejected check — by also listing the member crate as a
1210
+ literal entry, or by pointing `crate_path` straight at the member
1211
+ crate — can drop the workaround and let `crate_path` default to `"."`.
1212
+
1213
+ **Deprecations removed.** None.
1214
+
1215
+ **Behavior changes without code changes.**
1216
+
1217
+ - `putitoutthere check` accepts a glob-member workspace:
1218
+ ```toml
1219
+ # /Cargo.toml
1220
+ [workspace]
1221
+ members = ["packages/*"]
1222
+
1223
+ # /packages/rust/Cargo.toml
1224
+ [package]
1225
+ name = "rust-core"
1226
+
1227
+ [[bin]]
1228
+ name = "my-cli"
1229
+ path = "src/main.rs"
1230
+
1231
+ # /putitoutthere.toml
1232
+ [package.bundle_cli]
1233
+ bin = "my-cli"
1234
+ stage_to = "python/dirsql/_binary"
1235
+ # crate_path defaults to "."
1236
+ ```
1237
+ previously reported `bundle_cli.bin "my-cli" is not declared as a [[bin]]`,
1238
+ now reports zero findings.
1239
+
1240
+ **Verification.** With the glob-member workspace above, `putitoutthere
1241
+ check` reports zero findings.
1242
+
24
1243
  ### Crates metadata check resolves `[workspace.package]` inheritance
25
1244
 
26
1245
  **Summary.** Cargo's recommended pattern for shared crate metadata in a
@@ -396,6 +1615,56 @@ the same path the launcher resolves it from.
396
1615
 
397
1616
  ---
398
1617
 
1618
+ ### Preflight pyproject + cargo shape
1619
+
1620
+ **Summary.** Preflight gains two more checks — `requirePyprojectShape`
1621
+ and `requireCargoShape` — that mirror the #280 / #290 pattern for
1622
+ `pyproject.toml` (pypi packages) and `Cargo.toml` (crates packages,
1623
+ plus `bundle_cli` on pypi packages). The maturin / setuptools /
1624
+ hatchling / cargo CLIs surface mismatched-shape errors 10-20 minutes
1625
+ into a release run, deep into the verification build, with messages
1626
+ that don't name the precondition that failed. The new checks fire at
1627
+ publish time alongside the existing `require*` family and at PR time
1628
+ via `check.yml`. Findings aggregate across every failing package so
1629
+ consumers fix them all in one round-trip, exactly the shape the prior
1630
+ checks established. #301.
1631
+
1632
+ **Required changes.** None for well-formed manifests. Repos that
1633
+ declared one of the documented mismatches below previously got a
1634
+ mid-release red; they now get a fast preflight red instead.
1635
+
1636
+ The new error codes:
1637
+
1638
+ | Code | Fires when |
1639
+ |------|------------|
1640
+ | `PIOT_PYPI_NAME_MISMATCH` | `pyproject.toml`'s `[project].name` differs from `[[package]].name` (or the `pypi` override). |
1641
+ | `PIOT_PYPI_BUILD_BACKEND_MISMATCH` | `[build-system].build-backend` is set and does not start with the prefix the configured `build` mode expects (`maturin` → `maturin`, `setuptools` → `setuptools`, `hatch` → `hatchling`/`hatch`). |
1642
+ | `PIOT_PYPI_DYNAMIC_VERSION_NO_BACKEND` | `[project].dynamic` includes `"version"` but neither `[tool.hatch.version]` nor `[tool.setuptools_scm]` is present. |
1643
+ | `PIOT_PYPI_MATURIN_INCLUDE_MISSING` | `bundle_cli` is set but `[tool.maturin].include` does not cover `bundle_cli.stage_to`. |
1644
+ | `PIOT_CRATES_NAME_MISMATCH` | `Cargo.toml`'s `[package].name` differs from `[[package]].name` (or the `crate` override). |
1645
+ | `PIOT_CRATES_MISSING_BIN` | `bundle_cli.bin` is set but the target `Cargo.toml` has no `[[bin]]` table with that name (and the implicit-bin name derived from `[package].name` does not match either). |
1646
+ | `PIOT_CRATES_FEATURE_NOT_DECLARED` | `features` (on `kind = "crates"` packages) or `bundle_cli.features` references a feature not declared in `[features]`. |
1647
+ | `PIOT_CRATES_WORKSPACE_VERSION_MISMATCH` | `[package].version.workspace = true` but no ancestor `Cargo.toml` declares `[workspace.package].version`. |
1648
+
1649
+ **Deprecations removed.** None.
1650
+
1651
+ **Behavior changes without code changes.** Repos with one of the
1652
+ shapes above used to get a confusing mid-release error from
1653
+ maturin / setuptools / hatchling / cargo (sometimes after a
1654
+ verification build of every transitive dep); they now get a
1655
+ fingerprintable `PIOT_*` error at preflight time, before any side
1656
+ effects. PR-time `check.yml` runs surface the same findings on
1657
+ every pull request, so the typical case is fix-before-merge rather
1658
+ than fix-after-release-red. The `[build-system].build-backend`
1659
+ check is deliberately narrow: a missing `[build-system]` table is
1660
+ allowed (pip falls back to setuptools), and the prefix match
1661
+ tolerates backend-version drift across maturin / setuptools /
1662
+ hatchling.
1663
+
1664
+ **Verification.** Misconfigure one field, run `pnpm putitoutthere
1665
+ check` (or open a PR with `check.yml` wired), see the relevant
1666
+ `PIOT_*` code surface in seconds instead of mid-release.
1667
+
399
1668
  ### New `check.yml` reusable workflow for PR-time config sanity
400
1669
 
401
1670
  **Summary.** `putitoutthere` now ships a third reusable workflow,
@@ -805,6 +2074,48 @@ step (one in the build matrix, one in the publish-job rebuild)
805
2074
  when the strict install fails; healthy installs see no
806
2075
  warning.
807
2076
 
2077
+ ### Platform-publish `.npmrc` lookup
2078
+
2079
+ **Summary.** The reusable workflow's per-triple platform-package
2080
+ publishes (`build = "bundled-cli"` / `build = "napi"`) ran `npm
2081
+ publish` from a temporary staging directory rather than from the
2082
+ consumer's package path. npm reads `.npmrc` from cwd upward; the
2083
+ consumer's `.npmrc` lives at `pkg.path`, never on the path to a
2084
+ tempdir, so platform publishes never saw the auth the main package
2085
+ relied on. OIDC trusted publishing masked the gap (auth flows via
2086
+ the `ACTIONS_ID_TOKEN_REQUEST_TOKEN` environment variable, not
2087
+ `.npmrc`), but the `NPM_TOKEN` bootstrap path (#310) — required
2088
+ for the very first publish of a brand-new npm package — and the
2089
+ internal Verdaccio e2e seam (#304) both broke because both rely on
2090
+ `.npmrc`-supplied auth.
2091
+
2092
+ The engine now invokes `npm publish <stagingDir>` with `cwd:
2093
+ pkg.path`, matching how the main-package publish already runs.
2094
+ npm reads the consumer's `.npmrc` (including any `_authToken`,
2095
+ `always-auth`, or scoped-registry entries) and applies it to the
2096
+ PUT for each per-triple platform package.
2097
+
2098
+ **Required changes.** None. The fix is internal to
2099
+ `src/handlers/npm-platform.ts`; consumer `release.yml` flows are
2100
+ unchanged.
2101
+
2102
+ **Deprecations removed.** None.
2103
+
2104
+ **Behavior changes without code changes.** Consumers who rely on
2105
+ `NPM_TOKEN` (rather than OIDC) for the first publish of a
2106
+ bundled-cli / napi family — i.e. a brand-new npm package whose
2107
+ per-triple sub-packages also don't exist yet — now succeed without
2108
+ the workaround of publishing a `0.0.0-bootstrap` stub by hand.
2109
+ OIDC consumers see no observable difference: the same env-derived
2110
+ auth keeps flowing because `npm publish` reads
2111
+ `NODE_AUTH_TOKEN`/`ACTIONS_ID_TOKEN_REQUEST_TOKEN` from the
2112
+ environment regardless of which directory cwd points at.
2113
+
2114
+ **Verification.** A consumer publishing a brand-new bundled-cli /
2115
+ napi family via `NPM_TOKEN` completes per-triple sub-package
2116
+ publishes alongside the main package on the first release run,
2117
+ with no `npm publish (platform) failed` errors in the log.
2118
+
808
2119
  ### `[package.bundle_cli]` now actually stages the binary
809
2120
 
810
2121
  **Summary.** Wheels published from a maturin pypi package that