putitoutthere 0.2.46 → 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/README.md DELETED
@@ -1,1237 +0,0 @@
1
- # Put It Out There
2
-
3
- A reusable GitHub Actions workflow that publishes packages to crates.io, PyPI,
4
- and npm from one repo. OIDC-first, cascade-aware, polyglot. The consumer
5
- surface is one config file plus one canonical YAML calling
6
- `uses: thekevinscott/putitoutthere/.github/workflows/release.yml@v0`.
7
-
8
- > **Using Claude Code?** The [`first-release` skill](.claude/skills/first-release/SKILL.md)
9
- > drives this whole guide interactively — it detects what your repo publishes,
10
- > writes `putitoutthere.toml` and the release workflows, walks trusted-publisher
11
- > registration and the first-publish bootstrap, previews exactly what will
12
- > release with `plan`, and gets you to the zero-secret OIDC steady state
13
- > (`status` / `verify`). Just say *"walk me through the first release."*
14
-
15
- ## Quickstart
16
-
17
- ### 1. Drop in `.github/workflows/release.yml`
18
-
19
- ```yaml
20
- name: Release
21
-
22
- on:
23
- push:
24
- branches: [main]
25
-
26
- jobs:
27
- release:
28
- uses: thekevinscott/putitoutthere/.github/workflows/release.yml@v0
29
- permissions:
30
- contents: write
31
- id-token: write
32
-
33
- # PyPI upload runs in the caller's workflow context. Required because
34
- # PyPI Trusted Publishers can't validate OIDC tokens minted from a
35
- # cross-repo reusable workflow (pypi/warehouse#11096). The `if:`
36
- # gate skips this job for non-PyPI repos — paste verbatim regardless
37
- # of what you publish.
38
- pypi-publish:
39
- needs: release
40
- if: needs.release.outputs.has_pypi == 'true'
41
- runs-on: ubuntu-latest
42
- permissions:
43
- id-token: write
44
- steps:
45
- - uses: actions/download-artifact@v8
46
- with:
47
- pattern: '*-sdist'
48
- path: dist/
49
- merge-multiple: true
50
- - uses: actions/download-artifact@v8
51
- with:
52
- pattern: '*-wheel-*'
53
- path: dist/
54
- merge-multiple: true
55
- - uses: pypa/gh-action-pypi-publish@release/v1
56
- ```
57
-
58
- Pinned action versions, `plan → build → publish` orchestration, and GitHub
59
- Release creation all live inside the reusable workflow. Each tag the engine
60
- pushes gets a matching GitHub Release with notes auto-generated from PR
61
- titles between that tag and its predecessor (`gh release create
62
- --generate-notes`); no `gh release create` step is needed in your workflow. The `pypi-publish`
63
- job is the one piece that has to live in your workflow file: PyPI's
64
- Trusted Publisher feature filters OIDC tokens by `repository_owner` /
65
- `repository_name` claims, which always reflect the caller's repo — so a
66
- TP registered against `thekevinscott/putitoutthere` is filtered out
67
- before `job_workflow_ref` is even checked. Running `pypa/gh-action-pypi-publish`
68
- in your workflow context aligns the claims with your TP registration.
69
- The job is skipped automatically for repos that don't publish to PyPI.
70
-
71
- > [!IMPORTANT]
72
- > **Don't run anything else on `push: branches: [main]`.** If you have
73
- > per-language CI workflows (`rust.yml`, `node.yml`, `python.yml`,
74
- > etc.), keep them on `pull_request:` only — drop any `push: branches: [main]`
75
- > trigger they may carry. Branch protection plus PR-required CI already
76
- > covered the merge commit's contents on the PR build; firing the lane
77
- > workflows a second time on the push to `main` is duplicate work that
78
- > contends for runners with `release.yml` and delays the release. A repo
79
- > with three lane workflows + paths filters that all match the merge
80
- > commit will fire four workflows where one was wanted. Fix: keep
81
- > `release.yml` as the only `push: branches: [main]` workflow.
82
-
83
- Optional inputs — `with:` block at the call site:
84
-
85
- | Input | Default | Use when |
86
- |------------------|--------------|--------------------------------------------------------------------------|
87
- | `environment` | `release` | Your GitHub deployment environment is named differently. |
88
- | `node_version` | `24` | You need a specific Node version for `kind = "npm"` build steps. |
89
- | `python_version` | `3.12` | Deprecated — no longer affects `kind = "pypi"` builds. Wheel coverage is inferred from `requires-python` or pinned via [`python_versions`](#kind--pypi). |
90
-
91
- ### 1b. Recommended: drop in `.github/workflows/check.yml`
92
-
93
- Run every pre-merge config check the engine knows about on every
94
- PR. The fastest gate against a malformed `putitoutthere.toml`, a
95
- duplicate package name, a `depends_on` cycle, a missing
96
- `[[package]].path` directory, globs that match no tracked files,
97
- a `tag_format` collision, a missing `repository` field on an
98
- `npm` package, missing `description` / `license` on a `crates`
99
- package, a `bundle_cli` binary the crate doesn't declare, a
100
- `pyproject.toml` whose `[project].name` or `[build-system].build-backend`
101
- disagrees with the configured `name` / `build`, a `Cargo.toml` whose
102
- `[package].name` disagrees with the configured `name` / `crate`, or a
103
- `features` list referencing a feature the crate doesn't declare — a
104
- couple of seconds per PR, no per-target build, no `setup-python`
105
- / `setup-rust`. Findings are aggregated into one report so you
106
- fix everything in one push instead of chasing errors across re-
107
- runs.
108
-
109
- ```yaml
110
- name: putitoutthere check
111
-
112
- on:
113
- pull_request: {}
114
-
115
- jobs:
116
- putitoutthere-check:
117
- uses: thekevinscott/putitoutthere/.github/workflows/check.yml@v0
118
- ```
119
-
120
- Green here = "a release run from this commit would not surface
121
- configuration-level surprises." `check.yml` does not build anything,
122
- does not run `setup-node` against your sources, and never holds a
123
- publishable artifact in memory; its `permissions:` block is
124
- `contents: read` only.
125
-
126
- `check.yml` takes no inputs. The Node version is pinned internally
127
- because no consumer build steps run on this code path — the
128
- `node_version` knob on `build.yml` / `release.yml` does not exist
129
- here. Wire `check.yml` exactly as shown above.
130
-
131
- ### 1c. Recommended: drop in `.github/workflows/build-check.yml`
132
-
133
- Run the same plan + build matrix on every PR, with the publish step
134
- structurally absent. Slower than `check.yml` (it actually compiles
135
- every per-target wheel and binary) but catches the bugs `check.yml`
136
- can't observe — a per-target build break, a missing `repository`
137
- field that the build process surfaces, an `aarch64-apple-darwin`
138
- linker incompatibility. Wire both: `check.yml` catches the cheap
139
- mistakes in seconds, `build.yml` catches the rest before the merge.
140
-
141
- ```yaml
142
- name: Build check
143
-
144
- on:
145
- pull_request: {}
146
-
147
- jobs:
148
- build-check:
149
- uses: thekevinscott/putitoutthere/.github/workflows/build.yml@v0
150
- ```
151
-
152
- `build.yml` calls the same internal `_matrix.yml` reusable workflow that
153
- `release.yml` does — same action pins, same per-target build steps, same
154
- runners — so a PR that breaks `aarch64-apple-darwin` wheels surfaces
155
- in review instead of at release time. The publish job, the
156
- `id-token: write` permission, and the OIDC trusted-publisher exchanges
157
- do not exist on this code path; there is no flag, no input, no
158
- conditional that could ever cause it to publish. Same `node_version` /
159
- `python_version` inputs as `release.yml`; no new config to write.
160
-
161
- ### 2. Drop in `putitoutthere.toml`
162
-
163
- ```toml
164
- [putitoutthere]
165
- version = 1
166
-
167
- [[package]]
168
- name = "my-lib"
169
- kind = "pypi" # or "npm" | "crates"
170
- path = "."
171
- globs = ["src/**", "pyproject.toml"]
172
- build = "hatch" # required for kind = "pypi"
173
- tag_format = "v{version}" # single-package repos often want this
174
- ```
175
-
176
- `globs` are the path globs that trigger a release. Any commit touching a
177
- matching file makes the package a candidate.
178
-
179
- > [!CAUTION]
180
- > **Four schema gotchas, one per line.** Every one of these has tripped a
181
- > consumer at least once; the engine throws a hint when it sees them but
182
- > they're cheaper to avoid than to debug.
183
- >
184
- > | Wrong | Right |
185
- > |------------------------------------|--------------------------------|
186
- > | `version = 1` at file root | `[putitoutthere]` table with `version = 1` inside |
187
- > | `[[packages]]` (plural) | `[[package]]` (singular, one block per package) |
188
- > | `registry = "crates"` | `kind = "crates"` |
189
- > | `files = ["src/**"]` | `globs = ["src/**"]` |
190
-
191
- More config patterns are in [Configuration](#configuration) below.
192
-
193
- ### 3. Register trusted publishers
194
-
195
- Each registry needs a one-time external setup so OIDC publishes work. See
196
- [Trusted publishers](#trusted-publishers) below — three short lists, one per
197
- registry.
198
-
199
- ### 4. Push a release
200
-
201
- Merge to `main`. Default behavior: any package whose `globs` matched changed
202
- files cascades and ships at `patch`. To bump `minor` or `major`:
203
-
204
- ```
205
- fix: handle empty token lists
206
-
207
- release: minor
208
- ```
209
-
210
- …in the merge commit body. See [Trailer](#trailer) below.
211
-
212
- ## Configuration
213
-
214
- `putitoutthere.toml` lives at the repo root.
215
-
216
- ### `[putitoutthere]`
217
-
218
- ```toml
219
- [putitoutthere]
220
- version = 1 # required; only 1 is valid today
221
- ```
222
-
223
- ### `[[package]]` (one per releasable unit)
224
-
225
- | Field | Type | Required | Notes |
226
- |-----------------|----------|----------|---------------------------------------------------|
227
- | `name` | string | yes | Unique across the config. |
228
- | `kind` | enum | yes | `crates` \| `pypi` \| `npm`. |
229
- | `path` | string | yes | Package working dir (`Cargo.toml` / `pyproject.toml` / `package.json` location). |
230
- | `globs` | string[] | yes | Path globs that cascade this package. |
231
- | `depends_on` | string[] | no | Package names this one cascades on top of. |
232
- | `first_version` | string | no | Default `0.1.0`. |
233
- | `tag_format` | string | no | Template for the git tag. Default `"{name}-v{version}"`. Single-package repos often want `"v{version}"`. |
234
-
235
- ### `kind = "crates"`
236
-
237
- | Field | Type | Notes |
238
- |-----------------------|----------|------------------------------------------------------------|
239
- | `crate` | string | Override `name` → crates.io name. |
240
- | `features` | string[] | Pass through to `cargo publish --features`. |
241
- | `no_default_features` | bool | Pass `--no-default-features` to `cargo publish` when true. |
242
-
243
- > [!IMPORTANT]
244
- > **`Cargo.toml` MUST match the configured shape.** Preflight verifies
245
- > these at PR time (via `check.yml`) and again before any publish side
246
- > effect:
247
- >
248
- > - `[package].name` matches `[[package]].name` (or the `crate` override) —
249
- > `PIOT_CRATES_NAME_MISMATCH`.
250
- > - `[package].description` and `[package].license` (or `license-file`) are
251
- > set — `PIOT_CRATES_MISSING_METADATA`.
252
- > - Every entry in `features` (and in `bundle_cli.features`, when set) is
253
- > declared in `[features]` — `PIOT_CRATES_FEATURE_NOT_DECLARED`.
254
- > - When `bundle_cli.bin` is set, the target `Cargo.toml` declares a
255
- > `[[bin]]` with that name (or the implicit binary derived from
256
- > `[package].name`) — `PIOT_CRATES_MISSING_BIN`.
257
- > - When `[package].version.workspace = true`, an ancestor `Cargo.toml`
258
- > declares `[workspace.package].version` —
259
- > `PIOT_CRATES_WORKSPACE_VERSION_MISMATCH`.
260
-
261
- ### `kind = "pypi"`
262
-
263
- | Field | Type | Notes |
264
- |--------------|------------------------|----------------------------------------------------|
265
- | `pypi` | string | Override `name` → PyPI registered name. |
266
- | `build` | enum | `maturin` \| `setuptools` \| `hatch`. Optional. Default `setuptools`. |
267
- | `targets` | (string \| object)[] | Required when `build = "maturin"`. Triples or `{ triple, runner }` objects. |
268
- | `bundle_cli` | table | Opt-in: cross-compile a Rust CLI per target and stage it into each wheel. Only valid with `build = "maturin"`. See [Recipes → Rust CLI inside a PyPI wheel](#rust-cli-inside-a-pypi-wheel). |
269
- | `python_versions` | string[] | Optional override for the CPython versions wheels are built for, e.g. `["3.12", "3.13"]`. When omitted, the set is inferred from `[project].requires-python` and putitoutthere's checked-in released-CPython list (see below). |
270
-
271
- > [!NOTE]
272
- > **`kind = "pypi"` builds a wheel for every supported Python version.**
273
- > By default the version set is inferred from `[project].requires-python`
274
- > in your `pyproject.toml` — `requires-python = ">=3.10"` builds wheels
275
- > for every released CPython minor version in putitoutthere's checked-in
276
- > list that it allows. No configuration is needed for the common case;
277
- > update putitoutthere when a new CPython minor should be included.
278
- > To pin an explicit subset, set `python_versions` on the package. The
279
- > build matrix fans across the resolved set (per `maturin` target); the
280
- > sdist and a pure-Python `hatch` wheel are version-agnostic and built
281
- > once. A `maturin` wheel that is itself Python-version-independent —
282
- > `[tool.maturin].bindings = "bin"` (a `py3-none` Rust-binary wheel) or a
283
- > pyo3 `abi3` extension (a `cp3x-abi3` wheel) — is likewise built once, on
284
- > the newest resolved interpreter, instead of duplicated across the set
285
- > (the duplicates otherwise collide at the `pypi-publish` download). When
286
- > neither `python_versions` nor a parseable `requires-python` is present, a
287
- > single wheel is built for `3.12`.
288
-
289
- > [!IMPORTANT]
290
- > **`pyproject.toml` MUST match the configured shape.** Preflight verifies
291
- > these at PR time (via `check.yml`) and again before any publish side
292
- > effect:
293
- >
294
- > - `[project].name` matches `[[package]].name` (or the `pypi` override) —
295
- > `PIOT_PYPI_NAME_MISMATCH`.
296
- > - `[build-system].build-backend`, when set, matches the configured
297
- > `build` mode (`maturin` → `maturin`, `setuptools` →
298
- > `setuptools.build_meta`, `hatch` → `hatchling.build`) —
299
- > `PIOT_PYPI_BUILD_BACKEND_MISMATCH`.
300
- > - When `[project].dynamic` contains `"version"`, either
301
- > `[tool.hatch.version]` or `[tool.setuptools_scm]` declares the version
302
- > source — `PIOT_PYPI_DYNAMIC_VERSION_NO_BACKEND`.
303
- > - When `bundle_cli` is set, `[tool.maturin].include` covers
304
- > `bundle_cli.stage_to` — `PIOT_PYPI_MATURIN_INCLUDE_MISSING`.
305
-
306
- ### `kind = "npm"`
307
-
308
- | Field | Type | Notes |
309
- |-----------|------------------------|------------------------------------------------------|
310
- | `npm` | string | Override `name` → npm name (for scoped packages). |
311
- | `access` | enum | `public` \| `restricted`. Default `public`. |
312
- | `tag` | string | dist-tag. Default `latest`. |
313
- | `build` | string \| array | `"napi"` \| `"bundled-cli"` (single mode), or an array of entries (each: a bare mode string or `{ mode, name }` with a [name template](#multi-mode-npm-family)). Omitted = vanilla. See Recipes → [napi](#napi-npm-family) / [Bundled-CLI](#bundled-cli-npm-family) npm family. |
314
- | `targets` | (string \| object)[] | Required when `build` is set. |
315
- | `[package.bundle_cli]` | sub-table | Declarative cross-compile for `build = "bundled-cli"` rows. Fields: `bin` (required), `crate_path` (default `"."`), `features` (default `[]`), `no_default_features` (default `false`). See [Recipes → Bundled-CLI npm family](#bundled-cli-npm-family). |
316
-
317
- > [!IMPORTANT]
318
- > **`package.json` MUST declare a non-empty `repository` field.** `putitoutthere`
319
- > publishes npm packages with `npm publish --provenance` on the OIDC
320
- > trusted-publisher path; the npm CLI hard-requires `repository` so the
321
- > registry can verify the artifact was built from the repo the trusted
322
- > publisher declares. Preflight rejects the run with
323
- > `PIOT_NPM_MISSING_REPOSITORY` when the field is missing or empty.
324
- >
325
- > Canonical shape (use this in every npm `package.json` you publish through
326
- > `putitoutthere`):
327
- >
328
- > ```json
329
- > {
330
- > "repository": {
331
- > "type": "git",
332
- > "url": "git+https://github.com/<owner>/<repo>.git",
333
- > "directory": "<path/to/package>"
334
- > }
335
- > }
336
- > ```
337
- >
338
- > `directory` is needed for monorepo packages so npm can locate the source
339
- > within the repo. The legacy single-string form
340
- > (`"repository": "git+https://github.com/<owner>/<repo>.git"`) is also
341
- > accepted.
342
-
343
- > [!IMPORTANT]
344
- > **`package.json`'s `name` MUST match the configured shape.** Preflight
345
- > verifies this at PR time (via `check.yml`) and again before any publish
346
- > side effect:
347
- >
348
- > - `name` matches `[[package]].name` (or the `npm` override) —
349
- > `PIOT_NPM_NAME_MISMATCH`. `npm publish` packs the manifest `name`, but
350
- > `putitoutthere`'s idempotency check (`npm view <name>`) and the tag /
351
- > release-URL bookkeeping use the configured name; a divergence breaks
352
- > idempotency and can publish under an unexpected name. Use the `npm`
353
- > override when the registered name differs from `[[package]].name`
354
- > (e.g. a scoped `@scope/foo`).
355
-
356
- ### Example: polyglot Rust library
357
-
358
- One Rust crate feeds three artifacts:
359
-
360
- ```toml
361
- [[package]]
362
- name = "my-rust"
363
- kind = "crates"
364
- path = "crates/my-rust"
365
- globs = ["crates/my-rust/**"]
366
-
367
- [[package]]
368
- name = "my-py"
369
- kind = "pypi"
370
- path = "py/my-py"
371
- globs = ["py/my-py/**"]
372
- build = "maturin"
373
- targets = ["x86_64-unknown-linux-gnu", "aarch64-apple-darwin"]
374
- depends_on = ["my-rust"]
375
-
376
- [[package]]
377
- name = "my-cli"
378
- kind = "npm"
379
- path = "packages/ts"
380
- globs = ["packages/ts/**"]
381
- build = "bundled-cli"
382
- targets = ["x86_64-unknown-linux-gnu", "aarch64-apple-darwin"]
383
- depends_on = ["my-rust"]
384
- ```
385
-
386
- A change to `crates/my-rust/` cascades: the crate ships, then the Python
387
- wheels and npm family ship on top, version-bumped to match.
388
-
389
- ### Example: multi-package workspace
390
-
391
- ```toml
392
- [[package]]
393
- name = "@my/core"
394
- kind = "npm"
395
- path = "packages/core"
396
- globs = ["packages/core/**"]
397
-
398
- [[package]]
399
- name = "@my/parser"
400
- kind = "npm"
401
- path = "packages/parser"
402
- globs = ["packages/parser/**"]
403
- depends_on = ["@my/core"]
404
- ```
405
-
406
- ## Trailer
407
-
408
- The trailer is **optional**. Default behavior is `patch` whenever a package's
409
- `globs` matched changed files.
410
-
411
- Grammar:
412
-
413
- ```
414
- release: <bump> [pkg1, pkg2, ...]
415
- ```
416
-
417
- `<bump>` is `patch` | `minor` | `major` | `skip`. The optional package list
418
- scopes a non-default bump to specific packages.
419
-
420
- | Trailer | Effect |
421
- |--------------------------|------------------------------------------------------------------------|
422
- | *(none)* | Cascaded packages bump `patch`. |
423
- | `release: minor` | Cascaded packages bump `minor`. |
424
- | `release: major` | Cascaded packages bump `major`. |
425
- | `release: skip` | No release this commit. Cascade ignored. |
426
- | `release: minor [a, b]` | `a` and `b` bump `minor`; other cascaded packages stay at `patch`. |
427
-
428
- The trailer matches anywhere in the commit body. If multiple `release:` lines
429
- are present, the **last** one wins.
430
-
431
- The parser is intentionally lenient on three points: the key is
432
- case-insensitive (`Release:` and `RELEASE:` both match), leading
433
- whitespace before `release:` is allowed, and an empty package list
434
- (`release: minor []`) is equivalent to no list (`release: minor`).
435
- The documented forms above are the canonical shape; the leniency
436
- exists so commits authored under varied review styles still parse.
437
-
438
- ## Cascade
439
-
440
- A package cascades into the release plan when a commit changes any file
441
- matching one of its `globs` since its last tag. If another package
442
- declares `depends_on = ["this-package"]`, that package also cascades.
443
- Transitively, DFS-ordered, with cycle detection at config-load.
444
-
445
- Inside a single release, packages publish in topological order of their
446
- `depends_on` graph. If your Python wrapper depends on a Rust crate, the
447
- crate publishes first.
448
-
449
- Each handler's first move on publish is `isPublished` — check the registry
450
- for the target version. Already there → skip cleanly. Lets you re-run failed
451
- releases without fighting registry-immutable-publish semantics.
452
-
453
- ## Manual release
454
-
455
- Releases are normally change-driven: a package ships when a commit touches
456
- its `globs`. Sometimes you need to release a package that has **no new
457
- commits** — most often a re-release after a release-pipeline bug is fixed.
458
- The `release_packages` input on `release.yml` does exactly that.
459
-
460
- Wire it to a `workflow_dispatch` trigger in your caller workflow:
461
-
462
- ```yaml
463
- on:
464
- push: { branches: [main] }
465
- workflow_dispatch:
466
- inputs:
467
- release_packages:
468
- description: 'Comma-separated name[@bump|version] list'
469
- required: true
470
-
471
- jobs:
472
- release:
473
- uses: thekevinscott/putitoutthere/.github/workflows/release.yml@v0
474
- permissions:
475
- contents: write
476
- id-token: write
477
- with:
478
- release_packages: ${{ inputs.release_packages }}
479
- ```
480
-
481
- Push-triggered runs leave `release_packages` empty (the `inputs` context is
482
- empty outside `workflow_dispatch`), so the normal change-detected path is
483
- unaffected. Triggering the workflow manually from the Actions tab with
484
- `release_packages` set takes over.
485
-
486
- Grammar — a comma-separated list of entries:
487
-
488
- ```
489
- release_packages: lib-core@minor, lib-py@1.4.0, lib-js
490
- ```
491
-
492
- Each entry is a package name optionally suffixed with a version spec:
493
-
494
- | Entry | Effect |
495
- |------------------|-------------------------------------------------------------------|
496
- | `lib-js` | Release `lib-js`, bumping its last tag by `patch`. |
497
- | `lib-core@minor` | Release `lib-core`, bumping its last tag by `minor` (or `major`). |
498
- | `lib-py@1.4.0` | Release `lib-py` at exactly `1.4.0`. |
499
-
500
- When `release_packages` is set, change detection and `depends_on` cascade
501
- are bypassed entirely: **exactly** the named packages are released, and
502
- nothing else — even a package with real pending changes is left out unless
503
- you name it. An explicit version is used verbatim and is not checked
504
- against the last tag; if that version is already on the registry the
505
- publish-phase `isPublished` check skips it cleanly. Naming a package that
506
- is not declared in `putitoutthere.toml` is an error.
507
-
508
- ## Trusted publishers
509
-
510
- OIDC trusted publishers are the default and recommended auth path.
511
- The reusable workflow also accepts long-lived `CARGO_REGISTRY_TOKEN`
512
- (crates.io) and `NPM_TOKEN` (npm) values via `secrets:` for cases
513
- where Trusted Publishing isn't reachable — most commonly the very
514
- first publish of a brand-new crate or npm package, since Trusted
515
- Publishing on both registries binds to an *already-published*
516
- package and neither has a pending-publisher equivalent. When set,
517
- the OIDC exchange is skipped and the caller-provided token is used
518
- instead. Drop the secret once Trusted Publishing is registered
519
- against the existing package.
520
-
521
- For all three registries the OIDC fields are the same: **your**
522
- repository owner/name, **your** workflow filename (`release.yml`),
523
- and optionally a GitHub environment name. Note: you register against
524
- your *own* repository, not against `thekevinscott/putitoutthere` —
525
- see "How auth flows" below for the why.
526
-
527
- ### crates.io
528
-
529
- 1. **First publish (brand-new crate).** Trusted Publishing binds to
530
- an existing crate, so the first `cargo publish` has no OIDC path.
531
- Either run `cargo publish` once locally with your account's API
532
- token, or pass `CARGO_REGISTRY_TOKEN` to the reusable workflow via
533
- `secrets:` to bootstrap through this workflow:
534
-
535
- ```yaml
536
- jobs:
537
- release:
538
- uses: thekevinscott/putitoutthere/.github/workflows/release.yml@v0
539
- secrets:
540
- CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
541
- ```
542
-
543
- When `CARGO_REGISTRY_TOKEN` is set, the OIDC step
544
- (`rust-lang/crates-io-auth-action`) is skipped and the caller-
545
- provided token is exported to the publish step's environment.
546
- 2. Go to `https://crates.io/crates/<crate>/settings` → **Trusted Publishing**
547
- → **Add**.
548
- 3. Fill in: your repo owner, your repo name, workflow filename
549
- (`release.yml`), environment (optional).
550
- 4. Drop the `CARGO_REGISTRY_TOKEN` secret from the workflow once
551
- Trusted Publishing is registered; subsequent publishes are
552
- zero-secret on the OIDC path.
553
-
554
- ### PyPI
555
-
556
- 1. Go to `https://pypi.org/manage/project/<name>/settings/publishing/` (or
557
- **Publishing** on the project page).
558
- 2. Add a **GitHub** trusted publisher: your repo owner, your repo name,
559
- workflow filename (`release.yml`), environment (optional).
560
- 3. Brand-new project? Use a [pending publisher](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/)
561
- to skip the bootstrap token.
562
-
563
- ### npm
564
-
565
- 1. **First publish (brand-new package).** Trusted Publishing on npm
566
- binds to an existing package, so the first `npm publish` has no
567
- OIDC path. Pass `NPM_TOKEN` to the reusable workflow via
568
- `secrets:` to bootstrap through this workflow:
569
-
570
- ```yaml
571
- jobs:
572
- release:
573
- uses: thekevinscott/putitoutthere/.github/workflows/release.yml@v0
574
- secrets:
575
- NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
576
- ```
577
-
578
- When `NPM_TOKEN` is set, it is exported to the publish step's
579
- environment as `NODE_AUTH_TOKEN` and the npm CLI prefers it over
580
- the OIDC path. For bundled-cli / napi families the same secret
581
- authenticates publishes of all per-platform sub-packages on first
582
- publish — once those exist, each one needs its own Trusted
583
- Publisher registration (the bypass is a one-time bootstrap, not
584
- a permanent path).
585
- 2. Go to `https://www.npmjs.com/package/<name>/access` → **Require trusted
586
- publisher**.
587
- 3. Fill in: your repository, workflow filename (`release.yml`),
588
- environment (optional). Repeat for every per-platform sub-package
589
- for bundled-cli / napi families.
590
- 4. Drop the `NPM_TOKEN` secret from the workflow once Trusted
591
- Publishing is registered; subsequent publishes are zero-secret on
592
- the OIDC path.
593
-
594
- ### How auth flows
595
-
596
- `crates.io` and `npm` validate OIDC tokens that are minted by the
597
- reusable workflow's `publish` job. The reusable workflow already
598
- sits in your release path, so the OIDC `repository` and
599
- `job_workflow_ref` claims line up with your TP registration.
600
-
601
- PyPI is different. Its TP matching filters candidates by
602
- `repository_owner` + `repository_name` *before* checking
603
- `job_workflow_ref` ([Warehouse implementation](https://github.com/pypi/warehouse/blob/main/warehouse/oidc/models/github.py)).
604
- The `repository` claim always reflects the caller's repo — even
605
- inside a reusable workflow — so a TP registered against the
606
- reusable workflow's repo would be filtered out before
607
- `job_workflow_ref` is even checked. PyPI documents this:
608
- "[Reusable workflows cannot currently be used as the workflow in
609
- a Trusted Publisher.](https://docs.pypi.org/trusted-publishers/troubleshooting/)"
610
- Tracked at [pypi/warehouse#11096](https://github.com/pypi/warehouse/issues/11096).
611
-
612
- That's why the canonical template puts the PyPI upload step
613
- (`pypa/gh-action-pypi-publish`) directly in *your* workflow,
614
- gated on `needs.release.outputs.has_pypi`. In your workflow context
615
- both claims resolve to your repo, so your TP registration matches.
616
-
617
- ## Recipes
618
-
619
- ### Bundled-CLI npm family
620
-
621
- Ship a compiled CLI as an npm per-platform family — `npm install -g my-cli`
622
- gives users a working binary on PATH. The `esbuild` / `biome` distribution
623
- shape.
624
-
625
- Config:
626
-
627
- ```toml
628
- [[package]]
629
- name = "my-cli"
630
- kind = "npm"
631
- npm = "my-cli"
632
- build = "bundled-cli"
633
- path = "packages/ts-cli"
634
- globs = ["packages/ts-cli/**", "crates/my-cli/**"]
635
- targets = [
636
- "x86_64-unknown-linux-gnu",
637
- "aarch64-unknown-linux-gnu",
638
- "x86_64-apple-darwin",
639
- "aarch64-apple-darwin",
640
- "x86_64-pc-windows-msvc",
641
- ]
642
- ```
643
-
644
- The engine publishes a per-platform sub-package per target
645
- (`my-cli-<triple>`) plus a top-level whose `optionalDependencies` pin them
646
- at the published version. npm's resolver installs exactly one sub-package
647
- at consumer install time.
648
-
649
- With `[package.bundle_cli]` declared (below), the reusable workflow
650
- generates the per-platform launcher and the matching `package.json#bin`
651
- entry for you at build time — both writes are skipped when the consumer
652
- already has either piece committed, so overrides remain trivial. To
653
- override, commit your own `bin/<bundle_cli.bin>.js` at the package root
654
- (or set `package.json#bin` explicitly); the workflow leaves both alone
655
- when present.
656
-
657
- Declare `[package.bundle_cli]` so the reusable workflow runs the
658
- cross-compile for you:
659
-
660
- ```toml
661
- [package.bundle_cli]
662
- bin = "my-cli" # `cargo build --bin <this>`
663
- crate_path = "crates/my-cli" # `cargo build` runs from here; defaults to `.`
664
- # Optional, for crates that gate the CLI behind a Cargo feature
665
- # (the `[[bin]] required-features = ["cli"]` shape):
666
- # features = ["cli"]
667
- # no_default_features = false
668
- ```
669
-
670
- For every per-target row the workflow runs `rustup target add
671
- <triple>`, then `cargo build --release --target <triple> --bin
672
- <bin>` from `crate_path`, and copies the resulting binary
673
- (with `.exe` suffix on Windows) into the per-target staging
674
- directory. The engine then packages that directory as the
675
- platform sub-package's artifact. The `main` row carries no
676
- per-target binary (the launcher above is committed source).
677
-
678
- > [!NOTE]
679
- > **Constraint.** The binary must build with a vanilla
680
- > `cargo build --release --target <triple> --bin <bin>` from
681
- > `crate_path`, optionally with `--features` /
682
- > `--no-default-features`. Crates that need env vars, alternate
683
- > manifests, Zig-cc cross toolchains, or other cargo flags
684
- > don't fit the recipe — write your own release workflow
685
- > instead.
686
-
687
- > [!NOTE]
688
- > **Linux binaries are statically linked against musl.** A
689
- > binary compiled directly against the GitHub-hosted runner's
690
- > glibc carries that glibc's version as a hard runtime
691
- > requirement, so the package would break on any older Linux.
692
- > The reusable workflow sidesteps that by swapping the Linux
693
- > compile triple from `*-linux-gnu*` to `*-linux-musl*` before
694
- > `cargo build` runs (the package's declared triple, the npm
695
- > platform-package name, and everything else consumer-visible
696
- > stay on the original `*-linux-gnu*`; only the binary inside
697
- > switches). Your CLI crate must be musl-compatible:
698
- >
699
- > - If it makes HTTPS calls, prefer `reqwest` with `rustls-tls`
700
- > features (the default since reqwest v0.13).
701
- > - If it links openssl directly, enable the `vendored` feature
702
- > on the `openssl` crate.
703
- > - If it uses `git2`, enable `vendored-openssl` /
704
- > `vendored-libgit2`.
705
- > - If it uses `rusqlite` / `libsqlite3-sys`, enable the
706
- > `bundled` feature.
707
- > - If it uses `libpq-sys` / `mysqlclient-sys` (Postgres /
708
- > MySQL clients), prefer a pure-Rust alternative
709
- > (`sqlx` with `rustls`, `postgres-native-tls` swapped for
710
- > `postgres-rustls`) — these crates have no clean static path.
711
- >
712
- > The musl build fails loudly at release time with a linker
713
- > error if any of the above is missed, so a forgotten feature
714
- > never produces a broken release — only a blocked one.
715
-
716
- > [!WARNING]
717
- > **Do not run `cargo build` in `npm run build` when `[package.bundle_cli]` is configured.**
718
- > The reusable workflow compiles the Rust binary and stages it **after**
719
- > your `npm run build` step, so the engine's musl binary always overwrites
720
- > whatever `npm run build` staged. A build script that also runs cargo with
721
- > the raw `-linux-gnu` triple and copies to `build/<triple>/` does wasted
722
- > work silently. If you migrated from a hand-authored `scripts/build.cjs`
723
- > to `[package.bundle_cli]`, remove the cargo invocation; keep only steps
724
- > that compile or generate genuinely separate artifacts (TypeScript, assets,
725
- > etc.).
726
-
727
- Each per-platform sub-package needs its own npm trusted-publisher
728
- registration (a policy on `my-cli` does not cover
729
- `my-cli-x86_64-unknown-linux-gnu`).
730
-
731
- > [!NOTE]
732
- > **First-publish lockfile chicken-and-egg.** Some scaffolding will
733
- > populate `optionalDependencies` in your top-level `package.json`
734
- > with entries for `my-cli-<triple>@<version>` ahead of the first
735
- > publish. Those packages don't exist on the registry yet — the
736
- > engine publishes them as part of *this* run — so a locally-generated
737
- > `package-lock.json` / `pnpm-lock.yaml` either fails to install or
738
- > silently drops the entries (pnpm 10 does the silent drop). On the
739
- > next CI run, the strict installs (`npm ci`,
740
- > `pnpm install --frozen-lockfile`) refuse because lockfile and
741
- > `package.json` disagree.
742
- >
743
- > The reusable workflow handles this transparently: every strict
744
- > install in the build matrix and the publish-job rebuild step
745
- > falls back to its non-strict form on failure (with a
746
- > `::warning::` line in the run log so the recovery is visible).
747
- > No consumer-side change is required; you can keep the lockfile
748
- > committed and the `optionalDependencies` declared.
749
-
750
- ### napi npm family
751
-
752
- Ship a [napi-rs](https://napi.rs) Node addon (a `.node` native library) as
753
- an npm per-platform family — consumers `import` the package and Node loads
754
- the prebuilt binary for their platform. The `@node-rs/*` / `@swc/core`
755
- distribution shape.
756
-
757
- Config:
758
-
759
- ```toml
760
- [[package]]
761
- name = "my-addon"
762
- kind = "npm"
763
- path = "packages/node"
764
- build = "napi"
765
- globs = ["packages/node/**", "crates/core/**"]
766
- targets = [
767
- "x86_64-unknown-linux-gnu",
768
- "aarch64-unknown-linux-gnu",
769
- "x86_64-apple-darwin",
770
- "aarch64-apple-darwin",
771
- "x86_64-pc-windows-msvc",
772
- ]
773
- ```
774
-
775
- The engine publishes a per-platform sub-package per target
776
- (`my-addon-<triple>`) plus a top-level package whose `optionalDependencies`
777
- pin them at the published version; napi-rs's loader resolves the matching
778
- one at runtime. The reusable workflow fans the build across your `targets`
779
- — one native runner per triple — so you never wire per-target steps
780
- yourself.
781
-
782
- **You own the build script.** Unlike `bundled-cli` — where the engine runs
783
- the cross-compile for you — the napi toolchain stays consumer-owned. For
784
- each per-target row the workflow runs your `package.json` `build` script
785
- with `TARGET` set to that triple (and `BUILD=napi`); your script runs
786
- `napi build` for it and stages the resulting `.node` under
787
- `build/<triple>/`, the directory the engine packages per-platform
788
- artifacts from:
789
-
790
- ```js
791
- // scripts/build.cjs — invoked by your package.json "build" script
792
- const target = process.env.TARGET;
793
- // The noarch main row runs with TARGET=main (or unset) — nothing to build.
794
- if (!target || target === 'main' || target === 'noarch') process.exit(0);
795
-
796
- // napi-rs emits `<name>.<triple>.node`; stage it under build/<triple>/,
797
- // e.g. napi build --release --target ${target} --output-dir build/${target}
798
- ```
799
-
800
- The `main` (noarch) row carries no per-target binary — its build run is a
801
- no-op for the `.node` and compiles only your TypeScript / JS.
802
-
803
- > [!NOTE]
804
- > **Cross-compilation is yours to arrange.** Each target builds on a
805
- > native runner where one exists (`x86_64-linux` on `ubuntu-latest`,
806
- > `aarch64-linux` on `ubuntu-24.04-arm`, darwin on `macos-latest`,
807
- > windows on `windows-2022`), so the common triples need no cross
808
- > toolchain — `napi build --target <native triple>` just works. For a
809
- > target that isn't native to its runner (a `*-musl` triple, or an
810
- > x86_64 macOS build on the arm64 runner), your build script owns
811
- > `rustup target add <triple>` and any linker / C-toolchain setup. This
812
- > is the one place napi differs from `bundled-cli`, where the engine
813
- > performs the gnu→musl mapping and installs the musl toolchain for you.
814
-
815
- Each per-platform sub-package needs its own npm trusted-publisher
816
- registration (a policy on `my-addon` does not cover
817
- `my-addon-x86_64-unknown-linux-gnu`) — same as the bundled-cli family
818
- above.
819
-
820
- The **first-publish lockfile chicken-and-egg** note under
821
- [Bundled-CLI npm family](#bundled-cli-npm-family) applies identically to
822
- napi families: the reusable workflow's strict installs self-heal, so you
823
- can keep the lockfile committed and the `optionalDependencies` declared.
824
-
825
- > [!NOTE]
826
- > **Both modes at once.** A package that is *both* a `.node` addon and a
827
- > CLI declares `build` as an array — see
828
- > [Multi-mode npm family](#multi-mode-npm-family) below.
829
-
830
- ### Multi-mode npm family
831
-
832
- For a package that is both a napi-rs Node addon (a `.node` library) **and**
833
- a CLI binary, declare `build` as an array. Each entry contributes its own
834
- per-platform family; the main package's `optionalDependencies` spans both.
835
- The `@swc/core` distribution shape.
836
-
837
- ```toml
838
- [[package]]
839
- name = "my-cli"
840
- kind = "npm"
841
- path = "packages/ts"
842
- globs = ["packages/ts/**", "crates/my-cli/**"]
843
- build = [
844
- { mode = "napi", name = "@my-cli/lib-{triple}" },
845
- { mode = "bundled-cli", name = "@my-cli/cli-{triple}" },
846
- ]
847
- targets = [
848
- "linux-x64-gnu",
849
- "darwin-arm64",
850
- "win32-x64-msvc",
851
- ]
852
- ```
853
-
854
- Each entry has a **mode** (`napi` or `bundled-cli`) and a **`name`
855
- template** for its platform packages. Variables:
856
-
857
- | Variable | Resolves to |
858
- |-------------|-------------------------------------------------------------------|
859
- | `{name}` | The main package's npm name (`pkg.npm` if set, else `pkg.name`). |
860
- | `{scope}` | Scope without `@` for scoped names (e.g. `myorg`); `""` if unscoped. |
861
- | `{base}` | Name without scope (e.g. `core` for `@myorg/core`). |
862
- | `{triple}` | Target triple as written in `targets` — required in the template. |
863
- | `{mode}` | The entry's mode (`napi` / `bundled-cli`). |
864
-
865
- `{version}` is intentionally not surfaced — platform package names are
866
- immutable identifiers; the version is pinned in `optionalDependencies`,
867
- not the name.
868
-
869
- **Single-mode (string) form is preserved.** `build = "napi"` and
870
- `build = ["napi"]` are equivalent and produce the historical
871
- `<name>-<triple>` platform-package names byte-for-byte. The mode-infix
872
- artifact-directory naming (`<name>-napi-<triple>`, `<name>-bundled-cli-<triple>`)
873
- only applies when `build` has more than one entry.
874
-
875
- **Validation rules** enforced at config load:
876
-
877
- - Each `mode` value (`napi`, `bundled-cli`) appears at most once per package.
878
- - Every `name` template must contain `{triple}`.
879
- - Unknown placeholders are rejected.
880
- - All entries must produce distinct platform-package name templates.
881
-
882
- Each platform package across **every** family needs its own npm
883
- trusted-publisher registration. For the config above, that's
884
- `@my-cli/lib-linux-x64-gnu`, `@my-cli/lib-darwin-arm64`,
885
- `@my-cli/lib-win32-x64-msvc`, `@my-cli/cli-linux-x64-gnu`,
886
- `@my-cli/cli-darwin-arm64`, `@my-cli/cli-win32-x64-msvc` — six total,
887
- one per platform package, plus the top-level `my-cli`.
888
-
889
- ### Rust CLI inside a PyPI wheel
890
-
891
- `pip install my-lib` on any platform gets a working CLI on `PATH` without
892
- the user installing a Rust toolchain. The `ruff` / `uv` / `pydantic-core`
893
- pattern.
894
-
895
- Config:
896
-
897
- ```toml
898
- [[package]]
899
- name = "my-py"
900
- kind = "pypi"
901
- build = "maturin"
902
- path = "packages/python"
903
- globs = ["packages/python/**", "crates/my-rust/**"]
904
- targets = [
905
- "x86_64-unknown-linux-gnu",
906
- "aarch64-unknown-linux-gnu",
907
- "x86_64-apple-darwin",
908
- "aarch64-apple-darwin",
909
- "x86_64-pc-windows-msvc",
910
- ]
911
- depends_on = ["my-rust"]
912
-
913
- [package.bundle_cli]
914
- bin = "my-cli"
915
- stage_to = "src/my_py/_binary"
916
- crate_path = "crates/my-rust"
917
- # Optional. Forwarded to `cargo build` when the binary lives behind
918
- # `[[bin]] required-features = ["cli"]` (the lib-with-optional-CLI shape:
919
- # ruff / uv / pydantic-core / biome / swc). Empty list = no `--features`
920
- # flag, identical to omitting the key.
921
- features = ["cli"]
922
- no_default_features = false
923
- ```
924
-
925
- The reusable workflow cross-compiles the binary per target and stages it
926
- into the package source tree before maturin runs. The same musl
927
- compatibility requirement that applies to bundled-cli npm packages
928
- applies here — see [Linux binaries are statically linked against
929
- musl](#bundled-cli-npm-family) above for the list of Cargo features to
930
- flip when the build fails on a system-library dependency.
931
-
932
- Your `pyproject.toml` ties the staged binary into a `console_scripts`
933
- entry:
934
-
935
- ```toml
936
- [project.scripts]
937
- my-cli = "my_py._binary:entrypoint"
938
-
939
- [tool.maturin]
940
- include = ["src/my_py/_binary/**"]
941
- ```
942
-
943
- Launcher in `packages/python/src/my_py/_binary/__init__.py`:
944
-
945
- ```python
946
- import os, sys
947
- from pathlib import Path
948
-
949
- def entrypoint():
950
- here = Path(__file__).parent
951
- binary = here / ("my-cli.exe" if os.name == "nt" else "my-cli")
952
- if not binary.exists():
953
- sys.stderr.write(f"my-cli binary not found at {binary}\n")
954
- sys.exit(1)
955
- os.execv(binary, [str(binary), *sys.argv[1:]])
956
- ```
957
-
958
- ## Python version source — required shape
959
-
960
- Every `kind = "pypi"` package **must** declare `[project].dynamic = ["version"]`
961
- in its `pyproject.toml`. Static `[project].version = "..."` literals are
962
- rejected at PR time by `putitoutthere check` (error code
963
- `PIOT_PYPI_STATIC_VERSION`) and again at publish time before any side effect.
964
-
965
- Why the requirement exists: putitoutthere does not edit `pyproject.toml`
966
- at release time (per the "no version computation" design commitment) — a
967
- static literal silently ships the previous release's wheel/sdist because
968
- the build backend reads whatever is on disk. The fix is the same across
969
- all supported Python build backends: declare the version as dynamic and
970
- let the backend derive it.
971
-
972
- ### Recommended: `hatch-vcs`
973
-
974
- The blessed path for new Python packages. The version comes from the
975
- latest git tag at build time, so no manual `pyproject.toml` edit is ever
976
- needed.
977
-
978
- ```toml
979
- [build-system]
980
- requires = ["hatchling", "hatch-vcs"]
981
- build-backend = "hatchling.build"
982
-
983
- [project]
984
- name = "your-package"
985
- dynamic = ["version"]
986
-
987
- [tool.hatch.version]
988
- source = "vcs"
989
- ```
990
-
991
- The reusable workflow sets `SETUPTOOLS_SCM_PRETEND_VERSION` on the build
992
- step to the planned version, which `hatch-vcs` honors. Per-package
993
- variants like `SETUPTOOLS_SCM_PRETEND_VERSION_FOR_<PKG>` are silently
994
- ignored by `hatch-vcs`; only the global form works.
995
-
996
- ### Also accepted
997
-
998
- - **`setuptools-scm`** (for setuptools-backed projects): same idea, same
999
- env-var handoff. Add `setuptools-scm` to `[build-system].requires`,
1000
- declare `dynamic = ["version"]`, and the workflow's
1001
- `SETUPTOOLS_SCM_PRETEND_VERSION` injection covers the build step.
1002
- - **Maturin** (for Python packages built from a Rust crate): pyproject
1003
- declares `dynamic = ["version"]`; the version source is the sibling
1004
- `Cargo.toml`'s `[package].version`. putitoutthere bumps `Cargo.toml`
1005
- before `maturin build` runs.
1006
-
1007
- If a Python package can't fit any of these three shapes, it's outside
1008
- putitoutthere's scope — write your own release workflow.
1009
-
1010
- ## Error codes
1011
-
1012
- Every consumer-visible failure carries a stable `PIOT_*` code in the
1013
- GitHub Actions `::error::` annotation and in the corresponding log
1014
- line. Grep the run log for the code, then look it up here.
1015
-
1016
- | Code | What trips it | Where it fires |
1017
- |------|---------------|----------------|
1018
- | `PIOT_NPM_MISSING_REPOSITORY` | An npm package's `package.json` is missing a non-empty `repository` field. Required by `npm publish --provenance`. | PR-time (`check.yml`) and publish-time preflight. See [`kind = "npm"`](#kind--npm). |
1019
- | `PIOT_NPM_NAME_MISMATCH` | `package.json`'s `name` disagrees with the configured `[[package]].name` (or `npm` override). `npm publish` packs the manifest name while piot's idempotency/tag bookkeeping uses the configured name. | PR-time and publish-time. See [`kind = "npm"`](#kind--npm). |
1020
- | `PIOT_CRATES_NAME_MISMATCH` | `Cargo.toml`'s `[package].name` disagrees with the configured `[[package]].name` (or `crate` override). | PR-time and publish-time. See [`kind = "crates"`](#kind--crates). |
1021
- | `PIOT_CRATES_MISSING_METADATA` | `Cargo.toml` lacks `[package].description` and/or `license` (or `license-file`). crates.io 400s without it. | PR-time and publish-time. |
1022
- | `PIOT_CRATES_FEATURE_NOT_DECLARED` | A `features` entry (on the package or in `bundle_cli.features`) is not declared in the crate's `[features]` table. | PR-time and publish-time. |
1023
- | `PIOT_CRATES_MISSING_BIN` | `bundle_cli.bin` is set but the target crate has no `[[bin]]` (or implicit-binary) of that name. | PR-time and publish-time. |
1024
- | `PIOT_CRATES_WORKSPACE_VERSION_MISMATCH` | `Cargo.toml` declares `version.workspace = true` but no ancestor declares `[workspace.package].version`. | PR-time and publish-time. |
1025
- | `PIOT_CRATES_FIRST_PUBLISH_TP_REJECTED` | crates.io returned 404 because the crate has never been published. Trusted Publishing binds to an already-published crate. Bootstrap with `CARGO_REGISTRY_TOKEN` (see [crates.io](#cratesio) above). | Publish-time only — the registry's response is the signal. |
1026
- | `PIOT_PYPI_STATIC_VERSION` | `pyproject.toml` declares a static `[project].version = "..."` literal. Use `[project].dynamic = ["version"]` instead (see [Python version source](#python-version-source--required-shape)). | PR-time and publish-time. |
1027
- | `PIOT_PYPI_NAME_MISMATCH` | `pyproject.toml`'s `[project].name` disagrees with the configured `[[package]].name` (or `pypi` override). | PR-time and publish-time. |
1028
- | `PIOT_PYPI_BUILD_BACKEND_MISMATCH` | `[build-system].build-backend` is set but doesn't match the configured `build` mode (`maturin` → `maturin`, `setuptools` → `setuptools.build_meta`, `hatch` → `hatchling.build`). | PR-time and publish-time. |
1029
- | `PIOT_PYPI_DYNAMIC_VERSION_NO_BACKEND` | `[project].dynamic` contains `"version"` but no `[tool.hatch.version]` or `[tool.setuptools_scm]` block declares the source. | PR-time and publish-time. |
1030
- | `PIOT_PYPI_MATURIN_INCLUDE_MISSING` | `bundle_cli` is set on a maturin package but `[tool.maturin].include` doesn't cover `bundle_cli.stage_to`. The cross-compiled binary wouldn't land in any wheel. | PR-time and publish-time. |
1031
- | `PIOT_AUTH_NO_TOKEN` | The publish job reached the registry-auth step with no token resolved (neither an OIDC-minted token nor a caller-provided long-lived token). Almost always means the reusable workflow's trusted-publisher exchange failed silently or the caller-provided secret was empty. | Publish-time only. |
1032
- | `PIOT_PUBLISH_EMPTY_PLAN` | `publish` was invoked but `plan` returned zero rows for a reason other than `release: skip`. The reusable workflow's gate normally prevents this; if it fires, the gate was bypassed or the engine is inconsistent. | Publish-time only. |
1033
-
1034
- ## Release health
1035
-
1036
- The registry is the source of truth; git tags are putitoutthere's record
1037
- of what's been released (it derives "last released version" from them).
1038
- A few features keep the two in sync — `status` reports drift, `reconcile`
1039
- and the publish-path auto-heal fix it, and `plan` previews what a release
1040
- from the current ref would ship before you run one. `verify` rounds it out
1041
- by reporting whether each package already publishes via OIDC or still
1042
- depends on a long-lived token.
1043
-
1044
- ### `status` — registry-vs-tag drift report
1045
-
1046
- `status` reconciles, per package, the latest git tag against the
1047
- registry's latest published version — over the public registry APIs
1048
- (crates.io / npm / PyPI), no auth required — and flags any drift:
1049
-
1050
- ```
1051
- package tag registry state
1052
- mypkg-rust — 0.0.1 ⚠ published, untagged
1053
- mypkg-npm 0.0.1 0.0.1 ✓ in sync
1054
- mypkg-py 0.0.1 0.0.1 ✓ in sync
1055
- ```
1056
-
1057
- | State | Meaning |
1058
- |-------|---------|
1059
- | `in sync` | the latest tag matches the registry's latest version |
1060
- | `unreleased` | no tag, and nothing published |
1061
- | `published, untagged` | live on the registry but no tag — the drift that strands a package |
1062
- | `tagged, unpublished` | tagged, but the registry doesn't have that version |
1063
- | `version mismatch` | the tag and the registry disagree on the latest version |
1064
- | `registry unreachable` | the registry couldn't be reached (reported, never gated) |
1065
-
1066
- Why it matters: a half-failed run that publishes a version but never
1067
- tags it leaves the package `published, untagged`. Because the planner
1068
- reads "last released" from tags, that package then looks unreleased,
1069
- skips its already-live version forever, and can never bump — while its
1070
- dependents keep bumping past it. `status` surfaces that in one line.
1071
-
1072
- - `--check` exits non-zero on any drift state — run it as a CI gate so
1073
- drift can't merge unnoticed.
1074
- - `--json` emits the rows as machine-readable JSON.
1075
-
1076
- `putitoutthere` is published to npm, so run it with `npx` (it reads your
1077
- git tags, so make sure they're fetched):
1078
-
1079
- ```bash
1080
- # Report drift across every package in putitoutthere.toml:
1081
- npx putitoutthere status
1082
-
1083
- # Exit non-zero if anything has drifted:
1084
- npx putitoutthere status --check
1085
- echo $? # 1 when drifted, 0 when in sync
1086
-
1087
- # Machine-readable rows:
1088
- npx putitoutthere status --json
1089
- # [{"package":"mypkg-rust","kind":"crates","tag":null,"tagVersion":null,
1090
- # "registry":"0.0.1","registryUnreachable":false,
1091
- # "state":"published, untagged","drift":true}, …]
1092
- ```
1093
-
1094
- To gate every PR on release-state drift, add a step to any workflow —
1095
- checking out tags so `status` can compare them against the registry:
1096
-
1097
- ```yaml
1098
- - uses: actions/checkout@v4
1099
- with:
1100
- fetch-depth: 0 # status compares local tags vs the registry
1101
- - run: npx putitoutthere status --check
1102
- ```
1103
-
1104
- ### `plan` — preview what a release would ship
1105
-
1106
- `plan` answers "what would a release from this ref actually do?" Alongside
1107
- the build matrix, it reports a verdict per package — `PUBLISH` (the
1108
- planned version isn't on the registry yet), `SKIP` (already published), or
1109
- `UNKNOWN` (the registry couldn't be reached) — using the same
1110
- `isPublished` check the publish path runs, so the preview matches reality.
1111
- It also flags **version skew**: a package that would `PUBLISH` while a
1112
- dependency it `depends_on` would `SKIP` (a dependent shipping ahead of a
1113
- stuck dependency — the drift that strands a release).
1114
-
1115
- ```
1116
- $ npx putitoutthere plan
1117
- 3 matrix row(s):
1118
- mypkg-rust version=0.0.1 target=noarch artifact=mypkg-rust-crate
1119
- mypkg-npm version=0.0.2 target=noarch artifact=mypkg-npm-pkg
1120
- mypkg-py version=0.0.2 target=sdist artifact=mypkg-py-sdist
1121
- publish plan:
1122
- · mypkg-rust 0.0.1 SKIP
1123
- → mypkg-npm 0.0.2 PUBLISH
1124
- → mypkg-py 0.0.2 PUBLISH
1125
- ⚠ version skew: mypkg-npm would PUBLISH while its dependency mypkg-rust SKIPs
1126
- ```
1127
-
1128
- It's always on — no flag to remember — and degrades gracefully: an
1129
- unreachable registry yields `UNKNOWN` and the matrix is still emitted, so
1130
- `plan` never aborts. `--json` emits `{ matrix, verdicts, skew }` (the
1131
- `matrix` field is the same array the reusable workflow consumes).
1132
-
1133
- ### `verify` — OIDC trusted publisher vs token, per registry
1134
-
1135
- `verify` answers "do I still need the registry token, or is OIDC trusted
1136
- publishing active?" For each package it reads the latest release's trust
1137
- attribution from the registry's **public** surface — no secrets — and
1138
- classifies it:
1139
-
1140
- ```
1141
- $ npx putitoutthere verify
1142
- mypkg-rust 0.0.1 ✓ oidc trusted publisher (safe to drop the token)
1143
- mypkg-npm 0.0.1 ✓ oidc trusted publisher (safe to drop the token)
1144
- mypkg-py 0.0.1 ⚠ token token-dependent — no trusted publisher
1145
- ```
1146
-
1147
- | Posture | Meaning |
1148
- |---------|---------|
1149
- | `oidc` | the latest release carries a trusted-publisher / provenance attestation — the long-lived token is no longer needed |
1150
- | `token` | no such attestation — still token-dependent |
1151
- | `unpublished` | nothing published yet, so nothing to attribute |
1152
- | `unreachable` | the registry couldn't be reached (reported, never gated) |
1153
-
1154
- The signal comes straight from each registry: crates.io's
1155
- `version.trustpub_data`, npm's provenance attestations endpoint, and
1156
- PyPI's PEP 740 provenance — read with the same name resolution the publish
1157
- path uses.
1158
-
1159
- - `--check` exits non-zero while any package is still `token`-dependent —
1160
- gate CI on it to enforce the zero-secret OIDC steady state.
1161
- - `--json` emits the rows.
1162
-
1163
- ```bash
1164
- # Report the trust posture of every package:
1165
- npx putitoutthere verify
1166
-
1167
- # Fail CI until every package is on a trusted publisher:
1168
- npx putitoutthere verify --check
1169
-
1170
- # Machine-readable rows:
1171
- npx putitoutthere verify --json
1172
- ```
1173
-
1174
- ### `reconcile` — backfill missing tags on demand
1175
-
1176
- `reconcile` fixes the `published, untagged` drift `status` reports: for
1177
- every package that is live on its registry but has no tag, it creates the
1178
- missing tag (and pushes it). It's the on-demand companion to auto-heal —
1179
- where auto-heal only fires for a package caught in a release run,
1180
- `reconcile` heals an **already-stuck** package without a release, so you
1181
- can run it the moment `status` flags drift.
1182
-
1183
- The tag is pointed at a sibling package already tagged at that version —
1184
- the real release commit, e.g. a crate left untagged while its npm/PyPI
1185
- siblings tagged the same merge — and at `HEAD` when no sibling tag exists.
1186
- It reuses the exact drift detection `status` reports and the exact tagging
1187
- `publish` heals with, so it can never create a tag a release wouldn't.
1188
-
1189
- - Idempotent: a re-run is a no-op (already-correct tags are untouched).
1190
- - `--dry-run` reports what it would create without writing anything.
1191
- - `--json` emits the actions.
1192
-
1193
- ```bash
1194
- # Backfill any missing tags across putitoutthere.toml:
1195
- npx putitoutthere reconcile
1196
-
1197
- # Preview without writing:
1198
- npx putitoutthere reconcile --dry-run
1199
- # mypkg-rust: 0.0.1 live, no tag → would create mypkg-rust-v0.0.1 at a1b2c3d (sibling)
1200
-
1201
- # Machine-readable actions:
1202
- npx putitoutthere reconcile --json
1203
- ```
1204
-
1205
- Run it in CI with the release job's permissions (it pushes the tag),
1206
- checking out tags first:
1207
-
1208
- ```yaml
1209
- - uses: actions/checkout@v4
1210
- with:
1211
- fetch-depth: 0 # reconcile compares local tags vs the registry
1212
- - run: npx putitoutthere reconcile
1213
- ```
1214
-
1215
- ### Auto-heal
1216
-
1217
- The most common drift — a version live on the registry but missing its
1218
- tag — heals itself. **There's nothing to run**: when a release runs and
1219
- `publish` finds a version already published, it writes the missing tag
1220
- (at the release commit) instead of skipping silently. A package stranded
1221
- by an earlier half-failed run recovers on its **next release run** — it
1222
- has no tag, so it's force-selected into the plan, found already-published,
1223
- and tagged. No manual tag surgery. Idempotent: already-tagged packages
1224
- are untouched.
1225
-
1226
- If the repo has nothing else to release, don't wait for the next run —
1227
- heal the stuck package now with the `reconcile` command above, or trigger
1228
- a [manual release](#manual-release) for it (`release_packages`).
1229
-
1230
- ## Project layout
1231
-
1232
- - [`CHANGELOG.md`](./CHANGELOG.md) — per-release changes.
1233
- - [`MIGRATIONS.md`](./MIGRATIONS.md) — per-version upgrade guide.
1234
- - [`notes/design-commitments.md`](./notes/design-commitments.md) — non-goals.
1235
- - [`notes/internals/`](./notes/internals/) — internal contracts (artifact
1236
- layout, runner setup) that the reusable workflow honors so consumers don't
1237
- have to.