putitoutthere 0.2.45 → 0.2.47

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.
package/MIGRATIONS.md DELETED
@@ -1,4045 +0,0 @@
1
- # Migration guide
2
-
3
- How to upgrade between versions of `putitoutthere`. Sections are ordered
4
- newest-first; each one is self-contained. Every observable change to
5
- public API gets a section — additive changes as well as breaking ones —
6
- because versioning is not yet strictly semver.
7
-
8
- Each section covers five things, in order:
9
-
10
- 1. **Summary** — what changed and why.
11
- 2. **Required changes** — before/after diffs for config, CLI flags, and
12
- action inputs.
13
- 3. **Deprecations removed** — anything previously warned about that is
14
- now gone.
15
- 4. **Behavior changes without code changes** — same API, different
16
- runtime behavior (tag format, exit codes, default values).
17
- 5. **Verification** — commands you can run to confirm the upgrade
18
- worked, with the expected output.
19
-
20
- ---
21
-
22
- ## Unreleased
23
-
24
- ### New `verify bundle-cli` subcommand
25
-
26
- **Summary.** `putitoutthere verify bundle-cli` completes the `verify` command
27
- family (alongside `verify posture`, `verify npm-tarball`, `verify crate`, and
28
- `verify wheel`). It asserts a maturin bundled-CLI wheel under `<path>/dist`
29
- contains its cross-compiled binary at a path ending `<stage_suffix>/<bin>`,
30
- where `stage_suffix` is `--stage-to` with `[tool.maturin].python-source` (or
31
- the legacy `python_source`) subtracted from the front, and `<bin>` gains a
32
- `.exe` suffix on a Windows `--target`. It codifies, as one colocated-tested
33
- engine command, the inline "bundle_cli — verify wheel contains
34
- `<stage_to>/<bin>`" bash block the reusable release workflow carries
35
- (`_matrix.yml`, #282/#358); the behavior is unchanged — same wheel selection,
36
- same python-source stripping, same match, same error messages and exit code.
37
- The wheel is read with the same dependency-free pure-Node zip reader as
38
- `verify wheel` (no `unzip`), so it runs on every platform the maturin matrix
39
- builds on, Windows included. Unlike the earlier siblings' single copies —
40
- which lived in `e2e-fixture-job.yml`, a workflow that runs `node
41
- dist/cli-bin.js verify …` directly — this check's only copy lives in
42
- `_matrix.yml`, which invokes the engine solely via `action.yml` (no `verify`
43
- surface) and builds no `dist/`; that inline copy therefore stays in place
44
- until the reusable workflow gains a way to call the engine, with the tested
45
- command as the single source of truth going forward.
46
-
47
- **Required changes.** None. Additive. Consumers compose with the reusable
48
- workflow, not the CLI directly (design-commitment #10); this subcommand is
49
- an internal seam.
50
-
51
- **Deprecations removed.** None.
52
-
53
- **Behavior changes without code changes.** None.
54
-
55
- **Verification.** With a maturin bundled-CLI wheel under `<path>/dist`,
56
- `putitoutthere verify bundle-cli --path <path> --stage-to <dir> --bin <name> --target <triple>`
57
- prints `ok bundle_cli: <stage_suffix>/<bin> present in <wheel>` and exits 0
58
- when the binary is staged; a wheel missing it exits 1 with `wheel <name>
59
- missing bundle_cli binary at <stage_suffix>/<bin>` followed by a `wheel
60
- contents:` listing, and a package with no wheel under `dist/` exits 1 with
61
- `no wheel produced under <dir>`.
62
-
63
- ---
64
-
65
- ### New `verify wheel` subcommand
66
-
67
- **Summary.** `putitoutthere verify wheel` joins the `verify` command family
68
- (alongside `verify posture`, `verify npm-tarball`, and `verify crate`). It
69
- asserts a built maturin artifact under `<path>/dist` carries the planned
70
- version: a wheel row's first `*.whl` must have a `*.dist-info/METADATA`
71
- `Version:` equal to `--version`, and an sdist row (`--target sdist`) must
72
- produce a `*.tar.gz` whose filename contains it. This extracts the inline
73
- "Verify wheel/sdist version matches matrix.version" bash block the reusable
74
- e2e workflow carried (`e2e-fixture-job.yml`, #276) into one colocated-tested
75
- engine command (epic #442, sub-issue #450); the behavior is unchanged — same
76
- file selection, same error/ok messages, same exit code. The wheel's METADATA
77
- is read with a dependency-free pure-Node zip reader (no `unzip`), so the
78
- command runs on every platform the maturin matrix builds on.
79
-
80
- **Required changes.** None. Additive. Consumers compose with the reusable
81
- workflow, not the CLI directly (design-commitment #10); this subcommand is
82
- an internal seam the workflow invokes.
83
-
84
- **Deprecations removed.** None.
85
-
86
- **Behavior changes without code changes.** None.
87
-
88
- **Verification.** With a maturin build under `<path>/dist`,
89
- `putitoutthere verify wheel --path <path> --version <v> --target <triple>`
90
- prints `ok wheel: <name>.whl METADATA Version=<v>` and exits 0 when the
91
- wheel carries `<v>`; a mismatch exits 1 with
92
- `wheel METADATA Version='<actual>' but plan='<v>'`. `--target sdist` checks
93
- the sdist filename instead (`ok sdist:` / `does not contain planned
94
- version`).
95
-
96
- ---
97
-
98
- ### New `verify crate` subcommand
99
-
100
- **Summary.** `putitoutthere verify crate` joins the `verify` command family
101
- (alongside `verify posture` and `verify npm-tarball`). It reads a published
102
- `.crate` back off the `cargo-http-registry` disk root the engine published
103
- to, extracts it, and asserts the crate's source tree (`src/lib.rs` or
104
- `src/main.rs`) is present — proof the publish shipped real contents, not an
105
- empty or silently no-op'd tarball. This extracts the inline `.crate`
106
- verification bash block the reusable e2e workflow carried
107
- (`e2e-fixture-job.yml`, #334) into one colocated-tested engine command
108
- (epic #442, sub-issue #449); the behavior is unchanged — same crates-row
109
- selection, same present-and-non-empty guard, same error messages. Unlike
110
- the npm sibling, it reads a local disk root (same host, same job), so it
111
- takes `--registry-root <dir>` and performs no HTTP download or retry.
112
-
113
- **Required changes.** None. Additive. Consumers compose with the reusable
114
- workflow, not the CLI directly (design-commitment #10); this subcommand is
115
- an internal seam the workflow invokes.
116
-
117
- **Deprecations removed.** None.
118
-
119
- **Behavior changes without code changes.** None.
120
-
121
- **Verification.** With a published `.crate` under `<root>`,
122
- `putitoutthere verify crate --matrix '[{"name":"c","kind":"crates","version":"1.0.0"}]' --registry-root <root>`
123
- prints `ok: <path> contains src/lib.rs or src/main.rs` and exits 0; a
124
- missing/empty `.crate` exits 1 with `no .crate file found (or empty)`, and
125
- a source-less `.crate` exits 1 with
126
- `.crate tarball missing src/lib.rs and src/main.rs`.
127
-
128
- ---
129
-
130
- ### New `verify npm-tarball` subcommand; `verify` becomes a command family
131
-
132
- **Summary.** `putitoutthere verify` is now a command family. Bare `verify`
133
- — the #414 OIDC-vs-token publish/trust posture check — is unchanged and
134
- additionally spellable `verify posture`. A new subcommand,
135
- `verify npm-tarball`, downloads a published npm tarball back from the
136
- registry and asserts its contents honor what was declared: main/noarch
137
- rows must contain every directory entry their `package.json` `files[]`
138
- declares, and `--per-triple` asserts each synthesized platform package
139
- ships a non-`package.json` binary. This extracts the two inline bash
140
- blocks the reusable e2e workflow carried (`e2e-fixture-job.yml`) into one
141
- colocated-tested engine command (epic #442, sub-issue #443); the behavior
142
- is unchanged — same row selection, same registry read + retry, same error
143
- messages.
144
-
145
- **Required changes.** None. Additive. Consumers compose with the reusable
146
- workflow, not the CLI directly (design-commitment #10); this subcommand is
147
- an internal seam the workflow invokes. Anyone scripting `verify` directly
148
- keeps the same behavior — bare `verify` still means the posture check.
149
-
150
- **Deprecations removed.** None.
151
-
152
- **Behavior changes without code changes.** None. The reusable workflow's
153
- externally observable behavior (what publishes, tag format, release body)
154
- is identical; only the internal implementation of the post-publish
155
- tarball-verification step moved from inline YAML into the engine.
156
-
157
- **Verification.** Run `putitoutthere verify npm-tarball --matrix <plan
158
- matrix> --cwd <repo>` after a publish: it prints `ok: package/<dir>/` per
159
- honored `files[]` directory and exits non-zero (with a `::error::` naming
160
- the missing directory) if the published tarball dropped one.
161
-
162
- ### GitHub Release creation touches only the release's own tags
163
-
164
- **Summary.** The reusable workflow's `Create GitHub Release(s) for new
165
- tag(s)` step no longer runs a blanket `git fetch --tags origin`, and now
166
- pushes each of the release's tags ref-scoped (`git push origin
167
- "refs/tags/$tag"`, idempotent) before `gh release create`. Previously the
168
- un-forced fetch coupled the step to the state of **every** tag in the
169
- consumer's repo: a floating major tag (e.g. `v0`) force-moved mid-run by
170
- the consumer's own promotion automation failed the publish job with
171
- `! [rejected] v0 -> v0 (would clobber existing tag)` *after* every
172
- registry publish and per-package tag push had succeeded — and because
173
- consumer automation gates promotion on this job's conclusion, the release
174
- was published but never promoted, with no safe job-level rerun (the
175
- replayed publish re-plans against the already-pushed tags and hard-fails
176
- on an empty plan). Observed twice in two days on
177
- thekevinscott/testing-conventions (#436). The ref-scoped push also
178
- completes the engine's deliberately warn-only tag push (#407) within the
179
- same run, closing the second observed variant (`tag ... exists locally
180
- but has not been pushed`).
181
-
182
- **Required changes.** None. The step is workflow-internal; no config,
183
- input, or consumer-side YAML changes.
184
-
185
- **Deprecations removed.** None.
186
-
187
- **Behavior changes without code changes.**
188
-
189
- - A publish run no longer fails when any tag it does not own (a floating
190
- major tag, another run's tags) moves between checkout and Release
191
- creation.
192
- - A per-package tag that the engine created but could not push (its push
193
- is warn-only, #407) is now pushed by this step in the same run, so the
194
- GitHub Release is cut instead of the job failing one step later.
195
- - A genuine conflict — the same version tag already on the remote at a
196
- *different* commit — still fails loudly at the ref-scoped push. That
197
- means two runs released the same version, which the
198
- `putitoutthere-release-*` concurrency group exists to prevent.
199
-
200
- **Verification.** Land two release-triggering pushes to `main` a few
201
- minutes apart in a repo whose promotion automation force-moves a floating
202
- major tag on release success (the thekevinscott/testing-conventions
203
- shape): the second run's publish job completes green and its GitHub
204
- Releases exist, where it previously failed at `Create GitHub Release(s)
205
- for new tag(s)`. On any release run's log, the step shows a per-tag
206
- `git push origin "refs/tags/<tag>"` and no `git fetch`.
207
-
208
- ### napi `.node` embeds the release version
209
-
210
- **Summary.** `kind = "npm"` `build = "napi"` releases now rewrite the napi
211
- crate's version to the planned release version before `napi build` compiles
212
- the `.node`, mirroring the maturin (#276) and bundled-cli (#366) pre-build
213
- bumps. Previously the compiled `.node` embedded whatever `[package].version`
214
- literal sat on disk, so a library re-exposing the Rust core's `version()`
215
- through napi reported a version diverging from the published npm package
216
- (whose `package.json` version was already correct).
217
-
218
- **Required changes.** None for the common case. The bump is a
219
- workflow-internal step; a consumer whose napi crate's `Cargo.toml` is
220
- colocated with `package.json` (the napi-rs single-crate default, including
221
- the `version.workspace = true` workspace shape via #428) needs no config
222
- change. A **multi-mode** package (`build = ["napi", "bundled-cli"]`) or one
223
- whose napi crate lives outside the package directory has no `Cargo.toml` at
224
- the package path — piot skips the bump there with a `::notice::` (no
225
- per-napi `crate_path` config exists yet), so such a `.node` still embeds
226
- its on-disk `CARGO_PKG_VERSION`. Until a `crate_path` field lands, bump that
227
- crate's version yourself or colocate its manifest with `package.json`.
228
-
229
- **Deprecations removed.** None.
230
-
231
- **Behavior changes without code changes.** For a napi package, the
232
- per-platform `.node` now reports the released version from any API sourced
233
- on `CARGO_PKG_VERSION` (e.g. a Rust `version()` re-exported through napi).
234
- The synthesized per-platform `package.json` version is unchanged (it was
235
- already `matrix.version`). Non-napi npm packages are unaffected.
236
-
237
- **Verification.** Release a napi package and read its Rust-sourced
238
- `version()` from the installed addon (or inspect the crate manifest the
239
- build rewrote): it matches the published npm version, not the pre-release
240
- on-disk literal.
241
-
242
- ### Version bump follows Cargo workspace inheritance
243
-
244
- **Summary.** piot's pre-build version rewrite — the maturin `write-version`
245
- step (#276) and the npm / pypi bundled-cli `write-crate-version` step
246
- (#366) — now understands Cargo **workspace version inheritance**. A crate
247
- whose `[package]` declares `version.workspace = true` and inherits its
248
- version from `[workspace.package].version` at the workspace root (the
249
- standard shape for a single Rust core re-exposed as a PyO3 wheel and a napi
250
- addon) previously failed these steps with `Cargo.toml: no [package].version
251
- field found`. The rewrite now resolves the inheritance: a literal
252
- `[package].version` is bumped in place, while an inheriting member's version
253
- is bumped at the workspace root's `[workspace.package].version`.
254
-
255
- **Required changes.** None. Additive — a repo layout that previously errored
256
- at release now works. If you kept a duplicated literal `[package].version`
257
- in each crate to work around this, you may switch the members to
258
- `version.workspace = true` and let piot bump the workspace root.
259
-
260
- **Deprecations removed.** None.
261
-
262
- **Behavior changes without code changes.** A `kind = "pypi"`
263
- `build = "maturin"` or `kind = "npm"` `build = "bundled-cli"` package whose
264
- crate inherits its version from the workspace no longer fails the build
265
- step; the produced wheel / cross-compiled binary now carries the planned
266
- release version. Single-crate layouts with a literal `[package].version` are
267
- byte-for-byte unchanged.
268
-
269
- **Verification.** In a cargo workspace whose root declares
270
- `[workspace.package]` `version = "X"` and whose maturin/napi crate sets
271
- `version.workspace = true`, run a release: the published wheel / `.node`
272
- carries the planned release version (not the stale on-disk `X`), and the
273
- workspace-root `Cargo.toml` is the manifest rewritten during the build.
274
-
275
- ### verify: publish trust posture
276
-
277
- **Summary.** New command `putitoutthere verify` reports, per package, how
278
- its latest release authenticated to the registry — `oidc` (a
279
- trusted-publisher / provenance attestation is present, so the long-lived
280
- token can be dropped), `token` (no such attestation), `unpublished`, or
281
- `unreachable`. Read from public registry trust attribution (crates.io
282
- `trustpub_data`, npm provenance attestations, PyPI PEP 740 provenance) —
283
- no secrets. `--check` exits non-zero on any token-dependent package, so a
284
- team can gate CI on reaching the zero-secret OIDC steady state.
285
-
286
- **Required changes.** None. Additive — a new command.
287
-
288
- **Deprecations removed.** None.
289
-
290
- **Behavior changes without code changes.** None — new surface.
291
-
292
- **Verification.** Run `verify` on a repo whose packages publish via OIDC
293
- trusted publishers: each shows `oidc` and `verify --check` exits zero. A
294
- package last published with a long-lived token shows `token` and
295
- `verify --check` exits non-zero. `--json` emits the same rows.
296
-
297
- ### `status`: registry-vs-tag drift report
298
-
299
- **Summary.** New read-only command `putitoutthere status` reports, per
300
- package, whether the latest git tag matches the registry's latest
301
- published version, flagging drift — notably `published, untagged` (a
302
- version live on the registry but missing its tag, which strands the
303
- package). Reads public registry metadata only; no auth.
304
-
305
- **Required changes.** None. Additive — a new command.
306
-
307
- **Deprecations removed.** None.
308
-
309
- **Behavior changes without code changes.** None — new surface.
310
-
311
- **Verification.** Run `status` over a repo with a published-but-untagged
312
- package: that package shows `published, untagged` and `status --check`
313
- exits non-zero; a fully in-sync repo shows every package `in sync` and
314
- exits zero. `--json` emits the same rows as JSON.
315
-
316
- ### plan: publish-skip verdict and skew
317
-
318
- **Summary.** `putitoutthere plan` now always reports, per planned package,
319
- whether a release from this ref would `PUBLISH` (the version is not yet on
320
- the registry), `SKIP` (already published), or `UNKNOWN` (the registry
321
- couldn't be reached) — and flags **version skew** when a package would
322
- `PUBLISH` while a `depends_on` dependency `SKIP`s. It reuses the real
323
- planner and the same `isPublished` the publish path runs, so the preview
324
- matches reality. Always-on — there is no flag.
325
-
326
- **Required changes.** None for consumers of the reusable workflow. The
327
- matrix the workflow consumes (`outputs.matrix`, from the JS action's
328
- `$GITHUB_OUTPUT`) is **unchanged** — a bare array of build rows, byte for
329
- byte. Only a caller that parses `plan --json` **stdout** directly is
330
- affected (see below).
331
-
332
- **Deprecations removed.** None.
333
-
334
- **Behavior changes without code changes.**
335
-
336
- | | Before | After |
337
- |---|---|---|
338
- | `plan --json` stdout | `[ …matrix rows… ]` | `{ "matrix": [ …matrix rows… ], "verdicts": [ … ], "skew": [ … ] }` |
339
- | `plan` human output | matrix rows only | matrix rows + a `publish plan:` section + a `⚠ version skew` line when applicable |
340
- | reusable-workflow `outputs.matrix` | bare array | bare array (unchanged) |
341
-
342
- The `matrix` field is identical to the old top-level array; a direct
343
- stdout reader migrates by reading `.matrix`. A registry blip degrades a
344
- verdict to `UNKNOWN` and still emits the matrix — `plan` never aborts on
345
- an unreachable registry.
346
-
347
- **Verification.** Run `plan` on a ref where one package's planned version
348
- is already published and a dependent's is not: the published one shows
349
- `SKIP`, the dependent `PUBLISH`, and a `version skew` warning names the
350
- pair. `plan --json` carries the same under `verdicts` / `skew`. The build
351
- matrix (and a real release) are unaffected.
352
-
353
- ### reconcile: backfill missing tags
354
-
355
- **Summary.** New command `putitoutthere reconcile` backfills the missing
356
- git tag for every package that is live on its registry but untagged
357
- (`status`'s `published, untagged` drift). It is the on-demand companion to
358
- the publish-path auto-heal: auto-heal only fires for a package already in a
359
- publish run, so a package whose globs never change again stays stuck;
360
- `reconcile` heals it without a release. It reuses the same `computeStatus`
361
- detection `status` reports and the same idempotent `ensureTag` the publish
362
- path heals with — no parallel logic.
363
-
364
- **Required changes.** None. Additive — a new command.
365
-
366
- **Deprecations removed.** None.
367
-
368
- **Behavior changes without code changes.** `--dry-run` is now accepted on
369
- `reconcile` (it previews the heal without writing). It remains rejected on
370
- `plan` / `publish`, unchanged from the #244 removal.
371
-
372
- **Verification.** On a repo with a published-but-untagged package, run
373
- `putitoutthere reconcile`: the missing tag is created (per the package's
374
- `tag_format`) — pointed at a sibling package's tag commit for that version
375
- when one exists, else `HEAD` — and pushed; `git tag` / GitHub show it, and
376
- `status` then reports the package `in sync`. A second `reconcile` run is a
377
- no-op. `reconcile --dry-run` reports what it would create without writing;
378
- `--json` emits the actions.
379
-
380
- ### publish-path auto-heal: missing tags
381
-
382
- **Summary.** `putitoutthere publish` now self-heals a missing git tag: when
383
- a version is already live on the registry but has no tag, publish writes the
384
- tag instead of skipping silently. Previously the already-published branch
385
- returned before tagging, so a version that reached the registry on a
386
- half-failed run (published, then the run died before the tag step) was
387
- stranded published-but-untagged — and because piot derives "last released"
388
- from tags, it skipped forever and could never bump, while dependents drifted
389
- ahead into unflagged version skew.
390
-
391
- **Required changes.** None. Purely additive behavior on the publish path.
392
-
393
- **Deprecations removed.** None.
394
-
395
- **Behavior changes without code changes.** On the next release run after
396
- upgrading, any package that is live on a registry but missing its tag has the
397
- tag created (and pushed) at the release commit, automatically. Idempotent:
398
- packages already correctly tagged are untouched.
399
-
400
- **Verification.** Re-run a release on a repo with a published-but-untagged
401
- package: the run now creates the missing tag (per the package's `tag_format`;
402
- visible via `git tag` / on GitHub) and the package resumes normal version
403
- bumping on later releases.
404
-
405
- ### pypi version-independent wheels build once
406
-
407
- **Summary.** A `kind = "pypi"` `build = "maturin"` package whose wheel is
408
- Python-version-independent — `[tool.maturin].bindings = "bin"` (a
409
- Rust-binary `py3-none` wheel) or a pyo3 `abi3` / `abi3-pyXY` extension (a
410
- `cp3x-abi3` stable-ABI wheel) — used to be built once per CPython version
411
- in the resolved set (inferred from `[project].requires-python`, or pinned
412
- via `python_versions`). Because such a wheel is byte-identical no matter
413
- which interpreter built it, the fan produced N duplicate wheels, and the
414
- documented `pypi-publish` recipe's `merge-multiple: true` download then
415
- race-corrupted the identical wheel filenames extracted onto one path
416
- (`twine check` → `zipfile.BadZipFile`). The planner now collapses the fan
417
- to a single wheel per target for these packages.
418
-
419
- **Required changes.** None. Detection is automatic from your existing
420
- `pyproject.toml` / `Cargo.toml`; no `putitoutthere.toml`, `release:`
421
- trailer, or reusable-workflow input changes. The consumer `pypi-publish`
422
- job is unchanged — its `pattern: '*-wheel-*'` still matches the (now
423
- single) wheel artifact.
424
-
425
- **Deprecations removed.** None.
426
-
427
- **Behavior changes without code changes.** For a version-independent
428
- maturin wheel resolving to more than one CPython version:
429
-
430
- | | Before | After |
431
- |---|--------|-------|
432
- | Wheel build rows per target | one per resolved version (e.g. 6 for `>=3.9`) | one |
433
- | Wheel artifact name | `<pkg>-wheel-<triple>-py<ver>` (per version) | `<pkg>-wheel-<triple>` (unsuffixed) |
434
- | Build interpreter | each resolved version | newest resolved version only |
435
-
436
- Ordinary per-version extension modules (no abi3, no `bindings = "bin"`)
437
- are unaffected — they still fan and keep their `-py<ver>` suffixes. The
438
- sdist row is unchanged. Detection is conservative: an abi3 setup the
439
- engine doesn't recognize (a workspace-inherited `pyo3` dependency, a
440
- `[target.'cfg(...)'.dependencies]` table) falls back to the prior fanning
441
- behavior.
442
-
443
- **Verification.** Run `putitoutthere plan` (or release) a maturin package
444
- with `bindings = "bin"` or a pyo3 `abi3` feature and a `requires-python`
445
- spanning multiple minors. The build matrix now shows a single
446
- `<pkg>-wheel-<triple>` artifact per target instead of one per version, and
447
- the `pypi-publish` job's `twine check` no longer fails with `BadZipFile`.
448
-
449
- ### npm `TLOG_CREATE_ENTRY_ERROR` (409) provenance retry race
450
-
451
- **Summary.** `kind = "npm"` releases that publish with provenance
452
- (`--provenance`, the OIDC trusted-publisher path) could abort mid-matrix
453
- when npm's internal retry-on-transient-network-error re-submitted a
454
- byte-identical attestation and Sigstore/Rekor rejected the duplicate with
455
- `TLOG_CREATE_ENTRY_ERROR` (HTTP 409, "an equivalent entry already exists in
456
- the transparency log"). Only the registry-PUT edition of that race (the
457
- `E403` "cannot publish over the previously published versions" shape) was
458
- tolerated; the attestation edition fell through to a hard failure, which
459
- for a multi-platform (`napi` / `bundled-cli`) package left the remaining
460
- sub-packages and the main package unpublished — a partial release. The
461
- engine now recognizes the 409 and, because a 409 from Rekor does not by
462
- itself prove the artifact reached the registry, re-probes `npm view` to
463
- decide: present ⇒ benign duplicate (success); absent ⇒ a genuine partial
464
- publish, reported as an actionable error.
465
-
466
- **Required changes.** None. The behavior is automatic and applies to every
467
- `kind = "npm"` package; no `putitoutthere.toml`, `release:` trailer, or
468
- reusable-workflow input changes.
469
-
470
- **Deprecations removed.** None.
471
-
472
- **Behavior changes without code changes.** A `kind = "npm"` publish that
473
- hits `TLOG_CREATE_ENTRY_ERROR` (409) no longer fails unconditionally:
474
-
475
- | Situation | Before | After |
476
- |-----------|--------|-------|
477
- | 409 raised, package **is** on the registry | publish job fails (`npm publish (platform) failed: …`) | treated as `already-published` / counted as published; the cascade continues |
478
- | 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 |
479
-
480
- In the genuine partial-publish case the engine cannot recover in-process —
481
- an orphaned attestation needs a new `runID`/attempt to produce a fresh
482
- Rekor entry — so re-running the release is the documented remedy.
483
-
484
- **Verification.** Re-run a release whose previous attempt died on
485
- `TLOG_CREATE_ENTRY_ERROR`. If the sub-package already landed, the run now
486
- reports it as already-published and proceeds to the remaining packages
487
- instead of aborting; if it never landed, the error names the package and
488
- tells you to re-run (which mints a new attestation and gets past the
489
- dedupe).
490
-
491
- ### Bundled-CLI launcher generation no-ops without `[package.bundle_cli]`
492
-
493
- **Summary.** #299 moved npm bundled-cli launcher generation into the
494
- engine and invoked it on every bundled-cli package's main row.
495
- `writeLauncherFromConfig` assumed `[package.bundle_cli]` was always
496
- present and dereferenced `bundle_cli.bin` unconditionally, so a
497
- bundled-cli npm package that omits the table — the legacy
498
- "bring-your-own `scripts/build.cjs` + hand-authored `bin/<bin>.js`"
499
- shape that #298 explicitly kept opt-in — crashed the release at the
500
- `write-launcher` step with `Cannot read properties of undefined
501
- (reading 'bin')`. Launcher generation now no-ops when the table is
502
- absent, mirroring the cross-compile step's existing `matrix.bundle_cli`
503
- gate. The gate lives in the engine, not the workflow `if:`, because the
504
- main row the step runs on never carries `bundle_cli` (`plan.ts` attaches
505
- it only to per-target bundled-cli rows).
506
-
507
- **Required changes.** None. A bundled-cli package that declares
508
- `[package.bundle_cli]` is unchanged — the engine still generates
509
- `bin/<bin>.js` and the `package.json#bin` entry. A package that omits
510
- the table and ships its own launcher (the legacy path) no longer crashes;
511
- the engine leaves its launcher alone. A consumer that *intended* the
512
- declarative path and simply forgot the table should add it — the
513
- cross-compile and launcher generation both key off it:
514
-
515
- | Before | After |
516
- |--------|-------|
517
- | `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 |
518
-
519
- **Deprecations removed.** None.
520
-
521
- **Behavior changes without code changes.** A bundled-cli npm package
522
- without `[package.bundle_cli]` previously failed its release at the
523
- `write-launcher` step (as of #299); it now completes, with the engine
524
- authoring no launcher for it.
525
-
526
- **Verification.** Trigger a release for a bundled-cli npm package that
527
- omits `[package.bundle_cli]`: the run completes (previously it failed at
528
- the `write-launcher` step). A package that declares the table still gets
529
- its engine-generated launcher.
530
-
531
- ### Preflight npm package name must match configured name
532
-
533
- **Summary.** Preflight gains `requirePackageJsonShape`, the npm analogue
534
- of `requirePyprojectShape` / `requireCargoShape` (#301). Every cascaded
535
- `kind = "npm"` package's `package.json` `name` must equal the configured
536
- `[[package]].name` (or the `npm` override). `npm publish` packs the
537
- manifest `name`, but the engine's idempotency probe (`npm view <name>`)
538
- and the tag / release-URL bookkeeping use the configured name — so a
539
- divergence silently breaks idempotency and can publish under an
540
- unexpected name. pypi and crates already enforced the equivalent
541
- (`PIOT_PYPI_NAME_MISMATCH`, `PIOT_CRATES_NAME_MISMATCH`); this closes the
542
- gap for npm. The check fires at publish time alongside the existing
543
- `require*` family and at PR time via `check.yml`. Findings aggregate
544
- across every failing package.
545
-
546
- **Required changes.** None for well-formed manifests — `package.json`
547
- `name` already matches the configured name in the common case. The repos
548
- that trip the new check are those that used a path-style identifier as
549
- `[[package]].name` (e.g. `js/foo`) without setting the `npm` override
550
- while shipping `package.json` `name = "foo"`; those previously published
551
- with a broken `npm view` idempotency check and a wrong reported URL, and
552
- now get a fast preflight red instead. Fix by aligning the names or
553
- declaring the override:
554
-
555
- | Before | After |
556
- |--------|-------|
557
- | `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) |
558
-
559
- The new error code:
560
-
561
- | Code | Fires when |
562
- |------|------------|
563
- | `PIOT_NPM_NAME_MISMATCH` | `package.json`'s `name` differs from `[[package]].name` (or the `npm` override). |
564
-
565
- **Deprecations removed.** None.
566
-
567
- **Behavior changes without code changes.** An npm package with a name
568
- divergence that previously published (under the manifest name, with a
569
- broken `npm view` idempotency check and a wrong reported URL) now fails
570
- at preflight with a fingerprintable `PIOT_NPM_NAME_MISMATCH` before any
571
- side effect. Scoped names are compared verbatim (`npm = "@scope/foo"`
572
- matches `package.json` `name = "@scope/foo"`). A missing or malformed
573
- `package.json` is left to the other checks / the publish step.
574
-
575
- **Verification.** Set an npm package's `package.json` `name` to something
576
- other than its configured `[[package]].name` (with no `npm` override),
577
- run `pnpm putitoutthere check` (or open a PR with `check.yml` wired), and
578
- see `PIOT_NPM_NAME_MISMATCH` surface in seconds instead of mid-release.
579
-
580
- ### `_matrix.yml` build job primes a cargo cache (#391)
581
-
582
- **Summary.** `_matrix.yml`'s build job previously ran every per-target
583
- matrix cell without any Cargo cache. Each cell cold-compiled the full
584
- Rust dep graph on every PR — even PRs that touched nothing Rust-side —
585
- because `~/.cargo/registry` and the per-package `target/` dir started
586
- empty on every runner. On a wide bundle_cli / napi / maturin matrix
587
- this dominated wall-clock: 4-6 min per cell, ~8 min end-to-end on a
588
- typical downstream consumer's `release-precheck.yml` run, with no
589
- headroom against a 10-min PR CI gate.
590
-
591
- The build job now runs `Swatinem/rust-cache@v2` immediately after
592
- `actions/checkout`, gated on rows that actually invoke cargo:
593
-
594
- - `pypi/maturin` non-sdist (maturin shells out to cargo)
595
- - `npm/napi` (the consumer's `napi build` script calls cargo)
596
- - `npm/bundled-cli` non-main (the engine's `cargo build` for the
597
- staged CLI binary)
598
-
599
- The cache is partitioned by `matrix.target` via `shared-key` so a
600
- write to one target's slot doesn't blow away the next cell's, and
601
- `workspaces` enumerates both `matrix.path` (the consumer's package
602
- crate, where maturin / napi / single-crate bundled-cli compile) and
603
- `matrix.bundle_cli.crate_path` (the bundle_cli crate when it lives
604
- in a separate dir from `matrix.path` — the dirsql shape). Rows that
605
- produce no cargo work (pypi sdist, pure-Python hatch wheels, npm
606
- vanilla, bundled-cli `main`) skip the cache step entirely.
607
-
608
- **Required changes.** None — the cache is internal to the reusable
609
- workflow. No consumer config, YAML, or scripts need to change.
610
-
611
- **Deprecations removed.** None.
612
-
613
- **Behavior changes without code changes.** Per-target matrix cells
614
- that ran cargo cold previously will now restore their dep graph from
615
- GitHub Actions cache storage on the second and subsequent matching
616
- runs (matching = same `matrix.target` + same Cargo.lock contents).
617
- Cache storage accrues against the consumer's repository quota — the
618
- same accounting as any other `actions/cache` consumer, no separate
619
- billing. First run after a `Cargo.lock` change recompiles cold and
620
- takes the same wall-clock as before. The acceptance criterion: a
621
- second matrix run with no `Cargo.lock` change finishes the Rust
622
- compile step in well under one minute per cell.
623
-
624
- **Verification.** A `build.yml` (or `release.yml`) run's logs now
625
- show a `cargo cache (#391)` step between `Set up job` and the first
626
- Rust-touching step, on every row that invokes cargo. The step logs
627
- either `Cache hit` (subsequent run, no `Cargo.lock` change) or
628
- `Cache not found` (first run, or after a `Cargo.lock` change) and
629
- records a `~/.cargo` + `target/` save at job end. Pure-Python sdist
630
- and npm vanilla rows do not show the step at all.
631
-
632
- ### npm bundled-cli: npm-flavor triples now mapped to Rust triples
633
-
634
- **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:
635
-
636
- ```
637
- error: toolchain 'stable-x86_64-unknown-linux-gnu' does not support target 'linux-x64-gnu'
638
- ```
639
-
640
- Each affected step now contains an explicit `case` statement mapping napi-rs npm triples to their Rust equivalents before the `gnu→musl` swap.
641
-
642
- **Required changes.** None — the mapping is inside the reusable workflow. No consumer config, YAML, or scripts need to change.
643
-
644
- **Deprecations removed.** None.
645
-
646
- **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.
647
-
648
- **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> -> …`.
649
-
650
- ### `bundle_cli` stage step runs after consumer `npm run build`
651
-
652
- **Summary.** The engine's `bundle_cli — stage binary` step previously ran
653
- **before** `npm run build --if-present`. A consumer build script that also
654
- runs `cargo build --target $TARGET` (the raw `-linux-gnu` triple) and copies
655
- the result to `build/<triple>/` would overwrite the engine's musl binary with
656
- a glibc-linked one; the existence-only verify check passed and the
657
- dynamically-linked artifact shipped to the registry. The stage step now runs
658
- **after** `npm run build --if-present` so the engine's statically-linked musl
659
- binary always wins. The `bundle_cli — verify` step in `_matrix.yml` now also
660
- asserts static linking (`file`/`ldd` check), mirroring the check already
661
- present in `e2e-fixture-job.yml`.
662
-
663
- **Required changes.** None — this is a pure step-reordering inside the
664
- reusable workflow. No consumer-side YAML, config, or scripts need to change.
665
-
666
- **Deprecations removed.** None.
667
-
668
- **Behavior changes without code changes.** Consumer build scripts that stage
669
- a binary to `build/<triple>/` as part of `npm run build` will have that binary
670
- overwritten by the engine's musl binary. This was always the intended behavior;
671
- the old ordering was a bug. Consumer scripts that only compile TypeScript or
672
- do non-binary work are unaffected.
673
-
674
- **Verification.** A `bundle_cli` Linux build job's logs now show:
675
- 1. `bundle_cli — cargo build for <triple>` (musl build, unchanged)
676
- 2. `npm run build --if-present` (consumer build step)
677
- 3. `bundle_cli — stage binary into build/<triple>` (engine stages musl binary)
678
- 4. `bundle_cli — verify … is statically linked` (passes)
679
-
680
- ### `bundle_cli` musl builds install `musl-tools` C cross-compiler
681
-
682
- **Summary.** `rustup target add x86_64-unknown-linux-musl` registers the
683
- Rust musl target but does not install the C cross-compiler
684
- (`x86_64-linux-musl-gcc`). Crates that compile C source at build time —
685
- `libsqlite3-sys` with `features = ["bundled"]`, `openssl-sys` with
686
- `features = ["vendored"]`, and similar — invoke the C compiler directly
687
- during `cargo build`; without `musl-gcc` present, the build fails with:
688
-
689
- ```
690
- failed to find tool "x86_64-linux-musl-gcc": No such file or directory
691
- ```
692
-
693
- The `musl-tools` apt package provides `musl-gcc` and is not pre-installed
694
- on `ubuntu-latest`. `_matrix.yml` and `e2e-fixture-job.yml` now run
695
- `sudo apt-get install -y musl-tools` and export `CC_<triple>=musl-gcc` to
696
- `$GITHUB_ENV` before `cargo build`, gated on Linux targets.
697
-
698
- **Required changes.** None — the step is added automatically by the
699
- reusable workflow.
700
-
701
- **Deprecations removed.** None.
702
-
703
- **Behavior changes without code changes.** CLI crates that compile C
704
- source and previously had to run a custom `build` script to install
705
- `musl-tools` may be able to remove that script.
706
-
707
- **Verification.** On a bundle_cli Linux build, the workflow now prints
708
- `sudo apt-get install -y musl-tools` before the `cargo build` step.
709
- Crates with C deps (e.g. `features = ["bundled"]` on `rusqlite`) compile
710
- successfully without consumer-side workarounds.
711
-
712
- ### `bundle_cli` Linux binaries compiled as static musl
713
-
714
- **Summary.** `bundle_cli`'s Linux cross-compile step previously ran
715
- `cargo build --target $TARGET` directly on the GitHub-hosted runner.
716
- That runner's glibc (currently 2.39 on Ubuntu 24.04) got baked into
717
- the produced binary as a hard runtime requirement, so any older Linux
718
- at install time failed with `./bin: /lib/x86_64-linux-gnu/libc.so.6:
719
- version 'GLIBC_2.39' not found`. The fix derives a `BINARY_TARGET`
720
- from `matrix.target` by substituting `-linux-gnu*` → `-linux-musl*`
721
- and uses that for the three workflow steps that touch the binary's
722
- compile triple. Statically-linked musl binaries have no glibc floor.
723
- The package's declared target triple (used for npm platform-package
724
- names, napi builds, wheel tags, artifact names) is unchanged; only
725
- the binary inside switches compile triple.
726
-
727
- **Required changes.** None for the common case — the workflow makes
728
- the swap automatically. The exception: CLI crates that
729
- dynamic-link a system C library through default cargo features will
730
- see a linker error on the first release after upgrade. The static
731
- musl build cannot satisfy a dynamic link against the host's glibc-world
732
- libraries. The fix is a one-line `Cargo.toml` change per case:
733
-
734
- | Symptom (cargo error mentions) | Fix in your CLI's `Cargo.toml` |
735
- |--------------------------------|--------------------------------|
736
- | `openssl-sys`, `libssl.so`, OpenSSL | Switch to `rustls` (`reqwest = { default-features = false, features = ["rustls-tls"] }`), or pin `openssl = { features = ["vendored"] }` |
737
- | `git2`, `libgit2` | `git2 = { features = ["vendored-openssl", "vendored-libgit2"] }` |
738
- | `libsqlite3-sys`, SQLite | `rusqlite = { features = ["bundled"] }` (or `libsqlite3-sys = { features = ["bundled"] }`) |
739
- | `libpq`, Postgres client | Swap `postgres-native-tls` for `postgres-rustls`, or use `sqlx` with the `rustls` feature |
740
- | `libmysqlclient`, MySQL client | Same — prefer a pure-Rust client; `mysqlclient-sys` has no clean static path |
741
-
742
- The musl build fails loudly at release time when one of these is
743
- missed — there is no silent broken-binary failure mode. A blocked
744
- release is the worst outcome.
745
-
746
- **Deprecations removed.** None.
747
-
748
- **Behavior changes without code changes.** The bundled binary inside
749
- a Linux package built before this release required the build
750
- runner's glibc (currently 2.39); after this release it has no glibc
751
- requirement and runs on any Linux ≥ kernel 3.2, including Alpine,
752
- NixOS in musl mode, scratch containers, and any host whose glibc is
753
- older than the build runner's. Package identifiers (npm platform-
754
- package names like `@scope/cli-linux-x64-gnu`, PyPI wheel tags,
755
- artifact upload names) are unchanged — the substitution applies only
756
- to the binary's compile triple.
757
-
758
- **Verification.** Install a `bundle_cli`-enabled package from any
759
- pre-Ubuntu-24.04 Linux (Ubuntu 22.04, Debian 12, Amazon Linux 2,
760
- Alpine) and run the CLI — no `GLIBC_2.x not found` error. On the
761
- build side, the cargo invocation in CI now prints
762
- `cargo build --release --target x86_64-unknown-linux-musl ...`
763
- instead of `-gnu` for Linux rows; `file` on the produced binary
764
- reports `statically linked` instead of `dynamically linked`.
765
-
766
- ### pypi `requires-python` includes CPython 3.14
767
-
768
- **Summary.** Open-ended `requires-python` inference now expands against
769
- putitoutthere's checked-in released-CPython list through CPython 3.14.
770
- This fixes the stale-tail failure mode where `requires-python = ">=3.11"`
771
- emitted cp311/cp312/cp313 wheels but omitted cp314 after Python 3.14 was
772
- released.
773
-
774
- **Required changes.** None.
775
-
776
- | Before | After |
777
- |--------|-------|
778
- | `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. |
779
-
780
- **Deprecations removed.** None.
781
-
782
- **Behavior changes without code changes.** Consumers with open-ended
783
- `requires-python` ranges may see an extra cp314 wheel row. Explicit
784
- `python_versions` overrides are unchanged and still pin the exact wheel
785
- set.
786
-
787
- **Verification.** Push a release for a `kind = "pypi"` package whose
788
- `pyproject.toml` declares `requires-python = ">=3.11"`; the build matrix
789
- should include a `python_version: "3.14"` row, and the published PyPI
790
- release should include a cp314 wheel.
791
-
792
- ### Manual release via `release_packages`
793
-
794
- **Summary.** `release.yml` gained an optional `release_packages`
795
- `workflow_call` input. When set, it triggers a manual release of an
796
- explicit list of packages, bypassing change detection entirely. The
797
- motivating case: putitoutthere ships a release-pipeline bug, the bug is
798
- fixed, and downstream consumers must re-release the affected packages
799
- even though their own repos have no new commits since the last tag —
800
- the change-detected path emits an empty matrix in that state and cannot
801
- release. The input value is a comma-separated list of
802
- `name[@<bump|version>]` entries; each entry is a package name optionally
803
- suffixed with `@<patch|minor|major>` (bump the last tag) or an explicit
804
- `@<X.Y.Z>` semver (used verbatim). A bare name defaults to a patch bump.
805
- Only the named packages are planned — no `depends_on` cascade, no
806
- change-detected packages pulled in.
807
-
808
- **Required changes.** None — the input is optional and defaults to
809
- empty, which leaves the normal change-detected release path unchanged.
810
- To get a manual-release button, wire the input to a `workflow_dispatch`
811
- trigger in your caller `release.yml`:
812
-
813
- | Before | After |
814
- |--------|-------|
815
- | <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> |
816
-
817
- The push-triggered run passes an empty `release_packages` (the `inputs`
818
- context is empty outside `workflow_dispatch`), so the normal path keeps
819
- working.
820
-
821
- **Deprecations removed.** None.
822
-
823
- **Behavior changes without code changes.** None. The new behavior is
824
- gated entirely on the new input being non-empty.
825
-
826
- **Verification.** Trigger the workflow from the Actions tab with
827
- `release_packages` set to a known package (e.g. `lib-core@patch`).
828
- Confirm the plan job's matrix contains exactly that package and the
829
- publish job tags and publishes it at the expected version.
830
-
831
- ### pypi multi-version wheels
832
-
833
- **Summary.** `kind = "pypi"` packages now build a wheel for every
834
- CPython version they support, instead of a single wheel for the
835
- `python_version` workflow input. The version set is resolved per
836
- package: an explicit `python_versions` array in `putitoutthere.toml`
837
- wins; otherwise it is inferred from `[project].requires-python` in the
838
- package's `pyproject.toml`; otherwise a single default (`3.12`) is
839
- used. The build matrix fans `maturin` per-target wheel rows across the
840
- resolved set. This closes the incomplete-coverage bug where a package
841
- declaring `requires-python = ">=3.10"` shipped a cp312-only wheel and
842
- failed to install on every other interpreter.
843
-
844
- **Required changes.** None for the default path — `requires-python`
845
- inference is automatic. Optionally pin a subset:
846
-
847
- | Before | After |
848
- |--------|-------|
849
- | _(no knob; one wheel at `python_version`)_ | `python_versions = ["3.12", "3.13"]` under a `[[package]]` with `kind = "pypi"` |
850
-
851
- Consumers whose caller-side `pypi-publish` job collects wheels from the
852
- downloaded artifacts directory need no change as long as it globs all
853
- wheel artifacts (the recommended recipe already does); multi-version
854
- `maturin` wheel artifacts are now named
855
- `<pkg>-wheel-<triple>-py<ver>` rather than `<pkg>-wheel-<triple>`. A
856
- single planned version keeps the unsuffixed name.
857
-
858
- **Deprecations removed.** None. The `python_version` input on
859
- `release.yml`, `build.yml`, and `_matrix.yml` is now deprecated — it no
860
- longer affects pypi builds — but is retained so existing callers do not
861
- break. Remove it from your `with:` block at leisure.
862
-
863
- **Behavior changes without code changes.** A pypi package whose
864
- `requires-python` spans multiple versions now produces multiple wheels
865
- where it previously produced one. The `python_version` workflow input
866
- is inert for pypi builds.
867
-
868
- **Verification.** Push a release for a `kind = "pypi"` package whose
869
- `pyproject.toml` declares `requires-python = ">=3.11"`; the build job
870
- fans into one wheel row per released version (for example `3.11`,
871
- `3.12`, `3.13`, `3.14`), and the
872
- published PyPI release carries a wheel for each.
873
-
874
- ### pypi bundle_cli binary embeds the release version
875
-
876
- **Summary.** The reusable workflow's pypi `[package.bundle_cli]` path
877
- already rewrote the maturin package's version source before building
878
- wheels, but the cross-compiled CLI binary comes from the separate crate
879
- at `bundle_cli.crate_path`. `cargo build` bakes `CARGO_PKG_VERSION`
880
- from that crate's on-disk `Cargo.toml`, so a wheel could publish as
881
- `0.3.6` while its bundled CLI reported an older literal such as
882
- `0.2.7`. The build matrix now rewrites the bundle_cli crate's
883
- `[package].version` to `matrix.version` immediately before the pypi
884
- bundle_cli `cargo build`, matching the npm bundled-cli fix.
885
-
886
- **Required changes.** None. Consumers who declare `kind = "pypi"`,
887
- `build = "maturin"`, and `[package.bundle_cli]` get the corrected
888
- behavior on their next release run against `@v0`; no
889
- `putitoutthere.toml`, trailer, or consumer-side YAML change is needed.
890
-
891
- **Deprecations removed.** None.
892
-
893
- **Behavior changes without code changes.** Same config, different
894
- artifact: the CLI binary staged into each wheel now reports the
895
- planned release version from `--version` (and any other
896
- `CARGO_PKG_VERSION`-derived output) instead of the stale literal in
897
- the CLI crate manifest.
898
-
899
- **Verification.** Release a pypi maturin package that declares
900
- `[package.bundle_cli]`, install one of the published wheels, and run
901
- the bundled CLI's `--version`: it reports `<version>`, matching the
902
- published wheel metadata.
903
-
904
- ### npm bundled-cli binary embeds the release version
905
-
906
- **Summary.** The reusable workflow's npm `build = "bundled-cli"` path
907
- cross-compiled the bundled CLI from un-rewritten crate source, so the
908
- binary's `CARGO_PKG_VERSION` (what `<bin> --version` prints) was baked
909
- from the literal `[package].version` in the crate's `Cargo.toml`
910
- rather than the planned release version. A `@scope/cli-<triple>@0.3.5`
911
- platform package could ship a binary that reported `0.2.7`. The build
912
- matrix now rewrites the crate's `[package].version` to `matrix.version`
913
- before `cargo build` runs, mirroring the pre-build `write-version`
914
- step the pypi/maturin path already uses.
915
-
916
- **Required changes.** None. The fix lives entirely inside the reusable
917
- workflow's build matrix. Consumers who declare a `kind = "npm"`
918
- `build = "bundled-cli"` package with `[package.bundle_cli]` get the
919
- corrected behavior on their next release run against `@v0`; no
920
- `putitoutthere.toml`, trailer, or consumer-side YAML change is needed.
921
-
922
- **Deprecations removed.** None.
923
-
924
- **Behavior changes without code changes.** Same config, different
925
- artifact: the cross-compiled binary inside each per-platform package
926
- now reports the planned release version from `--version` (and any
927
- other `CARGO_PKG_VERSION`-derived output) instead of the stale literal
928
- on disk in the crate manifest.
929
-
930
- **Verification.** Release a `kind = "npm"` `build = "bundled-cli"`
931
- package, extract one per-platform package
932
- (`@scope/cli-<triple>@<version>`), and run the bundled binary's
933
- `--version`: it reports `<version>`, matching the published package.
934
- ### Bundled-cli staged binary is executable
935
-
936
- **Summary.** For `kind = "npm"` packages with a `build = "bundled-cli"`
937
- entry, the reusable workflow cross-compiles the CLI and stages it into
938
- a per-triple platform package (`@scope/cli-<triple>`). The staged
939
- binary was packed with mode `0644` — no executable bit. npm only sets
940
- the executable bit on `bin` entries; the bundled binary is referenced
941
- via `package.json#main`, so npm never `chmod`s it, and the bit it had
942
- on the build runner is stripped crossing the GitHub Actions artifact
943
- upload/download boundary. At runtime the generated launcher's
944
- `spawnSync` of the resolved binary failed with `EACCES`. The workflow
945
- now `chmod +x`es the staged binary for non-Windows targets before the
946
- platform package is packed/published.
947
-
948
- **Required changes.** None. The fix is internal to the reusable
949
- workflow's npm bundled-cli publish path.
950
-
951
- **Deprecations removed.** None.
952
-
953
- **Behavior changes without code changes.** Per-triple platform
954
- packages published for non-Windows targets now ship the CLI binary
955
- with mode `0755` instead of `0644`. Consumers who already worked
956
- around the bug (a `postinstall` `chmod`, or a launcher that `chmod`s
957
- before `spawnSync`) can drop that workaround; leaving it in place is
958
- harmless.
959
-
960
- **Verification.** Publish a `build = "bundled-cli"` npm family, then
961
- `npm install` it and run the CLI — `npx <bin> --version` succeeds
962
- instead of failing with `spawnSync ... EACCES`. Inspecting the
963
- published platform tarball
964
- (`tar -tvzf` on the `@scope/cli-<triple>` `.tgz`) shows the binary as
965
- `-rwxr-xr-x`.
966
-
967
- ### Pre-merge crate-size check
968
-
969
- **Summary.** `putitoutthere check` gained a check that runs
970
- `cargo package --no-verify` for every `kind = "crates"` package and
971
- fails when the resulting `.crate` is larger than crates.io's 10 MiB
972
- (`10485760`-byte) upload limit. Previously an oversized crate — most
973
- often caused by a tracked symlink dragging a build tree into the
974
- package — surfaced only mid-release as a `413 Payload Too Large` from
975
- `cargo publish`, after the verification build. The new check moves
976
- that failure to PR time, before merge.
977
-
978
- **Required changes.** None. The check is additive and runs
979
- automatically wherever `putitoutthere check` already runs (the
980
- `check.yml` reusable workflow). For the check to actually measure a
981
- crate, a Rust toolchain (`cargo`) must be on `PATH` in that job; when
982
- `cargo` is absent the check degrades to a no-op rather than failing,
983
- so a check job without Rust set up sees no behavior change.
984
-
985
- **Deprecations removed.** None.
986
-
987
- **Behavior changes without code changes.** A PR that would produce an
988
- oversized `.crate` now fails `putitoutthere check` with the new
989
- `PIOT_CRATES_PACKAGE_TOO_LARGE` error code, instead of passing the
990
- check and failing later inside the release run's publish job.
991
-
992
- **Verification.** Add a `kind = "crates"` package and run
993
- `putitoutthere check` (or open a PR against a repo wired to
994
- `check.yml`) in an environment with `cargo` on `PATH`: an oversized
995
- crate reports `PIOT_CRATES_PACKAGE_TOO_LARGE` naming the `.crate`
996
- size and the 10 MiB limit, while a normally-sized crate reports
997
- nothing.
998
-
999
- ### v0 tracks main HEAD
1000
-
1001
- **Summary.** Until this release, the floating `v0` tag advanced only
1002
- when a `release:` trailer fired the dogfood publish pipeline
1003
- (`release-npm.yml`), which then moved `v0` to the latest
1004
- `putitoutthere-v0.x.y` release commit. Commits that landed on main
1005
- without a trailer — test-only changes, docs edits, dependency bumps,
1006
- internal refactors, and one-off bug fixes whose author forgot the
1007
- trailer — left `v0` stale relative to main. The behavior was
1008
- explicitly chosen in issue #199 (`v0` = "latest released commit in
1009
- major line") and is now explicitly reversed: `v0` tracks main HEAD,
1010
- not the latest release.
1011
-
1012
- A new workflow `.github/workflows/advance-v0.yml` fires on every
1013
- push to main, builds the action bundle, folds it into a tag-only
1014
- commit (mirroring `release-npm.yml`'s existing Fold step —
1015
- `dist-action/` is gitignored on main, so `v0` must point at a
1016
- synthesized bundle commit for `uses:
1017
- thekevinscott/putitoutthere@v0` to resolve to a runnable action),
1018
- and force-moves `v0` to that commit. The new workflow shares the
1019
- `release` concurrency group with `release-npm.yml`, so when both
1020
- fire on the same push (a trailer-bearing commit), the registry
1021
- publish runs first and `v0` is then advanced on top.
1022
-
1023
- The permanent per-release tags (`putitoutthere-v0.x.y`) are
1024
- unchanged — they're cut by the dogfood publish pipeline on
1025
- trailer-fire and remain the canonical version history.
1026
-
1027
- **Required changes.** None on the consumer side. The change is in
1028
- how `@v0` resolves over time, not in what the workflow at that ref
1029
- does.
1030
-
1031
- **Deprecations removed.** None.
1032
-
1033
- **Behavior changes without code changes.** A commit that lands on
1034
- `main` of `thekevinscott/putitoutthere` is, on the next consumer
1035
- workflow resolve, the workflow code the consumer runs. Previously
1036
- consumers had to wait for a release to be cut to pick up engine
1037
- changes; now they pick them up on the next push to main. Consumers
1038
- who want pinning to a known-released version use a
1039
- `putitoutthere-v0.x.y` tag (or a SHA) instead of `@v0`.
1040
-
1041
- **Verification.** After this change merges and the first push to
1042
- main fires `advance-v0.yml`, the `v0` tag points at a fresh bundle
1043
- commit whose parent is the merge commit on main. Confirm with:
1044
-
1045
- ```
1046
- $ git ls-remote --tags https://github.com/thekevinscott/putitoutthere.git v0
1047
- <sha> refs/tags/v0
1048
- $ git log <sha> -1 --format='%H %s'
1049
- <sha> chore(v0): bundle action
1050
- $ git log <sha>^ -1 --format='%H %s' # parent is the merge commit on main
1051
- <parent-sha> <merge commit subject>
1052
- ```
1053
-
1054
- ### Preflight: manifest repository URL must match GITHUB_REPOSITORY; private repos rejected
1055
-
1056
- **Summary.** Two new preflight checks address the
1057
- "surprise-at-publish" failure mode where a manifest's declared
1058
- `repository` URL silently disagrees with the GitHub repository the
1059
- workflow is actually running from. npm's provenance verification
1060
- returns a 422 (`"package.json: repository.url is X, expected to
1061
- match Y from provenance"`) **after** the artifact has been uploaded
1062
- and the registry has done OIDC negotiation — the kind of mid-publish
1063
- surprise this engine's "no release surprises" design commitment
1064
- exists to prevent. The same risk lives on the crates.io / PyPI
1065
- trusted-publisher paths against `Cargo.toml [package].repository`
1066
- and `pyproject.toml [project.urls]`. Both checks now fire at the
1067
- preflight stage before any side effects.
1068
-
1069
- A second new check refuses to publish from a **private** GitHub
1070
- repository entirely. Provenance attestations embed a public
1071
- source-ref pointer that consumers cannot dereference when the repo
1072
- is private; the same source-visibility expectation underpins the
1073
- trusted-publisher story across all three registries. Hard-failing
1074
- at preflight beats silently shipping a verification-broken artifact.
1075
-
1076
- **Required changes.** None for any consumer whose manifest URLs
1077
- already point at the correct `owner/repo` on GitHub and whose
1078
- repository is public. The check is opt-out only by fixing the
1079
- underlying disagreement.
1080
-
1081
- | Failure mode | Fix |
1082
- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
1083
- | `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. |
1084
- | `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. |
1085
- | `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. |
1086
-
1087
- **Deprecations removed.** None.
1088
-
1089
- **Behavior changes without code changes.** None — both checks are
1090
- new gates.
1091
-
1092
- **Verification.** From a PR branch with a deliberately-wrong
1093
- `repository.url`, the PR-time `check.yml` job now reports
1094
- `[PIOT_REPO_URL_MISMATCH]` naming both the declared and expected
1095
- `owner/repo` slugs and the manifest path. From a private repo,
1096
- `publish` aborts before any side effect with `[PIOT_REPO_PRIVATE]`.
1097
- Both checks no-op outside a GHA context (when `GITHUB_REPOSITORY`
1098
- is unset), so a local `putitoutthere check` from a developer
1099
- machine does not false-positive.
1100
-
1101
- ### Windows default runner pinned to windows-2022
1102
-
1103
- **Summary.** GitHub is migrating `windows-latest` (and `windows-2025`)
1104
- to Visual Studio 2026 between 2026-06-08 and 2026-06-15 — see the
1105
- [GitHub Actions image-migration changelog](https://github.blog/changelog/2026-05-14-github-actions-upcoming-image-migrations/)
1106
- and [actions/runner-images#14016](https://github.com/actions/runner-images/issues/14016).
1107
- Until that date, `windows-latest` is Windows Server 2025 + VS2022; on
1108
- the cutover, `windows-latest` redirects to `windows-2025-vs2026` and
1109
- every consumer release run lands on a fresh toolchain with no
1110
- opportunity to verify it first.
1111
-
1112
- `defaultRunsOn` in `src/plan.ts` previously returned `windows-latest`
1113
- for any Windows-shaped triple, so every consumer release plan that
1114
- included `x86_64-pc-windows-msvc` (the standard windows triple for
1115
- napi, bundled-cli, and maturin builds) inherited that floating label.
1116
- The default is now `windows-2022`: stable, VS2022, no surprise
1117
- migration. Consumers who want to track the floating label or adopt
1118
- VS2026 early opt in via the per-target `{ triple, runner }` override
1119
- that already exists.
1120
-
1121
- **Required changes.** None for consumers who want to stay on a stable
1122
- VS2022 toolchain — the new default does that for them. Consumers who
1123
- want a different image opt in per target:
1124
-
1125
- Before (relied on the floating `windows-latest` default):
1126
-
1127
- ```toml
1128
- [[package]]
1129
- name = "lib-napi"
1130
- kind = "npm"
1131
- build = "napi"
1132
- targets = [
1133
- "x86_64-unknown-linux-gnu",
1134
- "x86_64-pc-windows-msvc",
1135
- ]
1136
- ```
1137
-
1138
- After (no change required — `x86_64-pc-windows-msvc` now resolves to
1139
- `windows-2022` by default):
1140
-
1141
- ```toml
1142
- [[package]]
1143
- name = "lib-napi"
1144
- kind = "npm"
1145
- build = "napi"
1146
- targets = [
1147
- "x86_64-unknown-linux-gnu",
1148
- "x86_64-pc-windows-msvc",
1149
- ]
1150
- ```
1151
-
1152
- After (opt in to a different image — for example, surface VS2026
1153
- breakage now rather than on the cutover date):
1154
-
1155
- ```toml
1156
- targets = [
1157
- "x86_64-unknown-linux-gnu",
1158
- { triple = "x86_64-pc-windows-msvc", runner = "windows-2025-vs2026" },
1159
- ]
1160
- ```
1161
-
1162
- Other valid choices for the `runner` value include `windows-2025`
1163
- (Server 2025 + VS2022 until the cutover, then VS2026), `windows-2022`
1164
- (matches the new default explicitly), and `windows-latest` (preserves
1165
- the previous floating-label behavior).
1166
-
1167
- **Deprecations removed.** None.
1168
-
1169
- **Behavior changes without code changes.** Every Windows-shaped
1170
- matrix row's `runs_on` field now resolves to `windows-2022` instead
1171
- of `windows-latest` when no per-target `runner` override is set.
1172
- Per-target overrides win exactly as before — the bare-string-vs-object
1173
- precedence in `defaultRunsOn` is unchanged. The redirect notice
1174
- GitHub injects into every `windows-latest`-targeted run
1175
- (`NOTICE: windows-latest requests are being redirected to
1176
- windows-2025-vs2026 by June 15, 2026`) stops appearing on plans
1177
- generated by the new engine.
1178
-
1179
- **Verification.** Run a release that includes any Windows triple and
1180
- confirm the build job runs on `windows-2022`:
1181
-
1182
- - In the GitHub Actions UI, the per-target build job's "Set up job"
1183
- step lists `Runner: GitHub Actions <n>` followed by an OS-image
1184
- block whose `Image: windows-2022` line names the pinned image. The
1185
- banner `windows-latest requests are being redirected to
1186
- windows-2025-vs2026 by June 15, 2026` no longer appears in the
1187
- job log.
1188
- - The published artifact's `runs_on` value, surfaced in the plan
1189
- step's job summary, is `windows-2022`.
1190
-
1191
- To opt in to a different image, set
1192
- `{ triple = "x86_64-pc-windows-msvc", runner = "<choice>" }` on the
1193
- relevant target and re-release; the build job moves to the named
1194
- image on the next run.
1195
-
1196
- ### Crates first-publish TP rejection detected
1197
-
1198
- **Summary.** crates.io's Trusted Publishing feature binds to an
1199
- already-published crate name. The very first publish of a brand-new
1200
- crate cannot use the TP path — the OIDC mint succeeds, the exchanged
1201
- token reaches cargo, but the registry rejects the publish with a 404
1202
- ("crate `<name>` does not exist or you do not have permission to
1203
- publish to it"). The engine previously surfaced this as a generic
1204
- `cargo publish failed` block, sending consumers down a credentials
1205
- rabbit-hole when the real fix is one bootstrap publish via the
1206
- classic-token fallback shipped in #283.
1207
-
1208
- The crates handler now detects this exact response shape and throws
1209
- with the new stable error code
1210
- `PIOT_CRATES_FIRST_PUBLISH_TP_REJECTED`, prefixed onto a message
1211
- that names the crate, explains the TP-binds-to-published-crate
1212
- constraint, and points at `CARGO_REGISTRY_TOKEN` as the bootstrap
1213
- path. Cargo's full stderr is preserved at the bottom of the error
1214
- for debuggability. Companion work landed a registry-auth response
1215
- fixtures catalogue at
1216
- [`notes/upstream-behaviors.md`](./notes/upstream-behaviors.md) that
1217
- indexes this and three other response shapes the engine handles or
1218
- architecturally avoids — see #296.
1219
-
1220
- **Required changes.** None. Consumers who never hit the
1221
- first-publish path see no change. Consumers whose first release
1222
- fails on a brand-new crate now see a clearer error pointing at the
1223
- fix; the fix itself (set `CARGO_REGISTRY_TOKEN` as a workflow
1224
- secret for one publish, then remove it) has been available since
1225
- #283 and is unchanged.
1226
-
1227
- **Deprecations removed.** None.
1228
-
1229
- **Behavior changes without code changes.** A `cargo publish`
1230
- failure whose stderr matches the first-publish-TP-rejection shape
1231
- now throws with `PIOT_CRATES_FIRST_PUBLISH_TP_REJECTED` instead of
1232
- the generic `cargo publish failed` shape. The full cargo stderr
1233
- remains in the error message. The detector is suppressed under the
1234
- `PIOT_CRATES_REGISTRY_PRIMARY` e2e seam (alt-registry doesn't model
1235
- TP, so a 404 there is a different bug).
1236
-
1237
- **Verification.** On a brand-new crate name where Trusted
1238
- Publishing is the only auth configured, run the release. The
1239
- release run fails with a message starting
1240
- `[PIOT_CRATES_FIRST_PUBLISH_TP_REJECTED] cargo publish: crates.io
1241
- rejected publishing "<name>" because the crate has never been
1242
- published.` followed by the bootstrap hint. Set
1243
- `CARGO_REGISTRY_TOKEN` in the workflow's `secrets:` block (per
1244
- #283), re-run, and the publish should succeed. Subsequent releases
1245
- can drop the secret and rely on Trusted Publishing.
1246
-
1247
- ### Bundled-CLI launcher generated by the workflow
1248
-
1249
- **Summary.** Bundled-CLI npm consumers used to author `bin/<bin>.js` —
1250
- a Node launcher that detects the host platform, maps it to a triple,
1251
- resolves the corresponding `<name>-<triple>` (or templated) platform
1252
- package, and execs the binary. The launcher's only per-consumer
1253
- inputs are the package name and the configured `targets` list. Both
1254
- are in the engine's hands at plan time. Every consumer's launcher
1255
- was byte-identical modulo those two values.
1256
-
1257
- `_matrix.yml`'s build job now invokes a new internal `putitoutthere
1258
- write-launcher` CLI subcommand on the main row of each `kind = "npm"
1259
- && build = "bundled-cli"` package (before `npm run build --if-present`
1260
- runs). The subcommand writes `bin/<bundle_cli.bin>.js` and adds the
1261
- matching `package.json#bin` entry in place. Both writes are guarded
1262
- by an "only if absent" check — existing consumer-authored launchers
1263
- and existing `bin` fields are preserved, so the override path is the
1264
- same file you'd already have committed.
1265
-
1266
- The generated launcher's shape mirrors the README example bundled-cli
1267
- consumers wrote by hand pre-#299: hashbang, a Node
1268
- `${platform}-${arch}` → triple table, `require.resolve` against the
1269
- platform package, `spawnSync` with `stdio: 'inherit'`. The platform
1270
- package's name template (`{name}-{triple}` by default, or whatever the
1271
- consumer set under `build = [{ mode = "bundled-cli", name = "..." }]`
1272
- in the multi-mode array form) has every placeholder except `{triple}`
1273
- resolved at generation time; `{triple}` becomes a backtick template
1274
- substitution at install time. The launcher imports nothing from
1275
- putitoutthere at runtime — it's a self-contained Node script with no
1276
- published-package dependencies.
1277
-
1278
- Together with #298 (which absorbed the cross-compile build script),
1279
- bundled-cli npm's consumer surface is now: declare the package in
1280
- `putitoutthere.toml`, register Trusted Publishers, push.
1281
-
1282
- **Required changes.** None for new consumers — declaring the package
1283
- in `putitoutthere.toml` is sufficient.
1284
-
1285
- For consumers who already shipped a hand-authored launcher and want
1286
- to migrate to the generated one, delete `bin/<bin>.js` from the source
1287
- tree. The build job will regenerate it on the next release run.
1288
- Leaving the file in place is fully supported; the workflow only
1289
- writes when the file is absent.
1290
-
1291
- ```diff
1292
- // packages/my-cli/package.json
1293
- {
1294
- "name": "my-cli",
1295
- - "bin": { "my-cli": "bin/my-cli.js" }
1296
- }
1297
- ```
1298
-
1299
- Removing the `bin` field is optional too: when the workflow sees an
1300
- existing `bin` field it leaves it alone. The diff above is only
1301
- necessary if the consumer wants the workflow to author the field
1302
- shape from scratch (`{ "<bin>": "bin/<bin>.js" }`).
1303
-
1304
- **Deprecations removed.** None — the legacy hand-authored launcher
1305
- path is still supported and the workflow respects the override.
1306
-
1307
- **Behavior changes without code changes.**
1308
-
1309
- - The published main package's `package.json#bin` now contains
1310
- `{ "<bundle_cli.bin>": "bin/<bundle_cli.bin>.js" }` for consumers
1311
- who previously had no `bin` field. Consumers with a pre-existing
1312
- `bin` field see no change.
1313
- - The published main package's tarball now contains
1314
- `bin/<bundle_cli.bin>.js` for consumers who previously did not
1315
- commit the file. Consumers who committed the file see their version
1316
- shipped unchanged.
1317
-
1318
- **Verification.** After a release run, inspect the published main
1319
- package's tarball:
1320
-
1321
- ```sh
1322
- npm pack <main-pkg-name>@<version>
1323
- tar -xvf <main-pkg-name>-<version>.tgz package/bin/<bundle_cli.bin>.js -O \
1324
- | head -20
1325
- ```
1326
-
1327
- The first line is `#!/usr/bin/env node`; the file declares a `triples`
1328
- object whose keys match the Node `${platform}-${arch}` strings the
1329
- package's `targets` resolve to, and whose values are the configured
1330
- triples. `package/package.json`'s `bin` field is
1331
- `{ "<bundle_cli.bin>": "bin/<bundle_cli.bin>.js" }`.
1332
-
1333
- ### `bundle_cli` wheel guard respects `python-source`
1334
-
1335
- **Summary.** Maturin's standard mixed-project layout
1336
- (`maturin new --mixed` generates `[tool.maturin].python-source = "python"`)
1337
- declares a package source root that maturin strips from on-disk paths
1338
- when rewriting them into the wheel's distribution layout. A binary
1339
- staged on disk at `<pkg.path>/<stage_to>/<bin>` — e.g.
1340
- `packages/python/python/dirsql/_binary/dirsql` — ends up in the wheel
1341
- at `dirsql/_binary/dirsql`, with `python/` stripped. The reusable
1342
- workflow's `bundle_cli` wheel-content guard previously asserted a
1343
- literal `<stage_to>/<bin>` suffix inside the produced wheel; the regex
1344
- never matched the stripped path, so the guard fired red on every
1345
- per-target build row even when the binary was correctly bundled.
1346
-
1347
- The guard now reads `[tool.maturin].python-source` (and the legacy
1348
- `python_source` spelling — both forms are accepted by maturin across
1349
- versions) from `<matrix.path>/pyproject.toml` and subtracts that
1350
- prefix from `stage_to` before constructing the suffix regex. Consumers
1351
- with an implicit-root layout (no `python-source` key, or an empty
1352
- value) keep the previous behavior byte-for-byte; consumers with the
1353
- explicit-root layout start passing the guard. Tracked at #338.
1354
-
1355
- **Required changes.** None.
1356
-
1357
- **Deprecations removed.** None.
1358
-
1359
- **Behavior changes without code changes.**
1360
-
1361
- - For a consumer with `[tool.maturin].python-source = "python"` and
1362
- `[package.bundle_cli].stage_to = "python/dirsql/_binary"`, the
1363
- reusable workflow's wheel-content guard now resolves the in-wheel
1364
- suffix to `dirsql/_binary/<bin>` (matching what maturin actually
1365
- produces) instead of asserting the unstripped `python/dirsql/_binary/<bin>`.
1366
- - A `python-source` value that isn't actually a prefix of `stage_to`
1367
- is left alone — the guard reverts to asserting the unstripped
1368
- `stage_to` so the consumer's misconfiguration surfaces with the same
1369
- diagnostic it does today.
1370
- - An unset or empty `python-source` value resolves to the empty
1371
- string and `stage_suffix` is unchanged. No behavior change for
1372
- consumers who don't use the explicit-root layout.
1373
-
1374
- **Verification.** With a maturin package whose `pyproject.toml`
1375
- declares `[tool.maturin].python-source = "python"` and whose
1376
- `[package.bundle_cli]` sets `stage_to = "python/<pkg>/_binary"`,
1377
- a release run should produce wheels whose `unzip -l` listing contains
1378
- `<pkg>/_binary/<bin>` and the wheel-content guard step should log
1379
- `ok bundle_cli: <pkg>/_binary/<bin> present in <wheel>`.
1380
-
1381
- ### `bundle_cli` cargo workspace
1382
-
1383
- **Summary.** Two collided bugs made `[package.bundle_cli]`
1384
- unsatisfiable for the standard cargo-workspace layout: a single
1385
- workspace root `Cargo.toml` with `[workspace] members = [...]` and the
1386
- `[[bin]]` declared in a member crate (the shape `cargo new --workspace`
1387
- produces, and what the polyglot Rust/Python recipe in the README
1388
- implies). With `crate_path = "."` (the default), `putitoutthere check`
1389
- parsed the workspace root `Cargo.toml` literally, saw no `[[bin]]`, and
1390
- emitted `bundle_cli.bin "X" is not declared as a [[bin]]`. With
1391
- `crate_path = "packages/rust"`, the check passed but the reusable
1392
- workflow's bundle_cli stage step couldn't find the produced binary —
1393
- cargo writes to the workspace-rooted target dir by default
1394
- (`<repo-root>/target/...`), not to the working-directory-rooted one
1395
- (`packages/rust/target/...`) the stage step assumed. There was no
1396
- `crate_path` value that satisfied both halves.
1397
-
1398
- The check now walks `[workspace].members` and aggregates each member's
1399
- declared bins (honoring the implicit-binary rule, including
1400
- `[package].name = { workspace = true }` inheritance from
1401
- `[workspace.package].name`). The reusable workflow's cargo build step
1402
- pins `--target-dir target` so the produced binary is deterministically
1403
- at `${{ matrix.bundle_cli.crate_path }}/target/<triple>/release/<bin>`
1404
- regardless of whether the crate participates in a workspace. Tracked at
1405
- #337.
1406
-
1407
- **Required changes.** None.
1408
-
1409
- Consumers whose `[package.bundle_cli]` block already worked (single-
1410
- crate layouts, or workspaces where `crate_path` pointed directly at the
1411
- member crate and the consumer's project structure happened to make the
1412
- workspace target dir line up with the member target dir) keep building
1413
- byte-identically. Consumers whose workspace layout previously failed
1414
- the check or stage step start working without touching their config.
1415
-
1416
- **Deprecations removed.** None.
1417
-
1418
- **Behavior changes without code changes.**
1419
-
1420
- - `putitoutthere check` accepts the cargo-workspace shape:
1421
- ```toml
1422
- # /Cargo.toml
1423
- [workspace]
1424
- members = ["packages/rust"]
1425
-
1426
- # /packages/rust/Cargo.toml
1427
- [package]
1428
- name = "my-cli"
1429
- description = "..."
1430
- license = "MIT"
1431
-
1432
- [[bin]]
1433
- name = "my-cli"
1434
- path = "src/main.rs"
1435
-
1436
- # /putitoutthere.toml
1437
- [package.bundle_cli]
1438
- bin = "my-cli"
1439
- stage_to = "python/dirsql/_binary"
1440
- # crate_path defaults to "."
1441
- ```
1442
- previously reported `bundle_cli.bin "my-cli" is not declared as a [[bin]]`,
1443
- now reports zero findings.
1444
- - The reusable workflow's bundle_cli build step now passes
1445
- `--target-dir target` to `cargo build`. The produced binary is at
1446
- `${{ matrix.bundle_cli.crate_path }}/target/<triple>/release/<bin>`
1447
- regardless of workspace membership, and the stage step's `src=` path
1448
- resolves correctly by construction.
1449
- - Members declared as glob patterns (`members = ["packages/*"]`) are
1450
- expanded against the filesystem by the check — see
1451
- [`bundle_cli` glob workspace members](#bundle_cli-glob-workspace-members).
1452
-
1453
- **Verification.** With the workspace layout above, the consumer should
1454
- see:
1455
-
1456
- - `putitoutthere check` reports zero findings.
1457
- - A maturin release run produces a wheel whose `unzip -l` includes the
1458
- staged binary (the existing wheel-content guard asserts this).
1459
-
1460
- ### `bundle_cli` glob workspace members
1461
-
1462
- **Summary.** The `bundle_cli` cargo-workspace fix above taught
1463
- `putitoutthere check` (and the pre-publish preflight) to walk
1464
- `[workspace].members` and aggregate each member crate's declared
1465
- `[[bin]]` entries, so `crate_path = "."` resolves a `[[bin]]` that
1466
- lives in a member crate. That walk only handled *literal* member
1467
- entries. cargo `members` entries are globs, and `members =
1468
- ["packages/*"]` — a Rust core crate under `packages/rust` wrapped by
1469
- sibling Python / npm packages — is the standard polyglot-repo shape. A
1470
- glob entry never resolved to a literal `<member>/Cargo.toml`, so the
1471
- member crate's `[[bin]]` went unseen and `crate_path = "."` was
1472
- rejected with `bundle_cli.bin "X" is not declared as a [[bin]]`.
1473
-
1474
- The check now expands `[workspace].members` glob entries against the
1475
- filesystem the way cargo resolves them; a member crate behind a glob is
1476
- found like any literal member.
1477
-
1478
- **Required changes.** None.
1479
-
1480
- Consumers whose workspace declares `members` with literal paths are
1481
- unaffected. Consumers who declared `members` with a glob and worked
1482
- around the rejected check — by also listing the member crate as a
1483
- literal entry, or by pointing `crate_path` straight at the member
1484
- crate — can drop the workaround and let `crate_path` default to `"."`.
1485
-
1486
- **Deprecations removed.** None.
1487
-
1488
- **Behavior changes without code changes.**
1489
-
1490
- - `putitoutthere check` accepts a glob-member workspace:
1491
- ```toml
1492
- # /Cargo.toml
1493
- [workspace]
1494
- members = ["packages/*"]
1495
-
1496
- # /packages/rust/Cargo.toml
1497
- [package]
1498
- name = "rust-core"
1499
-
1500
- [[bin]]
1501
- name = "my-cli"
1502
- path = "src/main.rs"
1503
-
1504
- # /putitoutthere.toml
1505
- [package.bundle_cli]
1506
- bin = "my-cli"
1507
- stage_to = "python/dirsql/_binary"
1508
- # crate_path defaults to "."
1509
- ```
1510
- previously reported `bundle_cli.bin "my-cli" is not declared as a [[bin]]`,
1511
- now reports zero findings.
1512
-
1513
- **Verification.** With the glob-member workspace above, `putitoutthere
1514
- check` reports zero findings.
1515
-
1516
- ### Crates metadata check resolves `[workspace.package]` inheritance
1517
-
1518
- **Summary.** Cargo's recommended pattern for shared crate metadata in a
1519
- workspace is `[workspace.package]` in the workspace root combined with
1520
- `<field>.workspace = true` on each member. `cargo publish` resolves the
1521
- inheritance and embeds the literal value into `Cargo.toml.orig` before
1522
- upload, so crates.io receives the resolved field. The pre-merge
1523
- `check` and the pre-publish `requireCratesMetadata` previously parsed
1524
- each member `Cargo.toml` in isolation and treated the
1525
- `{ workspace: true }` placeholder as a missing string, flagging
1526
- well-formed workspaces with `PIOT_CRATES_MISSING_METADATA` even though
1527
- the eventual `cargo publish` would succeed. The check now walks up from
1528
- each crate's `path` to find the nearest parent `Cargo.toml` carrying a
1529
- `[workspace]` table and, when a member field is declared as
1530
- `<field>.workspace = true`, resolves the value from `[workspace.package]`
1531
- before deciding it's missing. Genuinely-missing inherited fields — the
1532
- workspace root has no value for the key, or no `[workspace.package]`
1533
- block at all — still report through `PIOT_CRATES_MISSING_METADATA`. Hit
1534
- in the wild in `thekevinscott/dirsql#177`. Tracked at #328.
1535
-
1536
- **Required changes.** None.
1537
-
1538
- **Deprecations removed.** None.
1539
-
1540
- **Behavior changes without code changes.**
1541
-
1542
- - Crates packages whose `Cargo.toml` reads
1543
- ```toml
1544
- [package]
1545
- name = "foo"
1546
- description.workspace = true
1547
- license.workspace = true
1548
- ```
1549
- with the workspace root supplying
1550
- ```toml
1551
- [workspace.package]
1552
- description = "..."
1553
- license = "MIT"
1554
- ```
1555
- no longer surface as `PIOT_CRATES_MISSING_METADATA` findings from
1556
- `putitoutthere check` or as `requireCratesMetadata` errors from the
1557
- publish path. `license-file.workspace = true` resolves the same way.
1558
- - Crates that inherit a field whose workspace root omits it (or has no
1559
- `[workspace.package]` block) continue to surface as
1560
- `PIOT_CRATES_MISSING_METADATA` — the publish would still fail at
1561
- crates.io's metadata gate, so the preflight keeps flagging it.
1562
- - Inline (non-inherited) `description` / `license` / `license-file`
1563
- fields are unchanged.
1564
-
1565
- **Verification.** Inside a workspace that centralizes metadata:
1566
-
1567
- ```toml
1568
- # Cargo.toml
1569
- [workspace]
1570
- members = ["packages/rust"]
1571
-
1572
- [workspace.package]
1573
- license = "MIT"
1574
- description = "Shared description."
1575
-
1576
- # packages/rust/Cargo.toml
1577
- [package]
1578
- name = "foo"
1579
- description.workspace = true
1580
- license.workspace = true
1581
- ```
1582
-
1583
- `putitoutthere check` should report `0 findings` for `foo`'s metadata,
1584
- and `cargo metadata --no-deps --format-version=1 --manifest-path packages/rust/Cargo.toml`
1585
- should show the resolved `"license":"MIT"` / `"description":"..."`.
1586
-
1587
- ### Hatch wheel-any row
1588
-
1589
- **Summary.** `kind = "pypi"` + `build = "hatch"` now publishes a wheel
1590
- alongside the sdist. Previously the matrix carried only a
1591
- `target = "sdist"` row, so PyPI ended up with sdist-only and downstream
1592
- `pip install` / `uvx ...` had to provision hatchling and run
1593
- `python -m build` on a cold cache — several seconds per invocation
1594
- instead of a sub-second download-and-extract. `pypa/build`'s default on
1595
- a pure-Python tree produces both an sdist and an any-platform wheel; the
1596
- planner just wasn't asking for the wheel. Issue #324.
1597
-
1598
- The matrix now emits a second row per hatch package:
1599
-
1600
- | Field | Value |
1601
- |-----------------|--------------------------------------|
1602
- | `target` | `any` |
1603
- | `artifact_name` | `<package-name>-wheel-any` |
1604
- | `artifact_path` | `<package-path>/dist` |
1605
- | `runs_on` | `ubuntu-latest` |
1606
- | `build` | `hatch` |
1607
-
1608
- The reusable workflow's build step gates on
1609
- `matrix.kind == 'pypi' && matrix.build == 'hatch' && matrix.target == 'any'`
1610
- and runs `python -m build --wheel --outdir dist` (with
1611
- `SETUPTOOLS_SCM_PRETEND_VERSION` set, mirroring the sdist row's contract).
1612
-
1613
- **Required changes.** None. The recommended consumer-side recipe in
1614
- [README → Quickstart](./README.md#1-drop-in-githubworkflowsreleaseyml)
1615
- already uses `actions/download-artifact@v8` with
1616
- `pattern: '*-wheel-*'` and `pattern: '*-sdist'` and feeds the combined
1617
- `dist/` to `pypa/gh-action-pypi-publish@release/v1`. Both patterns now
1618
- match for hatch packages without any consumer-side YAML change.
1619
-
1620
- If you have a hand-rolled `pypi-publish` job that consumes specific
1621
- artifact names, add `<package-name>-wheel-any` to its download list.
1622
-
1623
- **Deprecations removed.** None.
1624
-
1625
- **Behavior changes without code changes.** Hatch packages whose previous
1626
- release shipped sdist-only will, on the first release after upgrading,
1627
- also publish an any-platform wheel to PyPI under the same version. No
1628
- new tag is created and no extra publish-time orchestration is needed —
1629
- the wheel is uploaded alongside the sdist in the existing
1630
- `pypa/gh-action-pypi-publish` step.
1631
-
1632
- Scope is `build = "hatch"` only. `build = "setuptools"` stays
1633
- sdist-only and `build = "maturin"` keeps its per-target wheel rows
1634
- (both unchanged).
1635
-
1636
- **Verification.** After publishing a hatch package:
1637
-
1638
- ```
1639
- curl -s https://pypi.org/pypi/<name>/json | jq '.urls[].packagetype'
1640
- # "sdist"
1641
- # "bdist_wheel" ← previously absent
1642
- ```
1643
-
1644
- `pip install <name>` (or `uvx <name>`) should download `*.whl` and
1645
- skip the local build step entirely.
1646
-
1647
- ---
1648
-
1649
- ### pypi `pyproject.toml` must declare `dynamic = ["version"]`
1650
-
1651
- **Summary.** Every `kind = "pypi"` package's `pyproject.toml` must
1652
- now declare `[project].dynamic = ["version"]`. Static
1653
- `[project].version = "x.y.z"` literals are rejected at PR time by
1654
- `putitoutthere check` and again at publish-time preflight, both
1655
- under the stable error code `PIOT_PYPI_STATIC_VERSION`. This is
1656
- the most common Python-publishing footgun: putitoutthere does not
1657
- edit `pyproject.toml` at release time (per the [no version
1658
- computation](./notes/design-commitments.md#non-goals) design
1659
- commitment), so a literal silently shipped the previous release's
1660
- wheel/sdist because the build backend read whatever was on disk.
1661
- Making the dynamic shape mandatory closes the failure mode at the
1662
- earliest knowable boundary — the consumer's own repo state, before
1663
- a release run is ever invoked.
1664
-
1665
- **Required changes.**
1666
-
1667
- Before — a static literal, accepted in v0.2.x:
1668
-
1669
- ```toml
1670
- [project]
1671
- name = "your-package"
1672
- version = "0.1.0"
1673
- ```
1674
-
1675
- After — `hatch-vcs` (recommended for new packages):
1676
-
1677
- ```toml
1678
- [build-system]
1679
- requires = ["hatchling", "hatch-vcs"]
1680
- build-backend = "hatchling.build"
1681
-
1682
- [project]
1683
- name = "your-package"
1684
- dynamic = ["version"]
1685
-
1686
- [tool.hatch.version]
1687
- source = "vcs"
1688
- ```
1689
-
1690
- After — `setuptools-scm` (for setuptools-backed projects):
1691
-
1692
- ```toml
1693
- [build-system]
1694
- requires = ["setuptools>=64", "setuptools-scm>=8"]
1695
- build-backend = "setuptools.build_meta"
1696
-
1697
- [project]
1698
- name = "your-package"
1699
- dynamic = ["version"]
1700
-
1701
- [tool.setuptools_scm]
1702
- ```
1703
-
1704
- After — `maturin` (Python packages built from a Rust crate): the
1705
- version source moves to the sibling `Cargo.toml`'s
1706
- `[package].version`. `pyproject.toml` declares only that the version
1707
- is dynamic:
1708
-
1709
- ```toml
1710
- [build-system]
1711
- requires = ["maturin>=1"]
1712
- build-backend = "maturin"
1713
-
1714
- [project]
1715
- name = "your-package"
1716
- dynamic = ["version"]
1717
- ```
1718
-
1719
- No reusable-workflow input changes; the existing
1720
- `SETUPTOOLS_SCM_PRETEND_VERSION` injection in the build step already
1721
- hands the planned version to `hatch-vcs` / `setuptools-scm`, and the
1722
- existing `putitoutthere write-version` step already bumps
1723
- `Cargo.toml` for the maturin path.
1724
-
1725
- **Deprecations removed.**
1726
-
1727
- - `pypi.writeVersion` and the `putitoutthere write-version` CLI
1728
- subcommand no longer rewrite static literals in place — both now
1729
- surface `PIOT_PYPI_STATIC_VERSION`. Previously they would
1730
- silently overwrite the literal.
1731
- - The `replacePyProjectVersion` helper export and the
1732
- "bumps BOTH pyproject and sibling Cargo.toml on the static-literal
1733
- path" #276 carve-out are gone. Under the dynamic contract, `Cargo.toml`
1734
- alone is the bump target on the maturin path and the pyproject literal
1735
- has no role.
1736
-
1737
- **Behavior changes without code changes.**
1738
-
1739
- - `putitoutthere check` reports `PIOT_PYPI_STATIC_VERSION` against
1740
- every pypi package whose `pyproject.toml` has a literal
1741
- `[project].version`. Aggregated with the other preflight
1742
- findings — one round-trip, not one error at a time.
1743
- - The publish path runs `requirePypiVersionSource` between the
1744
- existing `requireCratesMetadata` and the artifact-completeness
1745
- check; a misconfigured pypi tree fails fast there even if the
1746
- PR-time `check.yml` gate was skipped.
1747
-
1748
- **Verification.**
1749
-
1750
- ```bash
1751
- # In a repo that still declares a static version:
1752
- $ putitoutthere check
1753
- [PIOT_PYPI_STATIC_VERSION] packages/python/pyproject.toml declares a
1754
- static `[project].version` literal. Use `[project].dynamic =
1755
- ["version"]` ...
1756
-
1757
- # After the migration above:
1758
- $ putitoutthere check
1759
- # (no findings, exits 0)
1760
- ```
1761
-
1762
- Tracked at #333.
1763
-
1764
- ---
1765
-
1766
- ### `npm` `bundle_cli` absorbed into the reusable workflow
1767
-
1768
- **Summary.** `kind = "npm"` packages with `build = "bundled-cli"`
1769
- no longer need to author a `scripts/build.cjs` that performs the
1770
- Rust cross-compile by hand. Declare `[package.bundle_cli]` in
1771
- `putitoutthere.toml` — same schema as the pypi/maturin block from
1772
- #282, minus `stage_to` (npm staging is determined entirely by the
1773
- matrix row's `artifact_path`) — and the reusable workflow runs
1774
- `rustup target add`, `cargo build --release --target <triple>
1775
- --bin <bin>` against `crate_path`, and the copy-into-staging step
1776
- itself. A defense-in-depth build-content guard asserts the staged
1777
- binary exists before `actions/upload-artifact` runs, so a broken
1778
- row never leaves the build runner. Mirror of the pypi wiring landed
1779
- in #282; closes the seam that #287 patched (env-var contract
1780
- between the consumer's build script and the engine).
1781
-
1782
- **Required changes.** None for additive adoption. To migrate an
1783
- existing consumer with a hand-written `scripts/build.cjs`:
1784
-
1785
- Before (`putitoutthere.toml`):
1786
-
1787
- ```toml
1788
- [[package]]
1789
- name = "my-cli"
1790
- kind = "npm"
1791
- build = "bundled-cli"
1792
- path = "packages/ts-cli"
1793
- globs = ["packages/ts-cli/**", "crates/my-cli/**"]
1794
- targets = [
1795
- "x86_64-unknown-linux-gnu",
1796
- "x86_64-apple-darwin",
1797
- # ...
1798
- ]
1799
- ```
1800
-
1801
- `scripts/build.cjs` (consumer-owned):
1802
-
1803
- ```js
1804
- const { execFileSync } = require('node:child_process');
1805
- const { mkdirSync, copyFileSync } = require('node:fs');
1806
- const target = process.env.TARGET;
1807
- if (!target || target === 'main' || target === 'noarch') process.exit(0);
1808
- const binName = 'my-cli';
1809
- const ext = target.includes('windows') ? '.exe' : '';
1810
- execFileSync('rustup', ['target', 'add', target], { stdio: 'inherit' });
1811
- execFileSync('cargo', ['build', '--release', '--target', target, '--bin', binName],
1812
- { cwd: '../../crates/my-cli', stdio: 'inherit' });
1813
- mkdirSync(`build/${target}`, { recursive: true });
1814
- copyFileSync(`../../crates/my-cli/target/${target}/release/${binName}${ext}`,
1815
- `build/${target}/${binName}${ext}`);
1816
- ```
1817
-
1818
- `package.json` (consumer-owned):
1819
-
1820
- ```json
1821
- { "scripts": { "build": "tsc && node scripts/build.cjs" } }
1822
- ```
1823
-
1824
- After (`putitoutthere.toml`):
1825
-
1826
- ```toml
1827
- [[package]]
1828
- name = "my-cli"
1829
- kind = "npm"
1830
- build = "bundled-cli"
1831
- path = "packages/ts-cli"
1832
- globs = ["packages/ts-cli/**", "crates/my-cli/**"]
1833
- targets = [
1834
- "x86_64-unknown-linux-gnu",
1835
- "x86_64-apple-darwin",
1836
- # ...
1837
- ]
1838
-
1839
- [package.bundle_cli]
1840
- bin = "my-cli"
1841
- crate_path = "crates/my-cli"
1842
- # features = ["cli"] # if the binary is feature-gated
1843
- # no_default_features = false
1844
- ```
1845
-
1846
- `scripts/build.cjs`: **deleted.** The `build` script in
1847
- `package.json` typically becomes `"tsc"` (or whatever your
1848
- TypeScript launcher build was minus the `node scripts/build.cjs`
1849
- half).
1850
-
1851
- **During migration both shapes coexist.** A consumer that still
1852
- ships `scripts/build.cjs` keeps working — the workflow's cargo
1853
- build runs first, then `npm run build --if-present` runs the
1854
- consumer's script (which sees `build/<triple>/` already populated
1855
- and probably no-ops). There's no transitional broken state and
1856
- no flag to set.
1857
-
1858
- **Constraint.** The binary must build with a vanilla
1859
- `cargo build --release --target <triple> --bin <bin>` from
1860
- `crate_path`. Optional `features` / `no_default_features` cover
1861
- the `[[bin]] required-features = ["cli"]` shape. Crates that need
1862
- arbitrary env vars, alternate manifests, Zig-cc cross
1863
- toolchains, or other cargo flags don't fit the recipe and
1864
- should keep their own release workflow.
1865
-
1866
- **Deprecations removed.** None.
1867
-
1868
- **Behavior changes without code changes.** Existing consumers
1869
- without `[package.bundle_cli]` declared are unaffected — the
1870
- workflow's cargo build / stage / guard steps are gated on
1871
- `matrix.bundle_cli` being set, so consumers who still rely on
1872
- their hand-written `scripts/build.cjs` see the byte-identical
1873
- build matrix they saw before. The schema rejects `bundle_cli`
1874
- declared on a non-bundled-cli npm package (or with empty
1875
- `targets`), so a typo can't make the block silently inert.
1876
-
1877
- **Verification.** After upgrading, switch one consumer's
1878
- `putitoutthere.toml` to declare `[package.bundle_cli]` and
1879
- remove the `node scripts/build.cjs` half of their `build` script.
1880
- The next release run's build job will, for each per-target row,
1881
- include three new step lines: `bundle_cli — add Rust target`,
1882
- `bundle_cli — cargo build for <triple> (<bin>)`, and
1883
- `bundle_cli — stage binary into <artifact_path>`, followed by
1884
- the `bundle_cli — verify <artifact_path>/<bin>` guard before
1885
- `Upload artifact`. The published per-platform tarball, when
1886
- downloaded and unpacked, contains the cross-compiled binary at
1887
- the same path the launcher resolves it from.
1888
-
1889
- ---
1890
-
1891
- ### Preflight pyproject + cargo shape
1892
-
1893
- **Summary.** Preflight gains two more checks — `requirePyprojectShape`
1894
- and `requireCargoShape` — that mirror the #280 / #290 pattern for
1895
- `pyproject.toml` (pypi packages) and `Cargo.toml` (crates packages,
1896
- plus `bundle_cli` on pypi packages). The maturin / setuptools /
1897
- hatchling / cargo CLIs surface mismatched-shape errors 10-20 minutes
1898
- into a release run, deep into the verification build, with messages
1899
- that don't name the precondition that failed. The new checks fire at
1900
- publish time alongside the existing `require*` family and at PR time
1901
- via `check.yml`. Findings aggregate across every failing package so
1902
- consumers fix them all in one round-trip, exactly the shape the prior
1903
- checks established. #301.
1904
-
1905
- **Required changes.** None for well-formed manifests. Repos that
1906
- declared one of the documented mismatches below previously got a
1907
- mid-release red; they now get a fast preflight red instead.
1908
-
1909
- The new error codes:
1910
-
1911
- | Code | Fires when |
1912
- |------|------------|
1913
- | `PIOT_PYPI_NAME_MISMATCH` | `pyproject.toml`'s `[project].name` differs from `[[package]].name` (or the `pypi` override). |
1914
- | `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`). |
1915
- | `PIOT_PYPI_DYNAMIC_VERSION_NO_BACKEND` | `[project].dynamic` includes `"version"` but neither `[tool.hatch.version]` nor `[tool.setuptools_scm]` is present. |
1916
- | `PIOT_PYPI_MATURIN_INCLUDE_MISSING` | `bundle_cli` is set but `[tool.maturin].include` does not cover `bundle_cli.stage_to`. |
1917
- | `PIOT_CRATES_NAME_MISMATCH` | `Cargo.toml`'s `[package].name` differs from `[[package]].name` (or the `crate` override). |
1918
- | `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). |
1919
- | `PIOT_CRATES_FEATURE_NOT_DECLARED` | `features` (on `kind = "crates"` packages) or `bundle_cli.features` references a feature not declared in `[features]`. |
1920
- | `PIOT_CRATES_WORKSPACE_VERSION_MISMATCH` | `[package].version.workspace = true` but no ancestor `Cargo.toml` declares `[workspace.package].version`. |
1921
-
1922
- **Deprecations removed.** None.
1923
-
1924
- **Behavior changes without code changes.** Repos with one of the
1925
- shapes above used to get a confusing mid-release error from
1926
- maturin / setuptools / hatchling / cargo (sometimes after a
1927
- verification build of every transitive dep); they now get a
1928
- fingerprintable `PIOT_*` error at preflight time, before any side
1929
- effects. PR-time `check.yml` runs surface the same findings on
1930
- every pull request, so the typical case is fix-before-merge rather
1931
- than fix-after-release-red. The `[build-system].build-backend`
1932
- check is deliberately narrow: a missing `[build-system]` table is
1933
- allowed (pip falls back to setuptools), and the prefix match
1934
- tolerates backend-version drift across maturin / setuptools /
1935
- hatchling.
1936
-
1937
- **Verification.** Misconfigure one field, run `pnpm putitoutthere
1938
- check` (or open a PR with `check.yml` wired), see the relevant
1939
- `PIOT_*` code surface in seconds instead of mid-release.
1940
-
1941
- ### New `check.yml` reusable workflow for PR-time config sanity
1942
-
1943
- **Summary.** `putitoutthere` now ships a third reusable workflow,
1944
- `.github/workflows/check.yml`, that drives the engine's `check`
1945
- subcommand at PR time. The subcommand aggregates every pre-merge
1946
- check (`putitoutthere.toml` parse + schema, common-mistakes
1947
- detector, unique-name guard, `depends_on` cycle / dangling-ref
1948
- detection, `[[package]].path` existence, `globs` matching a
1949
- tracked file, `tag_format` collisions, npm `repository` field,
1950
- crates `description` / `license`, pypi `pyproject.toml` +
1951
- `bundle_cli` binary declaration, npm target triple mapping)
1952
- and reports findings in one round-trip. Where `release.yml` is
1953
- the release-time phase and `build.yml` is the heavier per-target
1954
- build gate, `check.yml` is the cheap config-sanity gate — a few
1955
- seconds per PR, no `setup-python` / `setup-rust`, no per-target
1956
- compile. Shipped per the "no release surprises" goal added in
1957
- #316: anything checkable from the consumer's repo state alone
1958
- surfaces at PR time, not at release time. Issue #317 (workflow
1959
- shell) + issue #319 (check list, shipped via #321).
1960
-
1961
- **Required changes.** Additive. Existing consumers do nothing.
1962
- Recommended: add a one-line PR-CI workflow.
1963
-
1964
- ```yaml
1965
- # .github/workflows/check.yml ← new file in your repo, optional
1966
- name: putitoutthere check
1967
-
1968
- on:
1969
- pull_request: {}
1970
-
1971
- jobs:
1972
- putitoutthere-check:
1973
- uses: thekevinscott/putitoutthere/.github/workflows/check.yml@v0
1974
- ```
1975
-
1976
- The new workflow accepts no `with:` inputs, no `secrets:`, no
1977
- `permissions:` requirements beyond the default `contents: read` it
1978
- sets internally. The integration line is the entire surface.
1979
-
1980
- **Deprecations removed.** None.
1981
-
1982
- **Behavior changes without code changes.** None. Existing release
1983
- runs are unaffected — the new workflow is opt-in PR-time CI; the
1984
- release path is unchanged.
1985
-
1986
- **Verification.** Open a PR that introduces a typo in
1987
- `putitoutthere.toml` (e.g. `[[packages]]` instead of `[[package]]`).
1988
- Without `check.yml` wired, the failure surfaces at release time
1989
- in a red `plan` step. With `check.yml` wired, the PR fails red at
1990
- review time with the same diagnosable error message, before the
1991
- merge.
1992
-
1993
- ### Internal cargo-http-registry alt-registry for crates e2e
1994
-
1995
- **Summary.** Internal change with no consumer-observable impact. Adds
1996
- [`cargo-http-registry`](https://github.com/d-e-s-o/cargo-http-registry)
1997
- — an off-the-shelf, auth-free cargo alt-registry, the lone
1998
- "Verdaccio for cargo" the survey of the cargo-registry ecosystem
1999
- turned up — to `e2e-fixture-job.yml`'s publish job, installed via
2000
- `cargo install --locked` and started as a background process on
2001
- every crates-bearing matrix row. Two internal engine seams in
2002
- `src/handlers/crates.ts` consume it: `PIOT_CRATES_REGISTRY_FALLBACK`
2003
- retries `cargo publish` against the alt-registry on a 429-rate-limit
2004
- shape from real crates.io ("You have published too many versions of
2005
- this crate in the last 24 hours") and emits a `::warning::` workflow
2006
- command so reviewers see the fallback engaged. A symmetric
2007
- `PIOT_CRATES_REGISTRY_PRIMARY` seam routes the publish *only* at the
2008
- override URL (no real-crates.io attempt, no fallback); reserved for
2009
- any future `*-first-publish` crates fixture. A first attempt on
2010
- this issue wired Kellnr; three CI rounds all 403'd because every
2011
- *production* cargo alt-registry (Kellnr / alexandrie / ktra /
2012
- cratery) is multi-tenant-shaped and deliberately rejects
2013
- fixture-style unrecognized identities. The cargo ecosystem has no
2014
- analog of npm's per-user self-registration convention, so picking
2015
- the one off-the-shelf "no-auth" registry is the only path that
2016
- works without auth gymnastics. The reusable consumer workflow
2017
- (`release.yml`), `putitoutthere.toml` schema, trailer grammar, the
2018
- dogfood `release-rust.yml`, and consumer-facing docs are
2019
- untouched. #331.
2020
-
2021
- **Required changes.** None.
2022
-
2023
- **Deprecations removed.** None.
2024
-
2025
- **Behavior changes without code changes.** None for consumers. For
2026
- contributors running e2e locally: the publish job in
2027
- `e2e-fixture-job.yml` now `cargo install`s
2028
- `cargo-http-registry@0.1.8` on crates-bearing rows (~70s cold; the
2029
- crate has a lightweight dep tree — tokio rt-only + warp + git2, no
2030
- openssl-sys / sqlite-sys / aws-lc-sys) and starts it as a
2031
- background process bound at `127.0.0.1:35503`. Cargo's
2032
- `net.git-fetch-with-cli = true` is written to `~/.cargo/config.toml`
2033
- on the same path because libgit2 enforces strict `application/x-git-*`
2034
- content-type checking that `cargo-http-registry` doesn't satisfy;
2035
- the system `git` binary is more lenient and works fine. The handler
2036
- in `src/handlers/crates.ts` now passes `--token <placeholder>`
2037
- alongside `--index <url>` on alt-registry invocations because
2038
- cargo's CLI refuses to dispatch publish without an explicit
2039
- `--token` once `--index` is set — a CLI quirk; the value is never
2040
- validated by `cargo-http-registry`. Steady-state crates fixtures
2041
- keep their real-crates.io OIDC-TP path unchanged on the happy path;
2042
- the fallback only fires when real crates.io returns a 429.
2043
-
2044
- **Verification.** A successful CI run on a PR that hits the
2045
- crates.io 24h-per-crate quota (the polyglot fixture's
2046
- `piot-fixture-zzz-poly-rust` row) goes green via the alt-registry
2047
- fallback with a `::warning::` in the run log naming the fallback URL,
2048
- instead of failing red on the 429. When the quota is fresh, the
2049
- `e2e (polyglot-everything)` row continues to publish to real
2050
- crates.io and the warning does not fire — visible diagnostic
2051
- distinction between the two paths. The diagnostic dump step at the
2052
- end of every crates-bearing publish job emits the
2053
- cargo-http-registry process log, the readiness-endpoint probe, and
2054
- the rendered cargo `config.toml` so any future failure has the wire
2055
- trace inline in the run log.
2056
-
2057
- ### Internal Verdaccio e2e coverage
2058
-
2059
- **Summary.** Internal change with no consumer-observable impact. Adds a
2060
- `js-vanilla-first-publish` fixture and matrix row that publishes to an
2061
- in-job Verdaccio service container; the post-publish tarball-verify step
2062
- is now registry-agnostic. `PIOT_NPM_REGISTRY` is an internal e2e seam
2063
- (`src/handlers/npm.ts`, `src/handlers/npm-platform.ts`) that routes the
2064
- publish at the override registry and suppresses provenance + the
2065
- public-npm bootstrap-hint path. The reusable consumer workflow
2066
- (`release.yml`), `putitoutthere.toml` schema, and trailer grammar are
2067
- untouched. #304 (parent #293).
2068
-
2069
- **Required changes.** None.
2070
-
2071
- **Deprecations removed.** None.
2072
-
2073
- **Behavior changes without code changes.** None for consumers. For
2074
- contributors running e2e locally: the publish job in
2075
- `e2e-fixture-job.yml` now spins up a Verdaccio service container per
2076
- job (~3s startup cost). First-publish fixtures route publish at
2077
- `http://localhost:4873`; steady-state fixtures keep their real-npm
2078
- path unchanged.
2079
-
2080
- **Verification.** A successful CI run on `main` shows the new
2081
- `e2e (js-vanilla-first-publish)` matrix row green. The existing
2082
- `e2e (js-vanilla)` row remains green, confirming the real-npm
2083
- steady-state hasn't regressed. To demonstrate the #256 tarball-verify
2084
- contract, trigger `E2E` via `workflow_dispatch` with
2085
- `simulate_no_dist: true` — the `js-vanilla-first-publish` row should
2086
- go red on `tarball missing 'dist'`.
2087
-
2088
- ### Single-artifact publish layout normalization
2089
-
2090
- **Summary.** The reusable workflow's publish job downloads build
2091
- artifacts with `actions/download-artifact@v8` configured as `path:
2092
- artifacts` and no `name`/`pattern` filter. That action is
2093
- count-sensitive: multiple artifacts land in `artifacts/<name>/...`
2094
- subdirs (the documented multi-case the engine's completeness check
2095
- and every handler are written against), but a *single* artifact
2096
- extracts directly into `artifacts/` with no per-artifact subdir.
2097
- Consumers whose plan emits exactly one expected artifact — pure-Python
2098
- packages with `build = "hatch"` (sdist row only) being the canonical
2099
- case — therefore failed at the completeness check with `missing
2100
- artifact directory <pkg>-sdist/` before the pypi handler ever ran.
2101
- Multi-artifact consumers (pypi+npm, sdist+wheels, polyglot) were
2102
- unaffected. The publish job now normalizes the layout in-process
2103
- before completeness: when the plan expects one staged artifact and
2104
- `artifacts/<artifact_name>/` is absent, files in `artifacts/` are
2105
- moved into that subdir so the engine's contract holds. Fully a
2106
- fix-in-place; no input shape, output shape, or config key changed.
2107
- Tracked in #311.
2108
-
2109
- **Required changes.** None.
2110
-
2111
- **Deprecations removed.** None.
2112
-
2113
- **Behavior changes without code changes.**
2114
-
2115
- - Pypi-only consumers with `build = "hatch"` (or any other
2116
- `[[package]]` whose plan emits a single artifact row) that
2117
- previously failed publish with `Artifact completeness check
2118
- failed: ... missing artifact directory <pkg>-sdist/` now reach the
2119
- pypi handler successfully and tag as expected. The wider release
2120
- flow — version-rewrite, tag creation, GitHub Release, caller-side
2121
- `pypi-publish` upload — was already correct; only the engine's
2122
- pre-publish completeness check was upstream of the bug.
2123
- - Multi-artifact plans (≥2 staged artifacts), crates-only plans, and
2124
- vanilla-npm plans (`[[package]] kind = "npm"` with no `build` /
2125
- `build = []`) see no observable difference. The normalization is
2126
- scoped to the exact case `download-artifact@v8` dumps into the
2127
- root.
2128
-
2129
- **Verification.** A release-please / release-plz cascade against a
2130
- pure-Python `[[package]]` with `build = "hatch"` should:
2131
-
2132
- 1. Surface the planned matrix as a single row,
2133
- `target = sdist artifact = <pkg>-sdist`.
2134
- 2. Reach the `pypi: <pkg>@<version> delegated to caller-side upload
2135
- step.` log line in the publish job.
2136
- 3. Push a `<pkg>-v<version>` tag, kick off the caller's
2137
- `pypi-publish` job, and produce a GitHub Release. No
2138
- `missing artifact directory` error appears in the run log.
2139
-
2140
- ### `[package.bundle_cli]` features and `no_default_features`
2141
-
2142
- **Summary.** `[package.bundle_cli]` previously only worked for crates
2143
- whose CLI binary built with a vanilla `cargo build --release --target
2144
- <triple> --bin <bin>` — no feature flags, no env. The standard shape
2145
- for libraries that ship an optional CLI (ruff, uv, pydantic-core,
2146
- biome, swc, dirsql) is `[[bin]] required-features = ["cli"]`, so the
2147
- binary's deps don't pollute `cargo add <name>`. Without a way to pass
2148
- `--features`, `cargo build --bin <bin>` exits with `target ... requires
2149
- the features: cli` and the recipe was inert for the consumers it was
2150
- designed for. The schema now exposes:
2151
-
2152
- - `features: list[string]` — forwarded to `cargo build --features
2153
- <comma-list>` when non-empty. Defaults to `[]`.
2154
- - `no_default_features: bool` — adds `--no-default-features` when
2155
- true. Defaults to `false`.
2156
-
2157
- Both keys are optional; the schema defaults preserve byte-identical
2158
- cargo invocations for existing `[package.bundle_cli]` blocks. Empty
2159
- strings inside the `features` list are rejected at config load. The
2160
- caveat under [`bundle_cli` now actually stages the binary](#packagebundle_cli-now-actually-stages-the-binary)
2161
- that named this gap as "not currently supported" has been corrected.
2162
- Tracked in #300.
2163
-
2164
- **Required changes.** None for consumers whose binary builds without
2165
- feature flags. Consumers whose `Cargo.toml` declares `required-features
2166
- = ["cli"]` (or who otherwise need a non-default feature set on the CLI
2167
- binary) add the new keys:
2168
-
2169
- ```diff
2170
- [package.bundle_cli]
2171
- bin = "my-cli"
2172
- stage_to = "src/my_py/_binary"
2173
- crate_path = "crates/my-rust"
2174
- +features = ["cli"]
2175
- +no_default_features = false
2176
- ```
2177
-
2178
- **Deprecations removed.** None.
2179
-
2180
- **Behavior changes without code changes.** None. The new keys default
2181
- to the equivalent of "no extra cargo flags," matching pre-#300
2182
- behavior.
2183
-
2184
- **Verification.** Trigger a release on a maturin pypi package whose
2185
- crate uses `[[bin]] required-features = ["cli"]` and that now declares
2186
- `features = ["cli"]` in its `[package.bundle_cli]` block. The
2187
- `bundle_cli — cargo build for <triple> (<bin>)` step in the build job's
2188
- log emits `cargo build --release --target <triple> --bin <bin>
2189
- --features cli` and exits zero; the wheel-content guard step that
2190
- follows confirms `<stage_to>/<bin>` is present in the produced `.whl`.
2191
-
2192
- ---
2193
-
2194
- ### Crates Cargo.toml must declare `description` and `license`
2195
-
2196
- **Summary.** Every cascaded `kind = "crates"` package's `Cargo.toml`
2197
- must now declare `[package].description` and either `[package].license`
2198
- or `[package].license-file`. The new preflight check
2199
- (`requireCratesMetadata` in `src/preflight.ts`) runs in
2200
- `src/publish.ts` immediately after `requireProvenanceMetadata` and
2201
- rejects the run with `PIOT_CRATES_MISSING_METADATA` before any
2202
- runner work. Why: crates.io rejects publish with `400 Bad Request:
2203
- missing or empty metadata fields: ...` after `cargo publish`'s
2204
- verification build has compiled the crate and every transitive dep
2205
- — wasting the entire publish job (often a minute+ of compile time
2206
- plus the upload) on a precondition checkable in milliseconds. Hit
2207
- in the wild on `thekevinscott/darkfactory`'s first crate publish;
2208
- tracked in #290. Same shape as #280 (npm `repository`).
2209
-
2210
- **Required changes.** Add the two fields to every Cargo.toml
2211
- declared as `kind = "crates"` in `putitoutthere.toml`.
2212
-
2213
- | Before | After |
2214
- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2215
- | `[package]`<br>`name = "lib"`<br>`version = "0.1.0"`<br>`edition = "2021"` | `[package]`<br>`name = "lib"`<br>`version = "0.1.0"`<br>`edition = "2021"`<br>`description = "One-line summary."`<br>`license = "MIT OR Apache-2.0"` |
2216
-
2217
- `license-file = "LICENSE"` is accepted as a substitute for
2218
- `license` (the SPDX expression form). Whitespace-only values
2219
- (`description = " "`) are treated as empty.
2220
-
2221
- No `release.yml` or `putitoutthere.toml` changes are required.
2222
-
2223
- **Deprecations removed.** None.
2224
-
2225
- **Behavior changes without code changes.** `putitoutthere publish`
2226
- now fails fast at preflight with
2227
- `[PIOT_CRATES_MISSING_METADATA] cargo publish requires the
2228
- following Cargo.toml [package] fields: description, and license
2229
- (or license-file).` for any cascaded crates package whose
2230
- Cargo.toml lacks the fields. Previously the same shape ran
2231
- through plan + auth-negotiation + ~30s of cargo verification
2232
- build and failed inside `cargo publish` with a 400 from
2233
- crates.io. Well-formed packages are unaffected.
2234
-
2235
- The error message names every failing package, its `Cargo.toml`
2236
- path, and the specific missing fields, so consumers fix all of
2237
- them in one round-trip rather than discovering them one at a
2238
- time across multiple release attempts.
2239
-
2240
- **Verification.** Drop `description` from one of your Cargo.toml
2241
- files locally and run `pnpm test:integration` (or the engine
2242
- against a local fixture); the failure should arrive at preflight
2243
- with `PIOT_CRATES_MISSING_METADATA` in the message, no crate
2244
- should be published, and no git tag should be created. Restore
2245
- the field; the next run completes normally.
2246
-
2247
- ---
2248
-
2249
- ### npm build step: TARGET / BUILD env vars
2250
-
2251
- **Summary.** The reusable workflow's `_matrix.yml` and `release.yml`
2252
- now set `TARGET=${{ matrix.target }}` and `BUILD=${{ matrix.build }}`
2253
- on the `matrix.kind == 'npm'` build step (and `TARGET=main BUILD=`
2254
- on `release.yml`'s publish-job rebuild loop, which only ever runs
2255
- for the main package's row). Bundled-CLI / napi consumers' build
2256
- scripts read these env vars to know which triple to cross-compile
2257
- and which build mode is active; without them, every per-platform
2258
- matrix row produced an empty `build/<triple>/` directory and
2259
- `actions/upload-artifact@v7` reported
2260
- `No files were found with the provided path: ...`. The internal
2261
- `e2e-fixture-job.yml` already passed `TARGET` / `BUILD` for the
2262
- `js-bundled-cli` fixture, so the fixture suite was green while the
2263
- shape it advertised was broken for real consumers. Tracked at #287;
2264
- hit in the wild on `thekevinscott/darkfactory`'s first release.
2265
-
2266
- The README's [Bundled-CLI npm family](./README.md#bundled-cli-npm-family)
2267
- recipe gained the consumer-side build-script contract that was
2268
- previously missing: a documented `scripts/build.cjs` template
2269
- that reads `TARGET`, runs `rustup target add` + `cargo build`
2270
- + stage-into-`build/<triple>/`, and no-ops on
2271
- `TARGET=main`. Without that section, consumers had to read the
2272
- `js-bundled-cli` test fixture or experiment to discover the
2273
- contract.
2274
-
2275
- **Required changes.** None for consumers whose existing build
2276
- script either ignored `TARGET` or used a different env var name —
2277
- those scripts now get the `TARGET` / `BUILD` exports too but
2278
- nothing forces them to read. To start using the contract, mirror
2279
- the README's `scripts/build.cjs` shape and reference it from
2280
- `package.json`'s `scripts.build` field. Existing fixtures and
2281
- the engine's contract are unchanged.
2282
-
2283
- **Deprecations removed.** None.
2284
-
2285
- **Behavior changes without code changes.** The npm build step
2286
- now runs with `TARGET` and `BUILD` set in its environment.
2287
- Build scripts that previously saw `undefined` and either crashed
2288
- or silently no-oped will now see a defined string. Vanilla npm
2289
- consumers (no `build = "bundled-cli" | "napi"`) see
2290
- `TARGET=main BUILD=` (or `BUILD=undefined` on rows that don't
2291
- declare a build mode); their build scripts that don't read either
2292
- var are unaffected.
2293
-
2294
- **Verification.** A bundled-cli consumer following the README
2295
- recipe sees their per-platform matrix rows produce non-empty
2296
- artifacts: each `<pkg>-npm-<triple>` upload contains
2297
- `build/<triple>/<bin-name>`. The release run's `Upload artifact`
2298
- step no longer logs `No files were found with the provided path`
2299
- for any npm row.
2300
- ### First-publish bundled-cli lockfile self-heal
2301
-
2302
- **Summary.** The reusable workflow's npm install steps —
2303
- `_matrix.yml`'s build-matrix install and `release.yml`'s
2304
- publish-job rebuild (#256) — both ran strict installs (`npm ci`
2305
- or `pnpm install --frozen-lockfile`) and refused on any drift
2306
- between the committed lockfile and `package.json`. For consumers
2307
- of the bundled-cli / napi shape, drift is the *expected* state on
2308
- the first publish: `package.json` declares
2309
- `optionalDependencies` for `<name>-<triple>@<version>` platform
2310
- packages that this pipeline produces, those packages don't exist
2311
- on the registry yet, pnpm 10 silently drops 404'd optionals from
2312
- the lockfile when it is regenerated locally, and the next CI run
2313
- sees lockfile and `package.json` disagree. Hit in the wild on
2314
- `thekevinscott/darkfactory`'s first release.
2315
-
2316
- Both install steps now fall back from the strict form to its
2317
- non-strict counterpart on failure (`pnpm install --no-frozen-lockfile`
2318
- / `npm install`) and emit a `::warning::` line in the run log
2319
- naming the recovery. The README's
2320
- [Bundled-CLI npm family](./README.md#bundled-cli-npm-family)
2321
- recipe grew a `[!NOTE]` callout documenting the chicken-and-egg
2322
- and the workflow's transparent recovery.
2323
-
2324
- **Required changes.** None. Consumers who were working around
2325
- the failure by gitignoring the lockfile, by suppressing
2326
- `optionalDependencies` from `package.json`, or by pinning to
2327
- older lockfile-tolerant pnpm versions can revert those
2328
- workarounds; the workflow now handles the bootstrap state on
2329
- its own.
2330
-
2331
- **Deprecations removed.** None.
2332
-
2333
- **Behavior changes without code changes.** Strict installs that
2334
- previously failed red on lockfile drift now succeed via the
2335
- non-strict fallback path. The build artifact is unchanged
2336
- (installs the deps `package.json` declares); only the strictness
2337
- of *how* it gets there relaxes. Healthy lockfiles still take
2338
- the strict path with no observable difference. The new
2339
- `::warning::` lines are visible in the run log on the GitHub
2340
- Actions UI but do not fail the run.
2341
-
2342
- **Verification.** A bundled-cli / napi consumer's first-publish
2343
- release run completes the build matrix without manual lockfile
2344
- fiddling. The run log contains a single
2345
- `::warning::pnpm-lock.yaml drift ...` line per affected install
2346
- step (one in the build matrix, one in the publish-job rebuild)
2347
- when the strict install fails; healthy installs see no
2348
- warning.
2349
-
2350
- ### Platform-publish `.npmrc` lookup
2351
-
2352
- **Summary.** The reusable workflow's per-triple platform-package
2353
- publishes (`build = "bundled-cli"` / `build = "napi"`) ran `npm
2354
- publish` from a temporary staging directory rather than from the
2355
- consumer's package path. npm reads `.npmrc` from cwd upward; the
2356
- consumer's `.npmrc` lives at `pkg.path`, never on the path to a
2357
- tempdir, so platform publishes never saw the auth the main package
2358
- relied on. OIDC trusted publishing masked the gap (auth flows via
2359
- the `ACTIONS_ID_TOKEN_REQUEST_TOKEN` environment variable, not
2360
- `.npmrc`), but the `NPM_TOKEN` bootstrap path (#310) — required
2361
- for the very first publish of a brand-new npm package — and the
2362
- internal Verdaccio e2e seam (#304) both broke because both rely on
2363
- `.npmrc`-supplied auth.
2364
-
2365
- The engine now invokes `npm publish <stagingDir>` with `cwd:
2366
- pkg.path`, matching how the main-package publish already runs.
2367
- npm reads the consumer's `.npmrc` (including any `_authToken`,
2368
- `always-auth`, or scoped-registry entries) and applies it to the
2369
- PUT for each per-triple platform package.
2370
-
2371
- **Required changes.** None. The fix is internal to
2372
- `src/handlers/npm-platform.ts`; consumer `release.yml` flows are
2373
- unchanged.
2374
-
2375
- **Deprecations removed.** None.
2376
-
2377
- **Behavior changes without code changes.** Consumers who rely on
2378
- `NPM_TOKEN` (rather than OIDC) for the first publish of a
2379
- bundled-cli / napi family — i.e. a brand-new npm package whose
2380
- per-triple sub-packages also don't exist yet — now succeed without
2381
- the workaround of publishing a `0.0.0-bootstrap` stub by hand.
2382
- OIDC consumers see no observable difference: the same env-derived
2383
- auth keeps flowing because `npm publish` reads
2384
- `NODE_AUTH_TOKEN`/`ACTIONS_ID_TOKEN_REQUEST_TOKEN` from the
2385
- environment regardless of which directory cwd points at.
2386
-
2387
- **Verification.** A consumer publishing a brand-new bundled-cli /
2388
- napi family via `NPM_TOKEN` completes per-triple sub-package
2389
- publishes alongside the main package on the first release run,
2390
- with no `npm publish (platform) failed` errors in the log.
2391
-
2392
- ### `[package.bundle_cli]` now actually stages the binary
2393
-
2394
- **Summary.** Wheels published from a maturin pypi package that
2395
- declared `[package.bundle_cli]` previously shipped without the
2396
- bundled CLI binary. The block was parsed by config, attached to
2397
- per-target wheel rows by the planner, and documented in the README
2398
- and MIGRATIONS — but the reusable workflow's build job
2399
- (`.github/workflows/_matrix.yml`) had no step that consumed the
2400
- metadata. Consumers' wheels arrived on PyPI missing the file the
2401
- launcher in `[project.scripts]` resolved at runtime, and
2402
- `pip install <pkg> && <pkg> ...` failed with `FileNotFoundError`.
2403
- The recipe was advertised as shipped in v0.2.0 (#217) but was
2404
- silently a no-op for over a release cycle. Hit in the wild on
2405
- `thekevinscott/dirsql`; tracked in #282.
2406
-
2407
- The workflow now runs four new steps for every per-target wheel
2408
- row where `matrix.bundle_cli` is set:
2409
-
2410
- 1. `rustup target add ${{ matrix.target }}` — make the triple known.
2411
- 2. `cargo build --release --target ${{ matrix.target }} --bin ${{ matrix.bundle_cli.bin }}`
2412
- against `crate_path` — produce the binary on the native host
2413
- runner (`defaultRunsOn` in `src/plan.ts` already maps every
2414
- supported triple to a native runner, so cross-compile linkers
2415
- are not needed).
2416
- 3. Copy the resulting binary (with `.exe` suffix on Windows) into
2417
- `${{ matrix.path }}/${{ matrix.bundle_cli.stage_to }}/` so
2418
- maturin's `[tool.maturin].include` glob picks it up as wheel
2419
- data.
2420
- 4. After `PyO3/maturin-action@v1` produces the `.whl`, open the
2421
- wheel and refuse `upload-artifact` if it does not contain a
2422
- file at any directory ending in `<stage_to>/<bin>`. This guard
2423
- stays useful after the staging steps land — it catches any
2424
- future regression where the cross-compile silently routes the
2425
- binary to the wrong path, and it ensures broken wheels never
2426
- leave the build runner.
2427
-
2428
- **Required changes.** None for consumers whose existing
2429
- `[package.bundle_cli]` block follows the documented shape — the
2430
- recipe simply starts working. Consumers who relied on the broken
2431
- state (e.g., shipped a workaround that hardcoded a copy of the
2432
- binary into the source tree before `putitoutthere` ran) can
2433
- remove the workaround.
2434
-
2435
- **Constraint not previously documented.** The cross-compile
2436
- assumes the binary is buildable with a vanilla
2437
- `cargo build --release --bin <bin>` — no env vars, no special
2438
- build config. Crates that gate the CLI behind a Cargo feature
2439
- (e.g., `--features cli`) are now supported via
2440
- `[package.bundle_cli].features` and
2441
- `[package.bundle_cli].no_default_features`; see
2442
- [`bundle_cli` features and `no_default_features`](#packagebundle_cli-features-and-no_default_features).
2443
-
2444
- **Deprecations removed.** None.
2445
-
2446
- **Behavior changes without code changes.** Existing
2447
- `[package.bundle_cli]` blocks change behavior at upgrade time:
2448
- the next release run produces wheels that contain the binary
2449
- (previously the workflow silently published wheels without it).
2450
- If a consumer's `[tool.maturin].include` path resolves to nothing
2451
- (typo, mismatched layout), the new wheel-content guard fails the
2452
- build red instead of silently uploading an unusable wheel.
2453
-
2454
- **Verification.** After upgrading, trigger a release on a maturin
2455
- package that declares `[package.bundle_cli]`. The build job's log
2456
- includes a `bundle_cli — verify wheel contains <stage_to>/<bin>`
2457
- step that ends with `ok bundle_cli: <stage_to>/<bin> present in
2458
- <wheel-name>.whl`. The published wheel, when downloaded and
2459
- unzipped, contains the binary at the expected path.
2460
- `pip install <pkg> && <pkg> --version` runs the launcher and the
2461
- launcher resolves the binary inside the wheel.
2462
-
2463
- ---
2464
-
2465
- ### Friendly config error hints
2466
-
2467
- **Summary.** A consumer integration produced a `putitoutthere.toml`
2468
- with four shape mistakes at once: `version = 1` declared at the
2469
- file root rather than under `[putitoutthere]`, `[[packages]]`
2470
- (plural) instead of `[[package]]` (singular), `registry =` instead
2471
- of `kind =`, and `files =` instead of `globs =`. The engine's
2472
- zod-derived error message named none of those four typos by their
2473
- correct equivalent — `Invalid input: expected object, received
2474
- undefined; ...; Unrecognized keys: "version", "packages"` is
2475
- technically correct and operationally useless. `parseConfig` now
2476
- runs a pre-pass that detects each of these four cases by inspecting
2477
- the parsed TOML before zod runs, and emits a hint that names both
2478
- the wrong key and the right one. The README's [Drop in
2479
- `putitoutthere.toml`](./README.md#2-drop-in-putitoutthere-toml)
2480
- section grew a wrong→right table covering the same four traps so
2481
- the docs and the engine speak the same vocabulary, the
2482
- [Drop in `.github/workflows/release.yml`](./README.md#1-drop-in-githubworkflowsreleaseyml)
2483
- section grew an `[!IMPORTANT]` callout warning consumers off
2484
- `push: branches: [main]` triggers on lane CI workflows, and `1b.
2485
- build-check.yml` was promoted from "Optional" to "Recommended"
2486
- because it's the cheapest pre-merge surface that exercises
2487
- `parseConfig` on the consumer's actual config.
2488
-
2489
- **Required changes.** None. The hints fire only on configs that
2490
- were already failing validation; valid configs are unaffected.
2491
- A config that was failing with a confusing zod message before will
2492
- now fail with a hint message that names the fix:
2493
-
2494
- | Wrong (still rejected, clearer message) | Right |
2495
- | -------------------------------------------------------------------- | ------------------------------------------------ |
2496
- | `version = 1` at file root, no `[putitoutthere]` table | `[putitoutthere]` table with `version = 1` inside |
2497
- | `[[packages]]` (plural) | `[[package]]` (singular, one block per package) |
2498
- | `registry = "crates"` | `kind = "crates"` |
2499
- | `files = ["src/**"]` | `globs = ["src/**"]` |
2500
-
2501
- Consumers with healthy configs can ignore this. Consumers with
2502
- broken configs whose CI was previously red against a zod message
2503
- will see the same red CI with a clearer message — fix the config
2504
- shape per the table above.
2505
-
2506
- **Deprecations removed.** None.
2507
-
2508
- **Behavior changes without code changes.** Error message text
2509
- on failed config validation. The exit code, the failure surface
2510
- (`parseConfig` throwing inside the engine), and the set of
2511
- configs that pass validation are all unchanged.
2512
-
2513
- **Verification.** A failing config with any of the four mistakes
2514
- above will now contain the words `did you mean` in its CI log
2515
- output. Repos with valid configs see no change in any release
2516
- or build-check run.
2517
-
2518
- ---
2519
-
2520
- ### npm token fallback
2521
-
2522
- **Summary.** The reusable workflow now accepts an optional
2523
- `NPM_TOKEN` via `secrets:`. Trusted Publishing on npm binds to
2524
- an *already-published* package, so the very first publish of a
2525
- brand-new npm package has no OIDC path available; without this
2526
- fallback every first-time bundled-cli / napi consumer hit a 6+
2527
- package manual `0.0.0-bootstrap` stub bootstrap, documented
2528
- nowhere, only discoverable by reading commit history of dirsql
2529
- or by hitting the failure. OIDC trusted publishers remain the
2530
- default and recommended path — when the secret is unset,
2531
- behavior is byte-for-byte unchanged. When the secret is set
2532
- AND the planned matrix contains an npm row, the secret is
2533
- exported to `$GITHUB_ENV` as `NODE_AUTH_TOKEN`; the npm CLI
2534
- then prefers the long-lived token over the OIDC path. Mirror
2535
- of #283 (crates) in shape, byte-for-byte. Hit in the wild on
2536
- the maintainer's own dirsql project (first version of
2537
- `@dirsql/cli-linux-x64-gnu` on npm is `0.0.0-bootstrap`,
2538
- 2026-04-30; real `0.2.8` lands the next day) and on
2539
- `darkfactory`'s first publish. #302.
2540
-
2541
- **Required changes.** None for consumers already on the OIDC
2542
- path. To bootstrap a brand-new npm package or to use the
2543
- workflow on an account where Trusted Publishing isn't
2544
- available, wire the secret in the caller's `release.yml`:
2545
-
2546
- | Before | After |
2547
- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2548
- | `uses: thekevinscott/putitoutthere/.github/workflows/release.yml@v0` | `uses: thekevinscott/putitoutthere/.github/workflows/release.yml@v0`<br>`secrets:`<br>` NPM_TOKEN: ${{ secrets.NPM_TOKEN }}` |
2549
-
2550
- The repo-level secret holding the npm automation token can be
2551
- named anything; the *workflow* secret it gets passed as must be
2552
- `NPM_TOKEN` exactly — the reusable workflow keys on that name.
2553
- Drop the `secrets:` block from the caller's `release.yml` once
2554
- Trusted Publishing is registered against the (now-existing)
2555
- package; subsequent publishes are zero-secret. For bundled-cli
2556
- / napi families each per-platform sub-package needs its own
2557
- Trusted Publisher registration after the first publish — the
2558
- secret-bypass is a one-time bootstrap, not a permanent path.
2559
-
2560
- **Deprecations removed.** None.
2561
-
2562
- **Behavior changes without code changes.** None when the secret
2563
- is unset (OIDC path unchanged). When the secret is set, a new
2564
- "Export NODE_AUTH_TOKEN (caller-provided)" step writes
2565
- `NODE_AUTH_TOKEN` to `$GITHUB_ENV` gated on the secret being
2566
- non-empty AND the planned matrix containing an npm row. The
2567
- gate reads the secret through a job-level `CALLER_NPM_TOKEN`
2568
- env var because GitHub Actions does not allow the `secrets`
2569
- context inside step-level `if:` conditions ([context
2570
- availability](https://docs.github.com/en/actions/learn-github-actions/contexts#context-availability));
2571
- this is an internal mechanism — consumers don't see or set
2572
- `CALLER_NPM_TOKEN` themselves. Unlike #283 (crates), there is
2573
- no separate OIDC step to "skip" — the npm CLI handles OIDC
2574
- internally via the runner's id-token, and the presence of
2575
- `NODE_AUTH_TOKEN` in the env is what switches the CLI's auth
2576
- mode.
2577
-
2578
- **Verification.** Wire `NPM_TOKEN` to a valid npm automation
2579
- token in the caller repo and trigger a release of a brand-new
2580
- package. The publish-job logs should show "Export
2581
- NODE_AUTH_TOKEN (caller-provided)" as `success`; `npm publish`
2582
- authenticates with the long-lived token rather than via OIDC,
2583
- and every per-platform sub-package in a bundled-cli / napi
2584
- family lands on the registry in a single run. Once first
2585
- publish succeeds, register Trusted Publishers against each
2586
- package URL, drop the `secrets:` block, and re-run a release
2587
- — the OIDC path covers the steady state from there.
2588
-
2589
- Verified end-to-end against existing seeded fixtures and a
2590
- real first-publish on a canary repo. The Verdaccio
2591
- first-publish fixture coverage for the same path is tracked
2592
- separately at #293; until that lands this fallback is verified
2593
- by composition (mirror of #283) plus consumer-side observation
2594
- on real first publishes rather than by an automated
2595
- fresh-state fixture in this repo's CI.
2596
-
2597
- ---
2598
-
2599
- ### Crates token fallback
2600
-
2601
- **Summary.** The reusable workflow now accepts an optional
2602
- `CARGO_REGISTRY_TOKEN` via `secrets:`. Trusted Publishing on
2603
- crates.io binds to an *already-published* crate, so the very
2604
- first publish of a brand-new crate has no OIDC path available;
2605
- without this fallback consumers had to either fork the workflow
2606
- or publish once outside it. OIDC trusted publishers remain the
2607
- default and recommended path — when the secret is unset,
2608
- behavior is byte-for-byte unchanged. When the secret is set,
2609
- the `rust-lang/crates-io-auth-action` OIDC exchange is skipped
2610
- and the caller-provided token is exported to `$GITHUB_ENV` as
2611
- `CARGO_REGISTRY_TOKEN` for the engine's crates handler to read.
2612
- The header comment in `.github/workflows/release.yml` has been
2613
- softened to match: previously *"Long-lived registry tokens are
2614
- explicitly NOT supported via this workflow"*; now OIDC is
2615
- described as the default with the token fallback called out for
2616
- first-publish bootstrap. #283.
2617
-
2618
- **Required changes.** None for consumers already on the OIDC
2619
- path. To bootstrap a brand-new crate or to use the workflow on
2620
- an account where Trusted Publishing isn't available, wire the
2621
- secret in the caller's `release.yml`:
2622
-
2623
- | Before | After |
2624
- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
2625
- | `uses: thekevinscott/putitoutthere/.github/workflows/release.yml@v0` | `uses: thekevinscott/putitoutthere/.github/workflows/release.yml@v0`<br>`secrets:`<br>` CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}` |
2626
-
2627
- The repo-level secret holding the crates.io API token can be
2628
- named anything; the *workflow* secret it gets passed as must
2629
- be `CARGO_REGISTRY_TOKEN` exactly — the reusable workflow keys
2630
- on that name. Drop the `secrets:` block from the caller's
2631
- `release.yml` once Trusted Publishing is registered against
2632
- the now-existing crate; subsequent publishes are zero-secret.
2633
-
2634
- **Deprecations removed.** None.
2635
-
2636
- **Behavior changes without code changes.** None when the secret
2637
- is unset (OIDC path unchanged). When the secret is set, the
2638
- publish job's "Authenticate with crates.io (OIDC)" step is
2639
- conditionally skipped and a new "Export CARGO_REGISTRY_TOKEN
2640
- (caller-provided)" step writes the secret to `$GITHUB_ENV`
2641
- gated on the same condition. The gate reads the secret through a
2642
- job-level `CALLER_CARGO_REGISTRY_TOKEN` env var because GitHub
2643
- Actions does not allow the `secrets` context inside step-level
2644
- `if:` conditions ([context availability](https://docs.github.com/en/actions/learn-github-actions/contexts#context-availability));
2645
- this is an internal mechanism — consumers don't see or set
2646
- `CALLER_CARGO_REGISTRY_TOKEN` themselves.
2647
-
2648
- **Verification.** Wire `CARGO_REGISTRY_TOKEN` to a valid
2649
- crates.io API token in the caller repo and trigger a release.
2650
- The publish-job logs should show "Authenticate with crates.io
2651
- (OIDC)" as `skipped`, "Export CARGO_REGISTRY_TOKEN (OIDC)" as
2652
- `skipped`, and "Export CARGO_REGISTRY_TOKEN (caller-provided)"
2653
- as `success`. The crate publishes; the only difference visible
2654
- in the registry is the publish was authorised against the
2655
- caller-provided token rather than an OIDC-minted ephemeral one.
2656
-
2657
- ---
2658
-
2659
- ### npm package.json must declare `repository`
2660
-
2661
- **Summary.** Every cascaded `kind = "npm"` package's `package.json`
2662
- must now carry a non-empty `repository` field. The new preflight
2663
- check (`requireProvenanceMetadata` in `src/preflight.ts`) runs in
2664
- `src/publish.ts` immediately after `requireAuth` and rejects the
2665
- run with `PIOT_NPM_MISSING_REPOSITORY` before any runner work.
2666
- Why: `putitoutthere` invokes `npm publish --provenance` on the OIDC
2667
- trusted-publisher path; the npm CLI hard-requires this field so the
2668
- registry can verify the artifact was built from the repo the trusted
2669
- publisher declares. A missing or empty field previously surfaced
2670
- only after the runner had spun up, OIDC had been negotiated, and
2671
- the artifact had been built — wasting a full release run on a
2672
- precondition checkable in milliseconds. Hit in the wild on
2673
- `coaxer@0.1.1`'s first npm release; tracked in #280.
2674
-
2675
- The npm handler's inline backstop (`assertRepositoryField` in
2676
- `src/handlers/npm.ts`) is also tightened. Previously it used
2677
- `if (!pkg.repository)` — falsy-only — which let three real shapes
2678
- slip through: an object without `url` (`{ type: 'git' }`), an
2679
- empty object (`{}`), and a whitespace-only string (`' '`). All
2680
- three are now rejected; the error message also carries the stable
2681
- `PIOT_NPM_MISSING_REPOSITORY` code and the path of the offending
2682
- file, matching the preflight error.
2683
-
2684
- **Required changes.** Add a `repository` block to every
2685
- `package.json` declared as `kind = "npm"` in
2686
- `putitoutthere.toml`. Canonical shape:
2687
-
2688
- | Before | After |
2689
- | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2690
- | `{ "name": "@scope/lib", "version": "0.1.0" }` | `{ "name": "@scope/lib", "version": "0.1.0", "repository": { "type": "git", "url": "git+https://github.com/<owner>/<repo>.git", "directory": "<path/to/package>" } }` |
2691
-
2692
- `directory` is needed for monorepo packages so npm can locate the
2693
- source within the repo. Both the object form and the legacy single-
2694
- string form (`"repository": "git+https://github.com/<owner>/<repo>.git"`)
2695
- are accepted; only a missing field, an empty string, or an object
2696
- without a non-empty `url` fails the check.
2697
-
2698
- No `release.yml` or `putitoutthere.toml` changes are required.
2699
-
2700
- **Deprecations removed.** None.
2701
-
2702
- **Behavior changes without code changes.** `putitoutthere publish`
2703
- now fails fast at preflight with
2704
- `[PIOT_NPM_MISSING_REPOSITORY] npm publish requires a non-empty
2705
- \`repository\` field in package.json` for any cascaded npm package
2706
- whose `package.json` lacks the field. Previously the same shape
2707
- ran through plan + build + auth-negotiation + artifact-staging
2708
- and failed inside `npm publish` with `npm publish --provenance
2709
- requires a \`repository\` field in package.json`. Well-formed
2710
- packages are unaffected.
2711
-
2712
- The error message names every failing package and its
2713
- `package.json` path so consumers fix all of them in one round-trip
2714
- rather than discovering them one at a time across multiple release
2715
- attempts.
2716
-
2717
- **Verification.** Drop the `repository` field from one of your
2718
- `package.json` files locally and run `pnpm test:integration` (or
2719
- the engine against a local fixture); the failure should arrive at
2720
- preflight with `PIOT_NPM_MISSING_REPOSITORY` in the message, no
2721
- platform packages should be published, and no git tag should be
2722
- created. Restore the field; the next run completes normally.
2723
-
2724
- ---
2725
-
2726
- ### pypi/maturin version bump at build
2727
-
2728
- **Summary.** `pypi` packages built with `build = "maturin"` now ship
2729
- wheels at the planned version, not at whatever literal happened to be
2730
- in `pyproject.toml` on the build runner. The reusable workflow's
2731
- build matrix (`_matrix.yml`) bumps the version source on disk to
2732
- `matrix.version` immediately before each `PyO3/maturin-action@v1`
2733
- step. Two version-source shapes are supported:
2734
-
2735
- - Static literal: `[project].version = "0.1.0"` in `pyproject.toml`
2736
- is rewritten in place. This is maturin's default project shape and
2737
- the case that was previously broken for every consumer.
2738
- - Dynamic: `pyproject.toml` declaring `[project].dynamic = ["version"]`
2739
- with the version sourced from a sibling `Cargo.toml`'s
2740
- `[package].version` — that's where the bump lands instead.
2741
-
2742
- Why it matters: maturin reads its version source from disk at build
2743
- time and honors no env override. Other build paths bumped elsewhere
2744
- in the contract — crates and npm at publish (`writeVersion` rewrites
2745
- the manifest before `cargo publish` / `npm publish` reads it),
2746
- setuptools-scm / hatch-vcs through `SETUPTOOLS_SCM_PRETEND_VERSION`
2747
- in the build step. Maturin had no equivalent, so wheels left the
2748
- build runner pre-versioned at the consumer's stale literal. PyPI
2749
- rejected the upload with `400 File already exists` whenever that
2750
- literal had been previously registered, even when crates and npm
2751
- shipped correctly. Hit in the wild on `dirsql`'s 0.2.8 release;
2752
- issue #276.
2753
-
2754
- **Required changes.** None. The bump is internal to the reusable
2755
- workflow. Consumers pinning `release.yml@v0` and `build.yml@v0`
2756
- inherit the fix on the next workflow run with no `release.yml` edits
2757
- and no `putitoutthere.toml` edits. The fix applies equally to the
2758
- static-literal and dynamic-version shapes; consumers don't need to
2759
- restructure their pyproject to opt in.
2760
-
2761
- The CLI gains a new internal subcommand,
2762
- `putitoutthere write-version --path <pkg-dir> --version <v>`, that
2763
- implements the bump dispatch. `action.yml` gains a new optional
2764
- `version:` input that the reusable workflow forwards. Both surfaces
2765
- are internal seams powering `_matrix.yml`; consumers compose with
2766
- the reusable workflow, not directly with the CLI or the JS action
2767
- (see [`notes/design-commitments.md`](./notes/design-commitments.md)
2768
- non-goal #7 and #10).
2769
-
2770
- **Deprecations removed.** None.
2771
-
2772
- **Behavior changes without code changes.** A maturin build run
2773
- through the reusable workflow now mutates the build runner's
2774
- `pyproject.toml` (or `Cargo.toml` for dynamic-version projects) in
2775
- place, bumping the version literal to `matrix.version`. The mutation
2776
- lives only on the build runner — the consumer's source tree is
2777
- untouched. Anyone fingerprinting on the on-disk manifest version
2778
- during a build (e.g. a custom shell step running between
2779
- `uses: thekevinscott/putitoutthere/.github/workflows/release.yml@v0`
2780
- and a follow-up step that reads the manifest) will now see the
2781
- planned version where they previously saw the consumer's stale
2782
- literal. Custom build steps that grep on a specific literal version
2783
- need to grep on `matrix.version` instead.
2784
-
2785
- `pyproject.toml` projects whose `[project]` table is malformed
2786
- (neither a static `version = "..."` line nor `dynamic = ["version"]`)
2787
- now fail the build matrix with a clear error. Previously the same
2788
- shape produced wheels at whatever fallback the build backend chose,
2789
- which silently disagreed with the plan.
2790
-
2791
- **Verification.** Cut a release on a maturin package whose
2792
- `pyproject.toml` carries a stale literal (e.g. `version = "0.1.0"`
2793
- when the planned version is `0.2.8`). The reusable workflow's
2794
- build matrix logs a `write-version: ... → 0.2.8` line before each
2795
- maturin invocation, and the produced wheel's `METADATA: Version:`
2796
- matches the planned version:
2797
-
2798
- ```sh
2799
- unzip -p dist/*.whl '*.dist-info/METADATA' | grep '^Version:'
2800
- # Version: 0.2.8
2801
- ```
2802
-
2803
- PyPI accepts the upload — assuming the planned version itself isn't
2804
- a duplicate of a previously-registered file.
2805
-
2806
- ### New `build.yml` reusable workflow for PR-time build verification
2807
-
2808
- **Summary.** A second consumer-facing reusable workflow,
2809
- `.github/workflows/build.yml`, runs the same plan + build matrix that
2810
- `release.yml` runs but stops there — no publish job, no
2811
- `id-token: write`, no OIDC exchange, no registry auth. Both workflows
2812
- delegate the matrix to a shared internal `_matrix.yml`, so action
2813
- pins (`actions/checkout@v6`, `PyO3/maturin-action@v1`, etc.), per-row
2814
- build steps, and runner selection cannot drift between PR-time
2815
- verification and release-time publishing. The structural guarantee —
2816
- publish-capable bytes do not exist on the build-check code path — is
2817
- what makes this safe to run on untrusted PRs; a `dry_run: true` input
2818
- on `release.yml` would have made it a runtime guarantee subject to
2819
- GHA's expression evaluation quirks and any future `if:` bug. An
2820
- `actionlint`-job grep assertion rejects any patch that adds
2821
- `id-token: write` to `build.yml` or `_matrix.yml`.
2822
-
2823
- The new workflow runs at the same `@v0` floating tag as
2824
- `release.yml`, pinned the same way the engine action already is —
2825
- floating lightweight tag, no annotated-tag pitfall ([github/community
2826
- discussion #48693](https://github.com/orgs/community/discussions/48693)).
2827
- Pinning per-release annotated tags
2828
- (e.g. `putitoutthere-v0.4.2`) is unsupported for the same reason it has
2829
- always been unsupported for `release.yml`; the `@v0` form is canonical.
2830
-
2831
- **Required changes.** None for existing consumers. `release.yml` is
2832
- unchanged in its public surface — same inputs, same `has_pypi`
2833
- output, same concurrency group, same publish behavior. Internally
2834
- its `plan` and `build` jobs moved into `_matrix.yml`, but that file
2835
- is internal (the leading underscore + the absence of consumer-facing
2836
- docs); pinning `release.yml@v0` keeps working byte-for-byte.
2837
-
2838
- To opt into PR-time build verification, add a new workflow to your
2839
- repo:
2840
-
2841
- ```yaml
2842
- # .github/workflows/build-check.yml
2843
- name: Build check
2844
- on:
2845
- pull_request: {}
2846
- jobs:
2847
- build-check:
2848
- uses: thekevinscott/putitoutthere/.github/workflows/build.yml@v0
2849
- ```
2850
-
2851
- No new permissions, no new inputs to set. `node_version` and
2852
- `python_version` accept the same defaults / overrides as
2853
- `release.yml`. PRs that break a target-specific build (a wheel that
2854
- fails on `aarch64-apple-darwin`, an npm postinstall that fails on
2855
- Windows) now surface in review.
2856
-
2857
- **Deprecations removed.** None.
2858
-
2859
- **Behavior changes without code changes.** `release.yml`'s internal
2860
- job graph collapsed from `plan → build → publish` to
2861
- `build (uses: _matrix.yml) → publish`. The `publish` job now reads
2862
- `needs.build.outputs.matrix` instead of `needs.plan.outputs.matrix`;
2863
- the matrix payload is unchanged. Run logs show one nested-workflow
2864
- group (`build / plan`, `build / build`) instead of two top-level
2865
- jobs; existing log scrapers that fingerprint on the job name `plan`
2866
- need to fingerprint on `build / plan` instead.
2867
-
2868
- **Verification.** On a repo that adds the build-check workflow, open
2869
- a PR that touches a `[[package]].globs` glob. The PR's Checks tab
2870
- shows a `build-check / build / build` matrix run. The job has no
2871
- `id-token: write` permission (visible in the `Show all checks` tag
2872
- on the run), and there is no `publish` job in the run's job graph.
2873
- On `main`, the existing release flow is unaffected — a tag push +
2874
- GitHub Release on the next workflow run.
2875
-
2876
- ### Crates dirty-check whitelists sibling package paths
2877
-
2878
- **Summary.** The engine's pre-publish dirty-workspace check
2879
- (`scanDirtyOutsideManifest` in `src/handlers/crates.ts`) used to
2880
- flag any dirty file in the repo outside the package's own
2881
- `Cargo.toml`. For polyglot consumers (rust + js in one repo), the
2882
- reusable workflow's `Build npm packages` step (added in #256) runs
2883
- `npm install + npm run build` for each npm package in the plan
2884
- before the engine publishes anything. That creates `node_modules/`,
2885
- `package-lock.json`, and `dist/` inside each npm package's path as
2886
- untracked files. cargo's git-status check sees them and the engine
2887
- refuses with `cargo publish: refusing to proceed; unexpected dirty
2888
- files in the working tree outside <crate>/Cargo.toml`.
2889
-
2890
- The check now whitelists every other configured package's path
2891
- (`siblingPackagePaths` in `Ctx`), the same way it already
2892
- whitelists the reusable workflow's `artifacts/` scratch dir. cargo
2893
- only packs files inside its own package directory, so dirty state
2894
- in sibling packages can't end up in the crate tarball regardless.
2895
- Stray edits elsewhere in the repo (a `README.md` change, an
2896
- unrelated source file mod) still fail the check.
2897
-
2898
- **Required changes.** None for consumers calling the reusable
2899
- workflow at `thekevinscott/putitoutthere/.github/workflows/release.yml@v0`.
2900
- This is a pure relaxation: setups that previously published cleanly
2901
- continue to; setups that hit the false-positive failure now succeed.
2902
-
2903
- **Deprecations removed.** None.
2904
-
2905
- **Behavior changes without code changes.** A polyglot release run
2906
- that previously failed with the "unexpected dirty files" message
2907
- on `node_modules/` / `package-lock.json` / `dist/` in a sibling npm
2908
- package now proceeds. The published crate tarball is unchanged
2909
- (cargo always scoped its packing to the crate dir).
2910
-
2911
- **Verification.** A polyglot repo with rust + js packages and a
2912
- crates row in the matrix now reaches `cargo publish` instead of
2913
- the dirty-check error. After a release, the crate tarball still
2914
- contains only files inside the crate's own dir:
2915
-
2916
- ```sh
2917
- cargo package --list --manifest-path <crate>/Cargo.toml
2918
- ```
2919
-
2920
- ### `publish` throws on empty matrix
2921
-
2922
- **Summary.** `putitoutthere publish` previously logged
2923
- `publish: plan is empty; nothing to release` at info level and exited
2924
- 0 when the matrix had no rows. The reusable workflow's `publish` step
2925
- went green on those runs even though nothing reached a registry —
2926
- visually indistinguishable from a successful release. The engine now
2927
- throws with code `PIOT_PUBLISH_EMPTY_PLAN`, the publish step exits
2928
- non-zero, and the run goes red. Skips remain a workflow-gate concern:
2929
- the canonical `release.yml` already has `if: …matrix output non-empty
2930
- …` on its publish job, so a `release: skip` trailer (or any other
2931
- empty-plan reason) skips the publish job rather than running it to a
2932
- no-op.
2933
-
2934
- **Required changes.** None for consumers calling the reusable
2935
- workflow at `thekevinscott/putitoutthere/.github/workflows/release.yml@v0`.
2936
- The reusable workflow's existing `if:` on the publish job already
2937
- gates correctly. Hand-rolled workflows that invoked the CLI's
2938
- `publish` directly without a plan-output gate will now see a non-zero
2939
- exit on empty plans; add a gate or stop calling publish on commits
2940
- that don't produce work.
2941
-
2942
- **Deprecations removed.** None.
2943
-
2944
- **Behavior changes without code changes.** A release run that
2945
- reached the publish step with an empty plan used to log
2946
- `published: (nothing)` and exit 0; it now logs `[PIOT_PUBLISH_EMPTY_PLAN]
2947
- publish was invoked but the plan is empty…` to stderr and exits 1.
2948
- For repos whose release runs were silently no-op-ing (the dogfood
2949
- incident's failure mode), this surfaces the gap.
2950
-
2951
- **Verification.** Trigger a release run that would produce an empty
2952
- plan (e.g. a commit that doesn't touch any package's `globs`) and
2953
- either bypass the workflow gate or invoke the CLI directly. Expect
2954
- exit 1, with `PIOT_PUBLISH_EMPTY_PLAN` in stderr. A healthy release
2955
- where the plan job's matrix is non-empty is unaffected.
2956
-
2957
- ### npm `build` accepts array of entries
2958
-
2959
- **Summary.** `kind = "npm"` packages can now declare `build` as an array
2960
- of entries to publish multiple per-platform package families from a
2961
- single main package — for example, a napi-rs Node addon plus a CLI
2962
- binary, both selected via `optionalDependencies` on a shared top-level
2963
- package. Each entry has a `mode` (`napi` / `bundled-cli`) and an
2964
- optional `name` template (e.g. `"@scope/lib-{triple}"`) that the
2965
- consumer fully controls. The previous single-mode string form is
2966
- preserved.
2967
-
2968
- **Required changes.** None. `build = "napi"` and `build = "bundled-cli"`
2969
- keep producing the same per-platform package names, the same artifact
2970
- directory layout, and the same matrix shape they did before. Adopt the
2971
- array form only if you need a multi-family npm package.
2972
-
2973
- | Field | Before | After |
2974
- |---|---|---|
2975
- | `build` (single mode) | `build = "napi"` | unchanged — `build = "napi"` still valid |
2976
- | `build` (single mode, array form) | _new_ | `build = ["napi"]` — equivalent to the string form |
2977
- | `build` (single mode, custom name) | _new_ | `build = [{ mode = "napi", name = "@scope/lib-{triple}" }]` |
2978
- | `build` (multi mode) | _new_ | `build = [{ mode = "napi", name = "@scope/lib-{triple}" }, { mode = "bundled-cli", name = "@scope/cli-{triple}" }]` |
2979
-
2980
- Variables in `name` templates: `{name}`, `{scope}`, `{base}`,
2981
- `{triple}`, `{mode}`. `{triple}` is required in every template.
2982
- `{version}` is not surfaced — platform package names are immutable
2983
- identifiers; the version is pinned via `optionalDependencies`.
2984
-
2985
- **Validation rules** enforced at config load:
2986
-
2987
- - Each `mode` value (`napi`, `bundled-cli`) appears at most once per package.
2988
- - Every `name` template must contain `{triple}`.
2989
- - Unknown placeholders are rejected.
2990
- - Templates across entries must be pairwise distinct (collision-free).
2991
-
2992
- **Multi-mode artifact layout.** When `build` has more than one entry,
2993
- the build-side artifact directory and path get a mode infix to keep
2994
- families separate:
2995
-
2996
- ```
2997
- artifacts/
2998
- my-cli-napi-linux-x64-gnu/ # napi family
2999
- my-cli-bundled-cli-linux-x64-gnu/ # bundled-cli family
3000
- ```
3001
-
3002
- The build job for a multi-mode row writes to
3003
- `<pkg.path>/build/<mode>-<triple>/`. Single-mode (string form or
3004
- length-1 array) still uses `<pkg.path>/build/<triple>/` —
3005
- byte-for-byte unchanged.
3006
-
3007
- **Trusted-publisher registrations.** Each platform package across
3008
- *every* family needs its own npm trusted-publisher registration. A
3009
- multi-mode package with N families × M targets needs N×M registrations
3010
- plus one for the top-level. There's no shorthand on npm's side; this
3011
- is the cost of the dual-family install pattern.
3012
-
3013
- **Deprecations removed.** None.
3014
-
3015
- **Behavior changes without code changes.** None for single-mode
3016
- configs. Multi-mode is new surface — no prior behavior to compare
3017
- against.
3018
-
3019
- **Verification.** For an existing single-mode config, `putitoutthere
3020
- plan` should emit identical matrix rows before and after the upgrade
3021
- (same `artifact_name`, same `artifact_path`, same `target`). For a
3022
- new multi-mode config, you should see one matrix row per `(mode,
3023
- triple)` plus a single `target = "main"` row, and the matrix
3024
- `artifact_name` should carry the mode infix
3025
- (`<name>-<mode>-<triple>`).
3026
-
3027
- ---
3028
-
3029
- ## v0.1.51 → v0.2.0
3030
-
3031
- ### Publish job rebuilds npm packages from source
3032
-
3033
- **Summary.** Vanilla npm packages were publishing with their compiled
3034
- output (`dist/`, `lib/`, etc.) missing from the tarball. The plan
3035
- emitted `artifact_path: package.json` for noarch npm rows, so the
3036
- build job's compile output was never uploaded — and the publish job's
3037
- fresh checkout had no compiled files. `npm publish` doesn't validate
3038
- `files` content, so the broken artifact reached the registry. Caught
3039
- in the wild on a downstream consumer. The publish job now installs
3040
- deps and runs `npm run build --if-present` per npm package path
3041
- before invoking the engine — the same logic the build job already
3042
- runs, just at the point where it actually matters.
3043
-
3044
- **Required changes.** None for consumers calling the reusable
3045
- workflow at `thekevinscott/putitoutthere/.github/workflows/release.yml@v0`.
3046
- The fix is internal to the reusable workflow.
3047
-
3048
- **Deprecations removed.** None.
3049
-
3050
- **Behavior changes without code changes.** The publish job now spends
3051
- additional time on `npm install` + `npm run build` for each npm
3052
- package in the plan. For repos whose package.json had no `build`
3053
- script, behavior is unchanged (`--if-present` skips). For repos that
3054
- did declare a build script, the published tarball now contains
3055
- whatever the build emits — which may be the first time the registry
3056
- artifact actually matches what the package author intended. If your
3057
- prior releases were unknowingly broken (compiled output missing), the
3058
- next release will fix them; verify by inspecting the next published
3059
- tarball with `npm view <pkg>@<ver>` + `npm pack <pkg>@<ver>`.
3060
-
3061
- **Verification.** After upgrading, a release run logs an `npm
3062
- install + build at <path>` group per npm package in the plan. The
3063
- published tarball contains every directory listed in package.json
3064
- `files[]`:
3065
-
3066
- ```sh
3067
- npm pack <pkg>@<new-version> --dry-run 2>&1 | grep -E '(dist|lib|build)/'
3068
- ```
3069
-
3070
- ### Reusable workflow + `action.yml` move to Node 24 actions
3071
-
3072
- **Summary.** GitHub deprecated Node 20 actions in September 2025; the
3073
- hosted runner forces Node 24 starting June 2, 2026 and removes Node 20
3074
- entirely on September 16, 2026.
3075
- Every workflow run that called `putitoutthere` was emitting deprecation
3076
- warnings — one per job inside the reusable workflow, plus a top-level
3077
- `Actions running on Node.js 20` warning attributed to
3078
- `thekevinscott/putitoutthere@v0` itself, which the consumer could not
3079
- fix locally. The reusable workflow's pinned action majors and the JS
3080
- action's `runs.using` now target Node 24-compatible versions.
3081
-
3082
- | Action | Before | After |
3083
- |---|---|---|
3084
- | `actions/checkout` | `@v4` | `@v6` |
3085
- | `actions/setup-node` | `@v4` | `@v6` |
3086
- | `actions/setup-python` | `@v5` | `@v6` |
3087
- | `actions/upload-artifact` | `@v4` | `@v7` |
3088
- | `actions/download-artifact` | `@v4` | `@v8` |
3089
- | `action.yml` `runs.using` | `node20` | `node24` |
3090
-
3091
- **Required changes.** Consumers calling the reusable workflow at
3092
- `thekevinscott/putitoutthere/.github/workflows/release.yml@v0` get the
3093
- new pins automatically — no consumer-side YAML changes required. The
3094
- caller-side `pypi-publish` job in the canonical template now uses
3095
- `actions/download-artifact@v8`; existing copies still pinned at `@v4`
3096
- keep working but should be bumped to silence the same deprecation
3097
- warning in the consumer's own workflow file:
3098
-
3099
- ```diff
3100
- pypi-publish:
3101
- ...
3102
- steps:
3103
- - - uses: actions/download-artifact@v4
3104
- + - uses: actions/download-artifact@v8
3105
- with:
3106
- pattern: '*-sdist'
3107
- ...
3108
- - - uses: actions/download-artifact@v4
3109
- + - uses: actions/download-artifact@v8
3110
- with:
3111
- pattern: '*-wheel-*'
3112
- ...
3113
- ```
3114
-
3115
- **Deprecations removed.** None.
3116
-
3117
- **Behavior changes without code changes.** Reusable workflow jobs now
3118
- run under Node 24 instead of Node 20. The artifact contract is
3119
- unchanged — `download-artifact@v8` preserves the per-name subdirectory
3120
- layout (`artifacts/<artifact-name>/<file>`) for downloads-by-name, and
3121
- `upload-artifact@v7`'s default still produces zipped uploads keyed by
3122
- the `name:` parameter. `download-artifact@v8` now fails on artifact
3123
- hash mismatches by default (was a warning in `@v4`); this is an
3124
- integrity check, not a behavior change for healthy uploads.
3125
-
3126
- **Verification.** A consumer release run no longer emits the
3127
- `Actions running on Node.js 20 ... thekevinscott/putitoutthere@v0`
3128
- deprecation warning, nor the per-job warnings against `actions/checkout@v4`
3129
- et al. Tag, GitHub Release, and registry uploads occur as before.
3130
-
3131
- ---
3132
-
3133
- ### PyPI uploads moved to caller-side job
3134
-
3135
- **Summary.** PyPI's Trusted Publisher matching filters candidates by
3136
- `repository_owner` + `repository_name` *before* checking
3137
- `job_workflow_ref`
3138
- ([Warehouse implementation](https://github.com/pypi/warehouse/blob/main/warehouse/oidc/models/github.py)).
3139
- The OIDC `repository` claim always reflects the caller's repo —
3140
- including inside a reusable workflow — so a TP registered against
3141
- the reusable workflow's repo is filtered out before workflow_ref
3142
- is even checked. PyPI documents this as unsupported
3143
- ([troubleshooting](https://docs.pypi.org/trusted-publishers/troubleshooting/)).
3144
- Tracked at [pypi/warehouse#11096](https://github.com/pypi/warehouse/issues/11096),
3145
- no timeline.
3146
-
3147
- To preserve OIDC trusted publishing for PyPI without setting
3148
- `PYPI_API_TOKEN`, the upload step (`pypa/gh-action-pypi-publish`)
3149
- now runs in the consumer's own workflow file as a second job,
3150
- gated on the new `has_pypi` output. The engine still owns plan,
3151
- build, version-rewrite, and git-tag creation for PyPI rows; only
3152
- the actual upload moves. See
3153
- [`notes/audits/2026-04-28-pypi-tp-reusable-workflow-constraint.md`](./notes/audits/2026-04-28-pypi-tp-reusable-workflow-constraint.md)
3154
- for the full diagnosis.
3155
-
3156
- **Required changes.** Update `.github/workflows/release.yml`:
3157
-
3158
- Before (~12 lines):
3159
-
3160
- ```yaml
3161
- name: Release
3162
- on:
3163
- push:
3164
- branches: [main]
3165
- jobs:
3166
- release:
3167
- uses: thekevinscott/putitoutthere/.github/workflows/release.yml@v0
3168
- permissions:
3169
- contents: write
3170
- id-token: write
3171
- ```
3172
-
3173
- After (~30 lines, single copy-paste from README → Quickstart):
3174
-
3175
- ```yaml
3176
- name: Release
3177
- on:
3178
- push:
3179
- branches: [main]
3180
- jobs:
3181
- release:
3182
- uses: thekevinscott/putitoutthere/.github/workflows/release.yml@v0
3183
- permissions:
3184
- contents: write
3185
- id-token: write
3186
-
3187
- pypi-publish:
3188
- needs: release
3189
- if: needs.release.outputs.has_pypi == 'true'
3190
- runs-on: ubuntu-latest
3191
- permissions:
3192
- id-token: write
3193
- steps:
3194
- - uses: actions/download-artifact@v8
3195
- with:
3196
- pattern: '*-sdist'
3197
- path: dist/
3198
- merge-multiple: true
3199
- - uses: actions/download-artifact@v8
3200
- with:
3201
- pattern: '*-wheel-*'
3202
- path: dist/
3203
- merge-multiple: true
3204
- - uses: pypa/gh-action-pypi-publish@release/v1
3205
- ```
3206
-
3207
- The `pypi-publish` job's `if:` gate skips it for non-PyPI repos —
3208
- paste verbatim regardless of what you publish. Crates.io and npm
3209
- are unaffected; their TP claim semantics work fine inside the
3210
- reusable workflow.
3211
-
3212
- **No PyPI TP re-registration required.** Your existing TP
3213
- registration (against your repo, your `release.yml`, optional
3214
- environment) was already correct for this pattern. If you'd
3215
- attempted to register a TP against `thekevinscott/putitoutthere`
3216
- to work around the prior failure, remove that entry — it would
3217
- have never matched anyway.
3218
-
3219
- **Deprecations removed.** None.
3220
-
3221
- **Behavior changes without code changes.** PyPI upload step now
3222
- runs in the consumer's workflow context. The reusable workflow's
3223
- publish job no longer installs `twine` or `setup-python`; engine
3224
- log lines for PyPI rows now read "delegated to caller-side upload
3225
- step" instead of "authenticating via OIDC".
3226
-
3227
- **Verification.** Push a release. The reusable workflow's
3228
- `release` job creates and pushes the git tag for PyPI rows; the
3229
- caller's `pypi-publish` job runs `pypa/gh-action-pypi-publish`
3230
- and uploads to PyPI. Check `https://pypi.org/project/<name>/<version>/`
3231
- to confirm.
3232
-
3233
- ---
3234
-
3235
- ### PyPI artifact discovery matches `{name}-sdist` and `{name}-wheel-` exactly
3236
-
3237
- **Summary.** `src/handlers/pypi.ts:collectArtifacts` used a bare prefix
3238
- match (`entry.startsWith("{name}-")`) to find a package's artifact
3239
- directories under `artifacts/`. Sibling packages whose names extended
3240
- the same prefix (`foo` and `foo-extras`) collided: `foo`'s discovery
3241
- also picked up `foo-extras-sdist`, and twine then uploaded the sibling's
3242
- tarball under `foo`'s OIDC identity, failing PyPI's project-name check.
3243
- The handler now matches the sdist directory exactly (`{name}-sdist`)
3244
- and the wheel directories by `{name}-wheel-` prefix only — the two
3245
- shapes the planner documents in §12.4.
3246
-
3247
- **Required changes.** None.
3248
-
3249
- **Deprecations removed.** None.
3250
-
3251
- **Behavior changes without code changes.** Repos with multiple pypi
3252
- packages where one name is a prefix of another (e.g. `foo` and
3253
- `foo-extras`) no longer cross-upload artifacts. Single-package repos
3254
- and repos with non-overlapping names are unaffected.
3255
-
3256
- **Verification.** A repo declaring both `foo` and `foo-extras` as
3257
- pypi packages publishes the correct tarballs to each project; neither
3258
- job uploads the other's artifacts.
3259
-
3260
- ---
3261
-
3262
- ### Reusable workflow's maturin sdist row uses `command: sdist`
3263
-
3264
- **Summary.** The reusable workflow's pypi-maturin build step was a single
3265
- `PyO3/maturin-action@v1` invocation with `command: build` and an
3266
- `--sdist` flag conditional on the row being the sdist target. `maturin
3267
- build --sdist` is documented as "build a wheel AND an sdist" — the
3268
- sdist's artifact directory ended up containing both a `.tar.gz` and a
3269
- manylinux wheel, which collided at upload time with the per-target
3270
- wheel rows and aborted twine with `400 File already exists`. The build
3271
- step is now split into two: `command: sdist` for the sdist row
3272
- (sdist-only) and `command: build` with `--target` for wheel rows.
3273
-
3274
- **Required changes.** None.
3275
-
3276
- **Deprecations removed.** None.
3277
-
3278
- **Behavior changes without code changes.** Maturin packages with a
3279
- `sdist` row in their plan now upload a single `.tar.gz` from that row,
3280
- not a wheel-plus-sdist pair. Per-target wheel rows are unaffected.
3281
-
3282
- **Verification.** A maturin-built package with `sdist` in `targets`
3283
- publishes to PyPI without `400 File already exists`. The sdist
3284
- artifact directory contains `.tar.gz` only.
3285
-
3286
- ---
3287
-
3288
- ### Synthesized npm platform packages inherit `repository`/`license`/`homepage`
3289
-
3290
- **Summary.** npm's provenance verifier rejected platform-package tarballs
3291
- with `E422 Error verifying sigstore provenance bundle: Failed to validate
3292
- repository information: package.json: "repository.url" is ""`. The
3293
- synthesizer in `src/handlers/npm-platform.ts` previously wrote only
3294
- `name`/`version`/`os`/`cpu`/`files`/`main`/`libc` into the per-target
3295
- `package.json`. The publishing GitHub repo URL is bound into the
3296
- sigstore bundle by `npm publish --provenance`; npm cross-checks it
3297
- against `package.json.repository.url` at upload time, so an empty value
3298
- fails verification. Identity fields (`repository`, `license`, `homepage`)
3299
- are now read from the main package's `package.json` and copied into each
3300
- synthesized platform package. Affects `build = "napi"` and
3301
- `build = "bundled-cli"` packages.
3302
-
3303
- **Required changes.** None — the fix is automatic. To benefit, ensure
3304
- the main package's `package.json` declares a `repository.url` that
3305
- matches the publishing repo (npm provenance has always required this for
3306
- the main package; platform packages now share the same expectation).
3307
-
3308
- **Deprecations removed.** None.
3309
-
3310
- **Behavior changes without code changes.** Per-target platform tarballs
3311
- on the registry now carry the same `repository`/`license`/`homepage`
3312
- values as the main package, instead of being absent.
3313
-
3314
- **Verification.** A `build = "napi"` or `build = "bundled-cli"` package
3315
- publishes its platform tarballs to npm without `E422` provenance errors.
3316
- `npm view <pkg>-<target>@<version> repository` returns the main
3317
- package's repository URL.
3318
-
3319
- ---
3320
-
3321
- ### Reusable workflow's npm build step forces `shell: bash`
3322
-
3323
- **Summary.** The build matrix can target Windows runners. GitHub Actions
3324
- defaults to `pwsh` for `run:` blocks on Windows, but the npm build's
3325
- shape detection (`if [ -f package-lock.json ]; then npm ci; elif ... fi`)
3326
- is bash syntax — PowerShell parsed it as a malformed expression and
3327
- aborted with `ParserError` before any package manager ran. The step now
3328
- sets `shell: bash` explicitly, which is portable across Linux, macOS,
3329
- and Windows runners (Git Bash ships on `windows-latest`).
3330
-
3331
- **Required changes.** None.
3332
-
3333
- **Deprecations removed.** None.
3334
-
3335
- **Behavior changes without code changes.** Consumers whose plan includes
3336
- an npm package targeting Windows runners (e.g. native node-addon shapes,
3337
- `napi-rs` matrices) now succeed past the install step. Linux/macOS-only
3338
- matrices are unaffected — bash was already the default there.
3339
-
3340
- **Verification.** An npm package with a Windows row in its plan
3341
- completes the install + build step on `windows-latest`; the job log
3342
- shows `Run if [ -f package-lock.json ]` executing under bash, not pwsh.
3343
-
3344
- ---
3345
-
3346
- ### Reusable workflow exchanges OIDC token for `CARGO_REGISTRY_TOKEN`
3347
-
3348
- **Summary.** Crates publishes were failing with `error: no token found,
3349
- please run cargo login` — the reusable workflow was relying on cargo to
3350
- find an OIDC token in env, but cargo only consumes
3351
- `CARGO_REGISTRY_TOKEN` (a registry-issued bearer), not raw OIDC
3352
- ID-tokens. The publish job now runs `rust-lang/crates-io-auth-action@v1`
3353
- when the plan contains a crates row and exports its `outputs.token`
3354
- as `CARGO_REGISTRY_TOKEN` for the engine subprocess.
3355
-
3356
- **Required changes.** None for consumers using the reusable workflow as
3357
- documented. Repos publishing to crates.io must have a configured trusted
3358
- publisher on crates.io pointing at their `release.yml` — same prerequisite
3359
- as before, just now actually exercised.
3360
-
3361
- **Deprecations removed.** None.
3362
-
3363
- **Behavior changes without code changes.** Crates publish in the
3364
- reusable workflow now reaches the registry; previously it failed at
3365
- the cargo invocation. JS/Python-only repos are unaffected — the auth
3366
- step is gated on `contains(needs.plan.outputs.matrix, '"kind":"crates"')`
3367
- and skips entirely when no crates row is in the plan.
3368
-
3369
- **Verification.** A `kind = "crates"` package whose trusted publisher is
3370
- configured on crates.io now publishes successfully through the reusable
3371
- workflow. The publish job log shows the `Authenticate with crates.io
3372
- (OIDC)` step running before `putitoutthere publish`.
3373
-
3374
- ---
3375
-
3376
- ### Crates publish's pre-cargo dirty-tree check ignores `artifacts/`
3377
-
3378
- **Summary.** The crates handler scans `git status --porcelain` before
3379
- invoking `cargo publish --allow-dirty`, refusing to proceed if anything
3380
- other than the managed `Cargo.toml` is dirty (the writeVersion bump
3381
- runs in the same job and would otherwise be the only legitimate dirty
3382
- file). The reusable workflow's `actions/download-artifact@v4` step
3383
- always creates `artifacts/` at the repo root before publish runs —
3384
- even for crates-only fixtures that have nothing to download — and the
3385
- pre-check was rejecting on `?? artifacts/`. The scan now treats the
3386
- engine's own `artifactsRoot` as managed scratch space and skips files
3387
- under it.
3388
-
3389
- **Required changes.** None.
3390
-
3391
- **Deprecations removed.** None.
3392
-
3393
- **Behavior changes without code changes.** Crates publishes that
3394
- previously errored with `unexpected dirty files in the working tree
3395
- outside <Cargo.toml>: - artifacts/` now proceed to `cargo publish`.
3396
- Stray edits anywhere else in the tree still fail the check.
3397
-
3398
- **Verification.** A `kind = "crates"` package in a repo whose only
3399
- "dirty" file (alongside the managed `Cargo.toml`) is the engine's
3400
- `artifacts/` directory now reaches cargo. `git status --porcelain`
3401
- showing `?? artifacts/` is no longer fatal.
3402
-
3403
- ---
3404
-
3405
- ### Crates publish no longer fails the pre-publish completeness check
3406
-
3407
- **Summary.** Consumers with a `kind = "crates"` package previously hit
3408
- `Artifact completeness check failed: missing artifact directory
3409
- <name>-crate/` before cargo was ever invoked. The reusable workflow
3410
- does not upload a `.crate` artifact (cargo packages and uploads from
3411
- source on the registry side), so the file the check demanded never
3412
- existed in the pipeline. The completeness check now skips crates
3413
- rows. Same reasoning as vanilla npm rows, which were already skipped.
3414
-
3415
- **Required changes.** None.
3416
-
3417
- **Deprecations removed.** None.
3418
-
3419
- **Behavior changes without code changes.** Crates publishes that
3420
- previously errored at the completeness gate now reach `cargo publish`.
3421
- A crates row whose source tree is genuinely broken still fails — the
3422
- failure just happens at the cargo step, not before.
3423
-
3424
- **Verification.** A `kind = "crates"` package in
3425
- `putitoutthere.toml` no longer requires any artifact upload step in
3426
- the consumer's workflow. Trigger a release with a `release: patch`
3427
- trailer; the publish job's "Run putitoutthere publish" step should
3428
- log `crates: cargo publish ...` instead of aborting on completeness.
3429
-
3430
- ### `[[package]].paths` renamed to `globs`
3431
-
3432
- **Summary.** The `path` / `paths` pair in `[[package]]` was confusing —
3433
- singular and plural differed only in a trailing `s` while meaning two
3434
- unrelated things (the package working directory vs. the cascade-trigger
3435
- globs). Renaming `paths` → `globs` removes the trailing-S collision.
3436
-
3437
- **Required changes.**
3438
-
3439
- | Before | After |
3440
- |-----|-----|
3441
- | `paths = ["src/**", "pyproject.toml"]` | `globs = ["src/**", "pyproject.toml"]` |
3442
-
3443
- Every `[[package]]` block in `putitoutthere.toml` needs the rename.
3444
- Configs declaring `paths` now fail validation under `.strict()`.
3445
-
3446
- **Deprecations removed.** None.
3447
-
3448
- **Behavior changes without code changes.** None — the field's semantics
3449
- are unchanged.
3450
-
3451
- **Verification.** `pnpm exec putitoutthere plan` (or the next reusable-
3452
- workflow run) loads cleanly. A config still declaring `paths` fails
3453
- load with a Zod error pointing at the unknown key.
3454
-
3455
- ### Removed: diagnostic CLI surface, GitHub-App auth, trust-policy validation
3456
-
3457
- **Summary.** Eight things removed in one pass, none consumer-observable
3458
- under the new "reusable workflow + OIDC-only" surface:
3459
-
3460
- - `[package.trust_policy]` config block (false security: typo-catcher
3461
- for npm/PyPI; the only real check was the crates.io registry
3462
- cross-check, which required a separate token most consumers wouldn't
3463
- set up).
3464
- - `putitoutthere doctor` subcommand (its main job was the trust-policy
3465
- validation above).
3466
- - `putitoutthere preflight` subcommand (the internal `requireAuth`
3467
- gate inside `publish` is preserved).
3468
- - `putitoutthere token list/inspect` subcommands (operator-debugging
3469
- surface for long-lived registry tokens — none exist under OIDC-only).
3470
- - `putitoutthere auth login/logout/status` subcommands + the
3471
- `putitoutthere-cli` GitHub App's device-flow plumbing + the keyring
3472
- (only purpose was powering `token list --secrets`).
3473
- - `src/release.ts` engine-side GitHub Release creation (duplicated by
3474
- the reusable workflow's `gh release create --generate-notes` step).
3475
- - `publish --preflight-check` flag (deep token-scope check for
3476
- long-lived tokens; OIDC-only renders it moot).
3477
- - Dead config fields: `cadence`, `agents_path`, `smoke`,
3478
- `wheels_artifact` — defined in the schema, never read.
3479
-
3480
- Net: ~2,800 lines of source removed, ~17% of `src/`.
3481
-
3482
- **Required changes.**
3483
-
3484
- | Before | After |
3485
- |-----|-----|
3486
- | `[package.trust_policy] workflow = "release.yml"` | Delete the block. Workflow renames still produce HTTP 400 from registries — same UX every other tool gives you. |
3487
- | `putitoutthere doctor` / `preflight` / `token` / `auth` invocations in any consumer script | Remove. None of these are reachable through the reusable workflow; consumer-facing surface is the workflow itself. |
3488
- | `cadence`, `agents_path`, `smoke`, `wheels_artifact` fields in `putitoutthere.toml` | Delete. They were never consumed; configs declaring them now fail validation under `.strict()`. |
3489
- | `--preflight-check` flag passed to `publish` | Drop. Internal `requireAuth` still gates publish. |
3490
-
3491
- **Deprecations removed.** Everything in the list above.
3492
-
3493
- **Behavior changes without code changes.** Engine behavior on the
3494
- plan / publish path is unchanged. `requireAuth` (the gate that
3495
- catches missing OIDC env or missing token) still runs; the deep
3496
- scope check (which required a long-lived token to inspect) no
3497
- longer runs because there's no long-lived token to inspect. GitHub
3498
- Release creation moves entirely to the reusable workflow's
3499
- `gh release create` step — engines invoked outside that workflow
3500
- (local dry-runs, custom integrations) no longer create Releases.
3501
-
3502
- **Verification.** A consumer who never used any of the removed
3503
- surfaces sees no observable change. Consumers who used `doctor` or
3504
- `token` subcommands see exit-1 + "unknown command"; switch to the
3505
- reusable workflow.
3506
-
3507
- ### Public surface collapsed to a reusable workflow
3508
-
3509
- **Summary.** The consumer surface is now one line in a `release.yml`:
3510
-
3511
- ```yaml
3512
- on:
3513
- push: { branches: [main] }
3514
-
3515
- jobs:
3516
- release:
3517
- uses: thekevinscott/putitoutthere/.github/workflows/release.yml@v0
3518
- permissions:
3519
- contents: write
3520
- id-token: write
3521
- ```
3522
-
3523
- Plus the consumer's existing `putitoutthere.toml`. Triggers live in
3524
- the consumer's file; everything below them — pinned action versions,
3525
- plan/build/publish orchestration, runner toolchain setup, artifact
3526
- upload/download, GitHub Release creation — lives in the reusable
3527
- workflow that piot ships. The CLI and the JS action are internal
3528
- seams the reusable workflow invokes; consumers do not call them.
3529
- Auth is OIDC trusted publishers only — long-lived registry tokens
3530
- are not reachable through the workflow. See [design
3531
- commitments](https://github.com/thekevinscott/putitoutthere/blob/main/notes/design-commitments.md)
3532
- for the authoritative non-goals.
3533
-
3534
- **Required changes.**
3535
-
3536
- | Before (hand-written `release.yml`) | After |
3537
- |-----|-----|
3538
- | ~100 lines of YAML: plan/build/publish jobs, twine install, git identity, GitHub Release backfill, hand-pinned action majors | `uses: thekevinscott/putitoutthere/.github/workflows/release.yml@v0` |
3539
- | `putitoutthere init` to scaffold the workflow | Subcommand removed; consumers add the snippet above by hand |
3540
- | `[[package]].build_workflow = "publish-foo.yml"` for unsupported shapes | Removed. Shapes that don't fit piot's named build modes write their own release workflow that doesn't use piot |
3541
- | Long-lived registry tokens (`NPM_TOKEN`, `PYPI_API_TOKEN`, `CARGO_REGISTRY_TOKEN`) passed to a hand-written publish step | Not reachable through the reusable workflow. Register an OIDC trusted publisher per registry once |
3542
- | Optional inputs `dry_run`, `working_directory`, `config` | Removed. Plan job already prints the matrix without side effects; config lives at `putitoutthere.toml` in the repo root, no override |
3543
- | Documentation site (`docs/`) | Removed. README is the single user-facing surface; `notes/internals/` holds the contracts the reusable workflow honors so consumers don't have to know them |
3544
-
3545
- **Deprecations removed.** `build_workflow:` is no longer in the
3546
- config schema (`src/config.ts`); configs that declare it now fail
3547
- validation. `putitoutthere init`, `--cadence`, and `--force` flags
3548
- are removed from the CLI.
3549
-
3550
- **Behavior changes without code changes.** Engine behavior (plan,
3551
- cascade, version bump, registry handlers, completeness check,
3552
- idempotency, OIDC trust-policy validation) is unchanged. The
3553
- reusable workflow internally pins:
3554
-
3555
- - `actions/checkout@v4` (`fetch-depth: 0`)
3556
- - `actions/setup-node@v4`
3557
- - `actions/setup-python@v5`
3558
- - `actions/upload-artifact@v4`
3559
- - `actions/download-artifact@v4`
3560
- - `PyO3/maturin-action@v1`
3561
-
3562
- If a consumer was running newer majors (e.g. one consumer hit
3563
- `download-artifact@v8` defaults that broke the artifact-naming
3564
- contract), the reusable workflow standardises everyone on the
3565
- known-tested versions.
3566
-
3567
- **Verification.**
3568
-
3569
- - `pnpm test:unit` passes in the main repo.
3570
- - A consumer's first cutover: drop in the 12-line `release.yml`
3571
- shown above, push a commit that touches a `[[package]].globs`
3572
- glob, and watch for a tag push + GitHub Release on the next
3573
- workflow run.
3574
-
3575
- ### Publish path works end-to-end for slash-containing `pkg.name`
3576
-
3577
- **Summary.** Follow-up to the [`/`-encoding fix](#package-names-with--no-longer-need-an-encode-decode-workaround)
3578
- ([#230](https://github.com/thekevinscott/putitoutthere/issues/230)).
3579
- Two bugs prevented slash-containing names from actually publishing
3580
- even after the planner started encoding `/` to `__`
3581
- ([#237](https://github.com/thekevinscott/putitoutthere/issues/237)):
3582
-
3583
- 1. The pypi handler (`src/handlers/pypi.ts`) and the npm-platform
3584
- synthesizer (`src/handlers/npm-platform.ts`) both built artifact
3585
- directory lookups from the raw `pkg.name`, so a package called
3586
- `py/foo` couldn't match the encoded on-disk directory
3587
- `py__foo-sdist/`. Symptom: `pypi: no artifacts found for py/foo
3588
- under <root>` at publish time.
3589
- 2. The planner emitted glob-shaped `artifact_path` values for crates
3590
- tarballs, pypi sdists, and pypi wheels (e.g.
3591
- `${pkg.path}/dist/*.tar.gz`). `actions/upload-artifact@v4` treats
3592
- a glob `path:` differently from a directory `path:` — it preserves
3593
- the workspace-relative path, so the sdist landed at
3594
- `artifacts/<name>/packages/python/dist/foo.tar.gz` instead of
3595
- `artifacts/<name>/foo.tar.gz`. Even after fix (1), the publish
3596
- handler couldn't find files inside that nested layout.
3597
-
3598
- Both fixed:
3599
-
3600
- - Handlers route directory lookups through `sanitizeArtifactName`,
3601
- matching whatever the planner emitted on the matrix row.
3602
- - Handlers walk the artifact directory recursively for the expected
3603
- file extensions (`.tar.gz` / `.whl` / `.crate`), so any layout
3604
- (flat or nested) works.
3605
- - Planner emits directory-shaped `artifact_path` values for the
3606
- three slots that used a glob:
3607
-
3608
- | Slot | Before | After |
3609
- |---|---|---|
3610
- | crates tarball | `${pkg.path}/target/package/*.crate` | `${pkg.path}/target/package` |
3611
- | pypi maturin wheel | `${pkg.path}/dist/*.whl` | `${pkg.path}/dist` |
3612
- | pypi sdist | `${pkg.path}/dist/*.tar.gz` | `${pkg.path}/dist` |
3613
-
3614
- **Required changes.**
3615
-
3616
- - **None for repos that pass `matrix.artifact_path` straight through**
3617
- to `actions/upload-artifact@v4` (the canonical pattern shown in
3618
- `docs/guide/shapes/*`). The matrix field already carries the new
3619
- directory shape; on-disk artifact layout becomes flat
3620
- (`<name>/foo.tar.gz` instead of `<name>/packages/python/dist/foo.tar.gz`),
3621
- but consumer workflows see no observable change.
3622
- - **Repos that hand-coded a glob path** should switch to the
3623
- directory shape (or — better — replace the hard-coded value with
3624
- the matrix field):
3625
-
3626
- ```diff
3627
- - uses: actions/upload-artifact@v4
3628
- with:
3629
- name: ${{ matrix.artifact_name }}
3630
- - path: packages/python/dist/*.tar.gz
3631
- + path: ${{ matrix.artifact_path }} # or "packages/python/dist"
3632
- ```
3633
-
3634
- The recursive reader keeps glob layouts working as a safety net,
3635
- but the directory shape is the canonical contract going forward.
3636
-
3637
- **Deprecations removed.** None.
3638
-
3639
- **Behavior changes without code changes.**
3640
-
3641
- - Artifact directory layout is now flat: `artifacts/<name>/<file>`
3642
- instead of `artifacts/<name>/<workspace-relative-path>/<file>`.
3643
- Anything reading the artifact tree (the docs page, debugging
3644
- scripts, custom verification jobs) should expect files at the
3645
- artifact root.
3646
- - The publish-side handlers now walk subdirectories recursively
3647
- when looking for `.whl` / `.tar.gz` / `.crate` files. This is
3648
- defensive for consumers whose build steps write to a non-standard
3649
- location inside `<name>/`; the planner's directory `artifact_path`
3650
- remains the canonical contract.
3651
-
3652
- **Verification.**
3653
-
3654
- ```sh
3655
- putitoutthere plan --json | jq '.[] | {name, artifact_name, artifact_path}'
3656
- ```
3657
-
3658
- Expect every `artifact_path` to be a plain directory (no `*`):
3659
-
3660
- ```json
3661
- { "name": "py/foo", "artifact_name": "py__foo-sdist", "artifact_path": "py/foo/dist" }
3662
- ```
3663
-
3664
- After the next release run, the `actions/upload-artifact@v4` step
3665
- uploads `py/foo/dist/` contents flat under
3666
- `artifacts/py__foo-sdist/` (no nested `packages/python/dist/`
3667
- prefix), and the publish step finds the sdist immediately.
3668
-
3669
- ### Scaffolded `release.yml` now forwards `GITHUB_TOKEN`
3670
-
3671
- **Summary.** piot has supported cutting a GitHub Release alongside each
3672
- tag push since #26, but the scaffolded `release.yml` template never
3673
- forwarded `GITHUB_TOKEN` to the publish step. GitHub Actions does not
3674
- auto-mount the runner token as an env var — `permissions: contents:
3675
- write` only grants the token *scope* to write Releases; the token still
3676
- has to be exposed via `env:` for piot's `release.ts` to read it from
3677
- `process.env.GITHUB_TOKEN`. Without it, piot silent-skipped Release
3678
- creation and consumers got tags but no Release entries on the repo's
3679
- Releases page. Fresh `piot init` runs now scaffold the env line.
3680
-
3681
- **Required changes.** Existing repos that ran `piot init` before this
3682
- change need a one-line addition to `.github/workflows/release.yml`:
3683
-
3684
- ```diff
3685
- - uses: thekevinscott/putitoutthere@v0
3686
- with:
3687
- command: publish
3688
- dry_run: ${{ inputs.dry_run || 'false' }}
3689
- env:
3690
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
3691
- CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_TOKEN }}
3692
- PYPI_API_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
3693
- + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
3694
- ```
3695
-
3696
- The publish job already declares `permissions: contents: write`, which
3697
- is the scope GitHub's runner-supplied `GITHUB_TOKEN` needs to create
3698
- Releases — no additional permission changes required.
3699
-
3700
- **Deprecations removed.** None.
3701
-
3702
- **Behavior changes without code changes.** Repos that adopt the new
3703
- template (or apply the diff above) start seeing GitHub Release entries
3704
- appear under the repo's `/releases` page after each publish. The
3705
- Release body is the output of:
3706
-
3707
- ```sh
3708
- git log <prev-tag>..<this-tag> --format='- %s' --no-merges
3709
- ```
3710
-
3711
- Tags suffixed with `-rc`, `-beta`, or `-alpha` are flagged
3712
- `prerelease: true`. Release creation is best-effort: a 4xx/5xx from
3713
- the GitHub API surfaces as a `publish: GitHub Release creation
3714
- failed` warning but does not fail the publish run — the registry
3715
- publish and tag push remain authoritative.
3716
-
3717
- **Verification.** After the next release run on a repo that adopted the
3718
- fix:
3719
-
3720
- ```bash
3721
- # Inspect the publish job log:
3722
- # "publish: GitHub Release created at https://github.com/.../releases/tag/<name>-v<x.y.z>"
3723
-
3724
- # Or hit the API directly:
3725
- gh release view <name>-v<x.y.z> --repo <owner>/<repo>
3726
- ```
3727
-
3728
- If you previously saw the warning `publish: GitHub Release creation
3729
- failed` in your publish logs, the warning should be gone and the
3730
- Releases page should populate.
3731
-
3732
- ### Package names with `/` no longer need an encode/decode workaround
3733
-
3734
- **Summary.** Polyglot-monorepo repos that group packages by language
3735
- (e.g. `name = "py/foo"`, `"js/bar"`) used to fail at the build job
3736
- with:
3737
-
3738
- ```
3739
- The artifact name is not valid: py/foo-sdist.
3740
- Contains the following character: Forward slash /
3741
- ```
3742
-
3743
- …because `actions/upload-artifact@v4` forbids `/` in artifact names
3744
- and the planner emitted `artifact_name` verbatim from `pkg.name`
3745
- ([#230](https://github.com/thekevinscott/putitoutthere/issues/230)).
3746
- The planner now encodes each `/` to `__`
3747
- (`py/foo` → `py__foo-sdist`), so the build job's
3748
- upload-artifact step works without modification — pass the matrix
3749
- `artifact_name` field through verbatim and the encoding happens
3750
- upstream.
3751
-
3752
- **Required changes.**
3753
-
3754
- - **None for repos with slash-free `pkg.name`** — `artifact_name`
3755
- is byte-identical to the previous version.
3756
- - **Repos that ran a prior `/`-encoding workaround should remove it.** The planner now
3757
- does the encoding natively; leaving the workaround in place
3758
- produces double-encoded names like `py____foo-sdist`, which the
3759
- publish-side reader will treat as a missing artifact.
3760
-
3761
- ```diff
3762
- - uses: actions/upload-artifact@v4
3763
- with:
3764
- - name: ${{ format('{0}', matrix.artifact_name) }} # any sed/format encode
3765
- - path: ${{ matrix.artifact_path }}
3766
- + name: ${{ matrix.artifact_name }} # use the field as-is
3767
- + path: ${{ matrix.artifact_path }}
3768
- ```
3769
-
3770
- ```diff
3771
- - uses: actions/download-artifact@v4
3772
- with:
3773
- path: artifacts
3774
- - - name: Decode artifact dir names
3775
- - run: |
3776
- - # rename artifacts/py__foo-sdist back to artifacts/py/foo-sdist
3777
- - ...
3778
- ```
3779
-
3780
- **Deprecations removed.** None.
3781
-
3782
- **Behavior changes without code changes.**
3783
-
3784
- - `pkg.name` containing `__` (the new encoding sequence) is now
3785
- rejected at config load with: `package name must not contain "__"
3786
- (reserved: piot encodes "/" to "__" for artifact-name slots; pick
3787
- a different separator)`. If your config uses `__` in a package
3788
- name today, rename to use `-` or `_` and update any tags / consumer
3789
- references; piot can't safely sanitize it without ambiguity.
3790
- - `pkg.name` containing `\`, `:`, `<`, `>`, `|`, `*`, `?`, or `"`
3791
- is now rejected at config load. None of these are valid in npm,
3792
- PyPI, or crates.io names, so any config that previously contained
3793
- them was already broken at publish time — the change just moves
3794
- the failure earlier with a clearer message.
3795
-
3796
- **Verification.**
3797
-
3798
- ```sh
3799
- putitoutthere plan --json | jq '.[].artifact_name'
3800
- ```
3801
-
3802
- Expect every emitted `artifact_name` to contain only ASCII letters,
3803
- digits, `-`, `_`, and `.` — no `/` and no other forbidden chars.
3804
- For a repo with `name = "py/foo"`:
3805
-
3806
- ```
3807
- "py__foo-sdist"
3808
- "py__foo-wheel-x86_64-unknown-linux-gnu"
3809
- ```
3810
-
3811
- After the next release, the build job's `actions/upload-artifact@v4`
3812
- step uploads under `py__foo-sdist/` (a single flat directory
3813
- under `artifacts/`), and piot's publish-side reader consumes the
3814
- same path.
3815
-
3816
- ### Documentation accuracy pass (#231)
3817
-
3818
- **Summary.** A docs-vs-code audit found several places where reference
3819
- material lagged behind shipped behavior. Existing configs and workflows
3820
- keep working — the only consumer-observable change is that `putitoutthere
3821
- --help` no longer mislabels `--json` as "plan only".
3822
-
3823
- **Required changes.** None.
3824
-
3825
- **Deprecations removed.** None.
3826
-
3827
- **Behavior changes without code changes.**
3828
-
3829
- - `putitoutthere --help` output: the `--json` line now reads `emit
3830
- machine-readable output (most commands)` instead of `(plan only)`. The
3831
- flag has always been accepted on every command that emits a result;
3832
- only the help text was wrong.
3833
- - No other behavior changes. All other audit findings were addressed by
3834
- updating documentation (`docs/api/cli.md`, `docs/api/action.md`,
3835
- `docs/guide/configuration.md`, `docs/guide/trailer.md`, `README.md`,
3836
- `action.yml` description text, VitePress sidebar).
3837
-
3838
- **Verification.**
3839
-
3840
- ```sh
3841
- putitoutthere --help | grep -- '--json'
3842
- # Expected: --json emit machine-readable output (most commands)
3843
- ```
3844
-
3845
- ### Python shape examples now use `uv build`
3846
-
3847
- **Summary.** Documentation examples for the Python library, Python
3848
- cibuildwheel, and dynamic-versions shapes switched the sdist-build
3849
- step from `python -m build --sdist` to `uv build --sdist`. piot's
3850
- contract is unchanged — backends, artifact names, the
3851
- `matrix.artifact_name` / `matrix.artifact_path` fields, and the
3852
- publish-side completeness check all work identically. The change
3853
- removes a `pip install build` round-trip and aligns the docs with
3854
- `uv` as the recommended Python toolchain.
3855
-
3856
- **Required changes.** None. `python -m build` still works. To
3857
- follow the new examples in your own `release.yml`:
3858
-
3859
- ```diff
3860
- build:
3861
- ...
3862
- steps:
3863
- - - uses: actions/setup-python@v5
3864
- - with: { python-version: '3.12' }
3865
- - name: Build sdist
3866
- - run: |
3867
- - cd ${{ matrix.path }}
3868
- - python -m pip install build
3869
- - python -m build --sdist --outdir dist
3870
- + working-directory: ${{ matrix.path }}
3871
- + run: uv build --sdist
3872
- + # uv installs and manages Python itself; no setup-python step needed.
3873
- + # Add this once at the top of the build job:
3874
- + - uses: astral-sh/setup-uv@v3
3875
- ```
3876
-
3877
- `uv build --sdist` writes to `dist/` inside the working directory
3878
- (same as `python -m build --outdir dist`), so
3879
- `matrix.artifact_path` keeps pointing at the right place. The
3880
- publish job is unchanged — `setup-python` + `pip install twine` is
3881
- still the recommended path there because piot's PyPI handler shells
3882
- out to `twine`.
3883
-
3884
- **When *not* to follow this example.** Stay on `python -m build`
3885
- if:
3886
-
3887
- - Your CI image already has Python pre-installed and adding
3888
- `setup-uv` would slow the cold cache.
3889
- - Your `pyproject.toml` exercises a build backend feature that uv's
3890
- isolated build environment doesn't yet handle (rare; uv's build
3891
- isolation matches `python -m build`'s).
3892
- - Your team's runbook standardises on `python -m build` and the
3893
- consistency cost of switching outweighs the per-run speedup.
3894
-
3895
- `python -m build` is not deprecated and will keep working.
3896
-
3897
- **Deprecations removed.** None.
3898
-
3899
- **Behavior changes without code changes.** None.
3900
-
3901
- **Verification.**
3902
-
3903
- ```bash
3904
- # After the build job runs:
3905
- ls artifacts/<pkg.name>-sdist/
3906
- # Expected: <pypi-name>-X.Y.Z.tar.gz (no .devN suffix)
3907
- ```
3908
-
3909
- If you see the expected sdist, the switch worked. If you see a
3910
- `.devN` suffix, your project uses dynamic versioning — see
3911
- [dynamic versions](https://thekevinscott.github.io/putitoutthere/guide/dynamic-versions)
3912
- for the env-var handoff (unchanged by this migration).
3913
-
3914
- ### Repository renamed `put-it-out-there` → `putitoutthere`
3915
-
3916
- **Summary.** The GitHub repository slug collapsed from `put-it-out-there`
3917
- to `putitoutthere`, matching the npm package and CLI binary name. The
3918
- human-readable name "Put It Out There" (with spaces) is unchanged. GitHub
3919
- auto-redirects the old URL, but any place a consumer has hard-coded the
3920
- old slug — npm/Cargo/pyproject `repository` URLs, GitHub Actions
3921
- references, OIDC trust policy `repository:` claims, docs links — should
3922
- be updated.
3923
-
3924
- **Required changes.**
3925
-
3926
- ```diff
3927
- # package.json (or Cargo.toml / pyproject.toml)
3928
- -"repository": "https://github.com/<owner>/put-it-out-there"
3929
- +"repository": "https://github.com/<owner>/putitoutthere"
3930
- ```
3931
-
3932
- ```diff
3933
- # .github/workflows/release.yml — if you reference the action by full repo path
3934
- -uses: thekevinscott/put-it-out-there/.github/actions/<...>
3935
- +uses: thekevinscott/putitoutthere/.github/actions/<...>
3936
- ```
3937
-
3938
- ```diff
3939
- # OIDC trust policies (PyPI, npm) that gate on the source repo
3940
- -"repository": "<owner>/put-it-out-there"
3941
- +"repository": "<owner>/putitoutthere"
3942
- ```
3943
-
3944
- If you only ever invoked `putitoutthere` via the npm package
3945
- (`npx putitoutthere`, `pnpm add -D putitoutthere`) or the published
3946
- GitHub Action, no change is required — those references already used the
3947
- collapsed name.
3948
-
3949
- **Deprecations removed.** None. The old slug continues to redirect at
3950
- the GitHub layer.
3951
-
3952
- **Behavior changes without code changes.**
3953
-
3954
- - Documentation site moved from
3955
- `https://thekevinscott.github.io/put-it-out-there/` to
3956
- `https://thekevinscott.github.io/putitoutthere/`. The old URL
3957
- redirects.
3958
- - `git remote -v` will still show the old URL until you `git remote
3959
- set-url origin https://github.com/thekevinscott/putitoutthere.git`.
3960
- Push and fetch keep working via redirect, but updating the remote
3961
- avoids surprise breakage if the redirect is ever retired.
3962
-
3963
- **Verification.**
3964
-
3965
- ```sh
3966
- # Confirm no stale references in your repo
3967
- grep -r "put-it-out-there" .
3968
- ```
3969
-
3970
- Expect no hits outside historical changelog/migration entries.
3971
-
3972
- ### `[package.bundle_cli]` — stage a Rust CLI into every maturin wheel (#217)
3973
-
3974
- > **Note (#282).** The "Behavior changes without code changes"
3975
- > paragraph below claimed two scaffolded build steps would be
3976
- > emitted. Those steps were not actually present in `_matrix.yml`
3977
- > until #282 (Unreleased); for v0.2.0 through v0.2.10 the recipe
3978
- > was a no-op and wheels shipped without the binary. See the
3979
- > Unreleased entry "[package.bundle_cli] now actually stages the
3980
- > binary" above for the actual landing.
3981
-
3982
- **Summary.** New optional sub-table under `[[package]]` for pypi packages
3983
- that want the `ruff` / `uv` / `pydantic-core` wheel shape: a companion
3984
- Rust CLI binary, cross-compiled per target and staged into the Python
3985
- source tree before maturin runs, so each wheel ships the binary as
3986
- package data and `pip install <pkg>` gets a working CLI on `PATH` with
3987
- no Rust toolchain on the user's machine. Additive — existing
3988
- configurations are unchanged.
3989
-
3990
- **Required changes.** None for existing configs. To opt in:
3991
-
3992
- ```diff
3993
- [[package]]
3994
- name = "my-py"
3995
- kind = "pypi"
3996
- build = "maturin"
3997
- path = "packages/python"
3998
- globs = ["packages/python/**"]
3999
- targets = ["x86_64-unknown-linux-gnu", "aarch64-apple-darwin"]
4000
- +
4001
- +[package.bundle_cli]
4002
- +bin = "my-cli"
4003
- +stage_to = "src/my_py/_binary"
4004
- +crate_path = "crates/my-rust" # defaults to "." (repo workspace root)
4005
- ```
4006
-
4007
- And in the Python package's `pyproject.toml`:
4008
-
4009
- ```diff
4010
- +[project.scripts]
4011
- +my-cli = "my_py._binary:entrypoint" # small os.execv launcher stub
4012
- +
4013
- [tool.maturin]
4014
- -include = ["..."]
4015
- +include = ["...", "src/my_py/_binary/**"] # ship the staged binary
4016
- ```
4017
-
4018
- See [README → Rust CLI inside a PyPI wheel](https://github.com/thekevinscott/putitoutthere/blob/main/README.md#rust-cli-inside-a-pypi-wheel)
4019
- for the full worked example including the launcher stub.
4020
-
4021
- **Deprecations removed.** None.
4022
-
4023
- **Behavior changes without code changes.** None for existing configs.
4024
- Packages that declare `[package.bundle_cli]` get two new steps emitted
4025
- in the scaffolded build job (`Setup Rust (if pypi bundle_cli)` +
4026
- `Build + stage bundled CLI`), both gated on
4027
- `matrix.kind == 'pypi' && matrix.bundle_cli.bin != '' && matrix.target != 'sdist'`
4028
- so packages without the block see no change.
4029
-
4030
- **Verification.** For a repo that opts in:
4031
-
4032
- ```bash
4033
- # After piot's build job runs on one target:
4034
- ls packages/python/src/my_py/_binary/
4035
- # Expected: my-cli (or my-cli.exe on Windows targets)
4036
-
4037
- # After the wheel is built:
4038
- python -m zipfile -l packages/python/dist/*.whl | grep _binary
4039
- # Expected: one entry per target listing the staged binary.
4040
-
4041
- # End-to-end on a released wheel:
4042
- pip install my-py==<published-version>
4043
- which my-cli
4044
- my-cli --version
4045
- ```