putitoutthere 0.2.29 → 0.2.30

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