putitoutthere 0.1.47 → 0.1.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/MIGRATIONS.md +297 -0
  3. package/README.md +68 -10
  4. package/action.yml +8 -4
  5. package/dist/action.d.ts +3 -3
  6. package/dist/action.d.ts.map +1 -1
  7. package/dist/action.js +7 -8
  8. package/dist/action.js.map +1 -1
  9. package/dist/cli.d.ts +12 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +22 -8
  12. package/dist/cli.js.map +1 -1
  13. package/dist/completeness.js +6 -0
  14. package/dist/completeness.js.map +1 -1
  15. package/dist/error-codes.d.ts +31 -0
  16. package/dist/error-codes.d.ts.map +1 -0
  17. package/dist/error-codes.js +32 -0
  18. package/dist/error-codes.js.map +1 -0
  19. package/dist/handlers/crates.d.ts +2 -2
  20. package/dist/handlers/crates.d.ts.map +1 -1
  21. package/dist/handlers/crates.js +18 -6
  22. package/dist/handlers/crates.js.map +1 -1
  23. package/dist/handlers/npm-platform.d.ts.map +1 -1
  24. package/dist/handlers/npm-platform.js +11 -7
  25. package/dist/handlers/npm-platform.js.map +1 -1
  26. package/dist/handlers/npm.d.ts.map +1 -1
  27. package/dist/handlers/npm.js +0 -3
  28. package/dist/handlers/npm.js.map +1 -1
  29. package/dist/handlers/pypi.d.ts +30 -11
  30. package/dist/handlers/pypi.d.ts.map +1 -1
  31. package/dist/handlers/pypi.js +44 -157
  32. package/dist/handlers/pypi.js.map +1 -1
  33. package/dist/plan.js +21 -6
  34. package/dist/plan.js.map +1 -1
  35. package/dist/publish.d.ts +1 -2
  36. package/dist/publish.d.ts.map +1 -1
  37. package/dist/publish.js +14 -20
  38. package/dist/publish.js.map +1 -1
  39. package/dist/types.d.ts +22 -1
  40. package/dist/types.d.ts.map +1 -1
  41. package/dist/types.js +16 -0
  42. package/dist/types.js.map +1 -1
  43. package/dist/verbose.d.ts.map +1 -1
  44. package/dist/verbose.js +40 -0
  45. package/dist/verbose.js.map +1 -1
  46. package/package.json +2 -3
package/CHANGELOG.md CHANGED
@@ -12,12 +12,14 @@ are prefixed `**BREAKING**` and link to the matching section in
12
12
 
13
13
  ### Added
14
14
 
15
+ - **`workflow_call` output `has_pypi`.** The reusable workflow now emits a string `'true'`/`'false'` indicating whether the planned matrix contains any `kind = "pypi"` rows. Consumers gate their caller-side `pypi-publish` job on this so non-PyPI repos paste the canonical template verbatim without paying any runtime cost. Computed in the `plan` job from the matrix output.
15
16
  - **Reusable workflow `.github/workflows/release.yml` (`workflow_call`).** The single user-facing surface. Consumer integration is one `uses: thekevinscott/putitoutthere/.github/workflows/release.yml@v0` line in their own `release.yml`; pinned action versions, plan/build/publish orchestration, and GitHub Release creation all live inside. Three optional inputs: `environment` (default `release`), `node_version` (default `24`), `python_version` (default `3.12`). No `dry_run`, `working_directory`, or `config` inputs — the plan job is already side-effect-free, the config file is `putitoutthere.toml` at the repo root, period. The engine is invoked via `uses: thekevinscott/putitoutthere@v0` so the workflow file and the engine always agree on a single git ref.
16
17
 
17
18
  - **`[package.bundle_cli]` recipe for maturin pypi packages** (#217). Opt-in declarative shape for libraries that ship a Rust CLI inside each wheel (the `ruff` / `uv` / `pydantic-core` pattern). Declare `bin`, `stage_to`, and optional `crate_path`; the reusable workflow cross-compiles the binary per target, stages it into the package source tree, and maturin picks it up via `[tool.maturin].include`. Requires `build = "maturin"` and non-empty `targets`. See [README → Recipes → Rust CLI inside a PyPI wheel](./README.md#rust-cli-inside-a-pypi-wheel). No behavior change for existing packages that don't declare the block.
18
19
 
19
20
  ### Changed
20
21
 
22
+ - **BREAKING: PyPI uploads moved to a caller-side `pypi-publish` job.** PyPI's Trusted Publisher matching filters candidates by `repository_owner` + `repository_name` *before* checking `job_workflow_ref` ([Warehouse implementation](https://github.com/pypi/warehouse/blob/main/warehouse/oidc/models/github.py)); since the OIDC `repository` claim always reflects the caller's repo even inside a reusable workflow, a TP registered against `thekevinscott/putitoutthere` is filtered out before the workflow_ref is ever checked. PyPI explicitly documents this as unsupported ([troubleshooting](https://docs.pypi.org/trusted-publishers/troubleshooting/)). Tracked at [pypi/warehouse#11096](https://github.com/pypi/warehouse/issues/11096), no timeline. The engine still does plan + build + version-rewrite + git tag for PyPI; the actual upload (`pypa/gh-action-pypi-publish`) now runs in the consumer's workflow file as a second job, gated on `needs.release.outputs.has_pypi`. The canonical template grew from ~12 → ~30 lines but remains a single copy-paste — the `if:` skips the job for non-PyPI repos. See [MIGRATIONS.md](./MIGRATIONS.md#pypi-uploads-moved-to-caller-side-job) and [`notes/audits/2026-04-28-pypi-tp-reusable-workflow-constraint.md`](./notes/audits/2026-04-28-pypi-tp-reusable-workflow-constraint.md).
21
23
  - **Public consumer surface collapsed to the README + the reusable workflow.** The CLI, the JS action (`action.yml`), and the diagnostic subcommands (`doctor`, `preflight`) are internal seams the reusable workflow invokes; consumers do not call them. The entire `docs/` directory is removed — `README.md` is the single user-facing surface. See [MIGRATIONS.md](./MIGRATIONS.md#public-surface-collapsed-to-a-reusable-workflow) for the before/after.
22
24
  - **Auth is OIDC trusted publishers only.** The reusable workflow does not pass long-lived registry tokens (`NPM_TOKEN`, `PYPI_API_TOKEN`, `CARGO_REGISTRY_TOKEN`) as secrets. The engine's env-var fallback code paths still exist (in `src/auth.ts`); they're just not reachable through the reusable workflow.
23
25
  - **Repository renamed `put-it-out-there` → `putitoutthere`.** GitHub auto-redirects the old slug, but consumers with the old URL pinned in `package.json`, `Cargo.toml`, `pyproject.toml`, or workflow files should update them. See [MIGRATIONS.md](./MIGRATIONS.md#repository-renamed-put-it-out-there--putitoutthere).
@@ -45,6 +47,61 @@ are prefixed `**BREAKING**` and link to the matching section in
45
47
 
46
48
  ### Fixed
47
49
 
50
+ - **PyPI artifact discovery now matches the documented `{name}-sdist` and `{name}-wheel-{target}` shapes exactly.** (#244)
51
+ Previously the handler used a bare prefix match (`entry.startsWith("{name}-")`), which silently picked up sibling packages whose names extended the same prefix (e.g. `foo`'s discovery matched `foo-extras-sdist`). The handler now matches the sdist directory exactly and the wheel directories by `{name}-wheel-` prefix only. Affects multi-package repos where one pypi package's name is a prefix of another's.
52
+
53
+ - **Reusable workflow's maturin sdist row now uses `command: sdist`.** (#244)
54
+ `maturin build --sdist` builds a wheel AND an sdist; the sdist row's
55
+ artifact tarball ended up containing a manylinux wheel that collided
56
+ with the per-target wheel rows at upload time, causing twine to abort
57
+ with `400 File already exists`. Splitting the sdist invocation to use
58
+ `command: sdist` (sdist-only) eliminates the collision.
59
+
60
+ - **Synthesized npm platform packages now inherit `repository`, `license`, and `homepage` from the main `package.json`.** (#244)
61
+ npm's provenance verifier rejected platform tarballs with `E422 Error verifying sigstore provenance bundle: Failed to validate repository information: package.json: "repository.url" is "", expected to match "https://github.com/<owner>/<repo>"`. The synthesizer used to write only `name`/`version`/`os`/`cpu`/`files`/`main`/`libc`; the empty repository URL didn't match the publishing repo baked into the sigstore bundle. Identity fields are now copied from the main package so per-target tarballs validate. Affects `build = "napi"` and `build = "bundled-cli"` packages.
62
+
63
+ - **Reusable workflow's npm build step now forces `shell: bash`.** (#244)
64
+ The build matrix can target Windows runners, where GitHub Actions defaults
65
+ to `pwsh` for `run:` blocks. The npm build's `if [ -f package-lock.json ]`
66
+ branch is bash syntax, which PowerShell parsed as a malformed expression
67
+ and aborted with `ParserError`. Adding `shell: bash` makes the step
68
+ portable across Linux, macOS, and Windows runners. No config changes
69
+ required for consumers; pure JS-on-ubuntu setups were unaffected.
70
+
71
+ - **Reusable workflow now exchanges OIDC ID-token for a `CARGO_REGISTRY_TOKEN`
72
+ before invoking the engine.** (#244) `cargo publish` was failing with
73
+ `error: no token found, please run cargo login` because the publish
74
+ job's env had no `CARGO_REGISTRY_TOKEN`. PyPI uploads (twine) and npm
75
+ publish both consume the OIDC ID-token directly via registry-side
76
+ acceptance; cargo doesn't — it needs a registry-issued bearer token,
77
+ which `rust-lang/crates-io-auth-action@v1` produces from the OIDC
78
+ ID-token. The workflow now runs that action conditionally (only when
79
+ the plan contains a `kind = "crates"` row) and exports the resulting
80
+ token to `$GITHUB_ENV` for the engine subprocess. No config or
81
+ workflow changes required for consumers.
82
+
83
+ - **Crates publish's pre-cargo dirty-tree check now ignores the
84
+ reusable workflow's `artifacts/` scratch directory.** (#244)
85
+ The pre-publish guard scans `git status --porcelain` for stray edits
86
+ outside the managed `Cargo.toml` (the engine passes `--allow-dirty`
87
+ to cargo to permit the writeVersion bump, then re-imposes a narrower
88
+ check). Reusable workflow's `actions/download-artifact@v4` step
89
+ always creates `artifacts/` under cwd, even for crates-only
90
+ fixtures with nothing to download — the pre-check was rejecting
91
+ with `unexpected dirty files in the working tree outside ... -
92
+ artifacts/`. The scan now treats `${ctx.artifactsRoot}` (the dir
93
+ the engine itself populates) as engine-managed and skips it. No
94
+ config or workflow changes required.
95
+
96
+ - **Crates publish no longer fails the completeness check.** (#244)
97
+ `cargo publish` packages and uploads from source on the registry
98
+ side, so the reusable workflow never produces a `<name>-crate/`
99
+ artifact directory. The pre-publish completeness check was demanding
100
+ a `.crate` file that nothing in the pipeline ever creates, which made
101
+ any consumer with a `kind = "crates"` package fail with
102
+ `missing artifact directory <name>-crate/` before cargo was ever
103
+ invoked. Crates rows now skip the completeness check (same reasoning
104
+ as vanilla npm rows). No config or workflow changes required.
48
105
  - **Scaffolded `release.yml` now forwards `GITHUB_TOKEN` to the publish
49
106
  step.** piot has cut GitHub Releases alongside tag pushes since #26, but
50
107
  Actions doesn't auto-mount the runner token as an env var, so the
package/MIGRATIONS.md CHANGED
@@ -21,6 +21,303 @@ Each section covers five things, in order:
21
21
 
22
22
  ## Unreleased
23
23
 
24
+ ### PyPI uploads moved to caller-side job
25
+
26
+ **Summary.** PyPI's Trusted Publisher matching filters candidates by
27
+ `repository_owner` + `repository_name` *before* checking
28
+ `job_workflow_ref`
29
+ ([Warehouse implementation](https://github.com/pypi/warehouse/blob/main/warehouse/oidc/models/github.py)).
30
+ The OIDC `repository` claim always reflects the caller's repo —
31
+ including inside a reusable workflow — so a TP registered against
32
+ the reusable workflow's repo is filtered out before workflow_ref
33
+ is even checked. PyPI documents this as unsupported
34
+ ([troubleshooting](https://docs.pypi.org/trusted-publishers/troubleshooting/)).
35
+ Tracked at [pypi/warehouse#11096](https://github.com/pypi/warehouse/issues/11096),
36
+ no timeline.
37
+
38
+ To preserve OIDC trusted publishing for PyPI without setting
39
+ `PYPI_API_TOKEN`, the upload step (`pypa/gh-action-pypi-publish`)
40
+ now runs in the consumer's own workflow file as a second job,
41
+ gated on the new `has_pypi` output. The engine still owns plan,
42
+ build, version-rewrite, and git-tag creation for PyPI rows; only
43
+ the actual upload moves. See
44
+ [`notes/audits/2026-04-28-pypi-tp-reusable-workflow-constraint.md`](./notes/audits/2026-04-28-pypi-tp-reusable-workflow-constraint.md)
45
+ for the full diagnosis.
46
+
47
+ **Required changes.** Update `.github/workflows/release.yml`:
48
+
49
+ Before (~12 lines):
50
+
51
+ ```yaml
52
+ name: Release
53
+ on:
54
+ push:
55
+ branches: [main]
56
+ jobs:
57
+ release:
58
+ uses: thekevinscott/putitoutthere/.github/workflows/release.yml@v0
59
+ permissions:
60
+ contents: write
61
+ id-token: write
62
+ ```
63
+
64
+ After (~30 lines, single copy-paste from README → Quickstart):
65
+
66
+ ```yaml
67
+ name: Release
68
+ on:
69
+ push:
70
+ branches: [main]
71
+ jobs:
72
+ release:
73
+ uses: thekevinscott/putitoutthere/.github/workflows/release.yml@v0
74
+ permissions:
75
+ contents: write
76
+ id-token: write
77
+
78
+ pypi-publish:
79
+ needs: release
80
+ if: needs.release.outputs.has_pypi == 'true'
81
+ runs-on: ubuntu-latest
82
+ permissions:
83
+ id-token: write
84
+ steps:
85
+ - uses: actions/download-artifact@v4
86
+ with:
87
+ pattern: '*-sdist'
88
+ path: dist/
89
+ merge-multiple: true
90
+ - uses: actions/download-artifact@v4
91
+ with:
92
+ pattern: '*-wheel-*'
93
+ path: dist/
94
+ merge-multiple: true
95
+ - uses: pypa/gh-action-pypi-publish@release/v1
96
+ ```
97
+
98
+ The `pypi-publish` job's `if:` gate skips it for non-PyPI repos —
99
+ paste verbatim regardless of what you publish. Crates.io and npm
100
+ are unaffected; their TP claim semantics work fine inside the
101
+ reusable workflow.
102
+
103
+ **No PyPI TP re-registration required.** Your existing TP
104
+ registration (against your repo, your `release.yml`, optional
105
+ environment) was already correct for this pattern. If you'd
106
+ attempted to register a TP against `thekevinscott/putitoutthere`
107
+ to work around the prior failure, remove that entry — it would
108
+ have never matched anyway.
109
+
110
+ **Deprecations removed.** None.
111
+
112
+ **Behavior changes without code changes.** PyPI upload step now
113
+ runs in the consumer's workflow context. The reusable workflow's
114
+ publish job no longer installs `twine` or `setup-python`; engine
115
+ log lines for PyPI rows now read "delegated to caller-side upload
116
+ step" instead of "authenticating via OIDC".
117
+
118
+ **Verification.** Push a release. The reusable workflow's
119
+ `release` job creates and pushes the git tag for PyPI rows; the
120
+ caller's `pypi-publish` job runs `pypa/gh-action-pypi-publish`
121
+ and uploads to PyPI. Check `https://pypi.org/project/<name>/<version>/`
122
+ to confirm.
123
+
124
+ ---
125
+
126
+ ### PyPI artifact discovery matches `{name}-sdist` and `{name}-wheel-` exactly
127
+
128
+ **Summary.** `src/handlers/pypi.ts:collectArtifacts` used a bare prefix
129
+ match (`entry.startsWith("{name}-")`) to find a package's artifact
130
+ directories under `artifacts/`. Sibling packages whose names extended
131
+ the same prefix (`foo` and `foo-extras`) collided: `foo`'s discovery
132
+ also picked up `foo-extras-sdist`, and twine then uploaded the sibling's
133
+ tarball under `foo`'s OIDC identity, failing PyPI's project-name check.
134
+ The handler now matches the sdist directory exactly (`{name}-sdist`)
135
+ and the wheel directories by `{name}-wheel-` prefix only — the two
136
+ shapes the planner documents in §12.4.
137
+
138
+ **Required changes.** None.
139
+
140
+ **Deprecations removed.** None.
141
+
142
+ **Behavior changes without code changes.** Repos with multiple pypi
143
+ packages where one name is a prefix of another (e.g. `foo` and
144
+ `foo-extras`) no longer cross-upload artifacts. Single-package repos
145
+ and repos with non-overlapping names are unaffected.
146
+
147
+ **Verification.** A repo declaring both `foo` and `foo-extras` as
148
+ pypi packages publishes the correct tarballs to each project; neither
149
+ job uploads the other's artifacts.
150
+
151
+ ---
152
+
153
+ ### Reusable workflow's maturin sdist row uses `command: sdist`
154
+
155
+ **Summary.** The reusable workflow's pypi-maturin build step was a single
156
+ `PyO3/maturin-action@v1` invocation with `command: build` and an
157
+ `--sdist` flag conditional on the row being the sdist target. `maturin
158
+ build --sdist` is documented as "build a wheel AND an sdist" — the
159
+ sdist's artifact directory ended up containing both a `.tar.gz` and a
160
+ manylinux wheel, which collided at upload time with the per-target
161
+ wheel rows and aborted twine with `400 File already exists`. The build
162
+ step is now split into two: `command: sdist` for the sdist row
163
+ (sdist-only) and `command: build` with `--target` for wheel rows.
164
+
165
+ **Required changes.** None.
166
+
167
+ **Deprecations removed.** None.
168
+
169
+ **Behavior changes without code changes.** Maturin packages with a
170
+ `sdist` row in their plan now upload a single `.tar.gz` from that row,
171
+ not a wheel-plus-sdist pair. Per-target wheel rows are unaffected.
172
+
173
+ **Verification.** A maturin-built package with `sdist` in `targets`
174
+ publishes to PyPI without `400 File already exists`. The sdist
175
+ artifact directory contains `.tar.gz` only.
176
+
177
+ ---
178
+
179
+ ### Synthesized npm platform packages inherit `repository`/`license`/`homepage`
180
+
181
+ **Summary.** npm's provenance verifier rejected platform-package tarballs
182
+ with `E422 Error verifying sigstore provenance bundle: Failed to validate
183
+ repository information: package.json: "repository.url" is ""`. The
184
+ synthesizer in `src/handlers/npm-platform.ts` previously wrote only
185
+ `name`/`version`/`os`/`cpu`/`files`/`main`/`libc` into the per-target
186
+ `package.json`. The publishing GitHub repo URL is bound into the
187
+ sigstore bundle by `npm publish --provenance`; npm cross-checks it
188
+ against `package.json.repository.url` at upload time, so an empty value
189
+ fails verification. Identity fields (`repository`, `license`, `homepage`)
190
+ are now read from the main package's `package.json` and copied into each
191
+ synthesized platform package. Affects `build = "napi"` and
192
+ `build = "bundled-cli"` packages.
193
+
194
+ **Required changes.** None — the fix is automatic. To benefit, ensure
195
+ the main package's `package.json` declares a `repository.url` that
196
+ matches the publishing repo (npm provenance has always required this for
197
+ the main package; platform packages now share the same expectation).
198
+
199
+ **Deprecations removed.** None.
200
+
201
+ **Behavior changes without code changes.** Per-target platform tarballs
202
+ on the registry now carry the same `repository`/`license`/`homepage`
203
+ values as the main package, instead of being absent.
204
+
205
+ **Verification.** A `build = "napi"` or `build = "bundled-cli"` package
206
+ publishes its platform tarballs to npm without `E422` provenance errors.
207
+ `npm view <pkg>-<target>@<version> repository` returns the main
208
+ package's repository URL.
209
+
210
+ ---
211
+
212
+ ### Reusable workflow's npm build step forces `shell: bash`
213
+
214
+ **Summary.** The build matrix can target Windows runners. GitHub Actions
215
+ defaults to `pwsh` for `run:` blocks on Windows, but the npm build's
216
+ shape detection (`if [ -f package-lock.json ]; then npm ci; elif ... fi`)
217
+ is bash syntax — PowerShell parsed it as a malformed expression and
218
+ aborted with `ParserError` before any package manager ran. The step now
219
+ sets `shell: bash` explicitly, which is portable across Linux, macOS,
220
+ and Windows runners (Git Bash ships on `windows-latest`).
221
+
222
+ **Required changes.** None.
223
+
224
+ **Deprecations removed.** None.
225
+
226
+ **Behavior changes without code changes.** Consumers whose plan includes
227
+ an npm package targeting Windows runners (e.g. native node-addon shapes,
228
+ `napi-rs` matrices) now succeed past the install step. Linux/macOS-only
229
+ matrices are unaffected — bash was already the default there.
230
+
231
+ **Verification.** An npm package with a Windows row in its plan
232
+ completes the install + build step on `windows-latest`; the job log
233
+ shows `Run if [ -f package-lock.json ]` executing under bash, not pwsh.
234
+
235
+ ---
236
+
237
+ ### Reusable workflow exchanges OIDC token for `CARGO_REGISTRY_TOKEN`
238
+
239
+ **Summary.** Crates publishes were failing with `error: no token found,
240
+ please run cargo login` — the reusable workflow was relying on cargo to
241
+ find an OIDC token in env, but cargo only consumes
242
+ `CARGO_REGISTRY_TOKEN` (a registry-issued bearer), not raw OIDC
243
+ ID-tokens. The publish job now runs `rust-lang/crates-io-auth-action@v1`
244
+ when the plan contains a crates row and exports its `outputs.token`
245
+ as `CARGO_REGISTRY_TOKEN` for the engine subprocess.
246
+
247
+ **Required changes.** None for consumers using the reusable workflow as
248
+ documented. Repos publishing to crates.io must have a configured trusted
249
+ publisher on crates.io pointing at their `release.yml` — same prerequisite
250
+ as before, just now actually exercised.
251
+
252
+ **Deprecations removed.** None.
253
+
254
+ **Behavior changes without code changes.** Crates publish in the
255
+ reusable workflow now reaches the registry; previously it failed at
256
+ the cargo invocation. JS/Python-only repos are unaffected — the auth
257
+ step is gated on `contains(needs.plan.outputs.matrix, '"kind":"crates"')`
258
+ and skips entirely when no crates row is in the plan.
259
+
260
+ **Verification.** A `kind = "crates"` package whose trusted publisher is
261
+ configured on crates.io now publishes successfully through the reusable
262
+ workflow. The publish job log shows the `Authenticate with crates.io
263
+ (OIDC)` step running before `putitoutthere publish`.
264
+
265
+ ---
266
+
267
+ ### Crates publish's pre-cargo dirty-tree check ignores `artifacts/`
268
+
269
+ **Summary.** The crates handler scans `git status --porcelain` before
270
+ invoking `cargo publish --allow-dirty`, refusing to proceed if anything
271
+ other than the managed `Cargo.toml` is dirty (the writeVersion bump
272
+ runs in the same job and would otherwise be the only legitimate dirty
273
+ file). The reusable workflow's `actions/download-artifact@v4` step
274
+ always creates `artifacts/` at the repo root before publish runs —
275
+ even for crates-only fixtures that have nothing to download — and the
276
+ pre-check was rejecting on `?? artifacts/`. The scan now treats the
277
+ engine's own `artifactsRoot` as managed scratch space and skips files
278
+ under it.
279
+
280
+ **Required changes.** None.
281
+
282
+ **Deprecations removed.** None.
283
+
284
+ **Behavior changes without code changes.** Crates publishes that
285
+ previously errored with `unexpected dirty files in the working tree
286
+ outside <Cargo.toml>: - artifacts/` now proceed to `cargo publish`.
287
+ Stray edits anywhere else in the tree still fail the check.
288
+
289
+ **Verification.** A `kind = "crates"` package in a repo whose only
290
+ "dirty" file (alongside the managed `Cargo.toml`) is the engine's
291
+ `artifacts/` directory now reaches cargo. `git status --porcelain`
292
+ showing `?? artifacts/` is no longer fatal.
293
+
294
+ ---
295
+
296
+ ### Crates publish no longer fails the pre-publish completeness check
297
+
298
+ **Summary.** Consumers with a `kind = "crates"` package previously hit
299
+ `Artifact completeness check failed: missing artifact directory
300
+ <name>-crate/` before cargo was ever invoked. The reusable workflow
301
+ does not upload a `.crate` artifact (cargo packages and uploads from
302
+ source on the registry side), so the file the check demanded never
303
+ existed in the pipeline. The completeness check now skips crates
304
+ rows. Same reasoning as vanilla npm rows, which were already skipped.
305
+
306
+ **Required changes.** None.
307
+
308
+ **Deprecations removed.** None.
309
+
310
+ **Behavior changes without code changes.** Crates publishes that
311
+ previously errored at the completeness gate now reach `cargo publish`.
312
+ A crates row whose source tree is genuinely broken still fails — the
313
+ failure just happens at the cargo step, not before.
314
+
315
+ **Verification.** A `kind = "crates"` package in
316
+ `putitoutthere.toml` no longer requires any artifact upload step in
317
+ the consumer's workflow. Trigger a release with a `release: patch`
318
+ trailer; the publish job's "Run putitoutthere publish" step should
319
+ log `crates: cargo publish ...` instead of aborting on completeness.
320
+
24
321
  ### `[[package]].paths` renamed to `globs`
25
322
 
26
323
  **Summary.** The `path` / `paths` pair in `[[package]]` was confusing —
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A reusable GitHub Actions workflow that publishes packages to crates.io, PyPI,
4
4
  and npm from one repo. OIDC-first, cascade-aware, polyglot. The consumer
5
- surface is one config file plus ~10 lines of YAML calling
5
+ surface is one config file plus one canonical YAML calling
6
6
  `uses: thekevinscott/putitoutthere/.github/workflows/release.yml@v0`.
7
7
 
8
8
  ## Quickstart
@@ -22,10 +22,41 @@ jobs:
22
22
  permissions:
23
23
  contents: write
24
24
  id-token: write
25
+
26
+ # PyPI upload runs in the caller's workflow context. Required because
27
+ # PyPI Trusted Publishers can't validate OIDC tokens minted from a
28
+ # cross-repo reusable workflow (pypi/warehouse#11096). The `if:`
29
+ # gate skips this job for non-PyPI repos — paste verbatim regardless
30
+ # of what you publish.
31
+ pypi-publish:
32
+ needs: release
33
+ if: needs.release.outputs.has_pypi == 'true'
34
+ runs-on: ubuntu-latest
35
+ permissions:
36
+ id-token: write
37
+ steps:
38
+ - uses: actions/download-artifact@v4
39
+ with:
40
+ pattern: '*-sdist'
41
+ path: dist/
42
+ merge-multiple: true
43
+ - uses: actions/download-artifact@v4
44
+ with:
45
+ pattern: '*-wheel-*'
46
+ path: dist/
47
+ merge-multiple: true
48
+ - uses: pypa/gh-action-pypi-publish@release/v1
25
49
  ```
26
50
 
27
51
  Pinned action versions, `plan → build → publish` orchestration, and GitHub
28
- Release creation all live inside the reusable workflow.
52
+ Release creation all live inside the reusable workflow. The `pypi-publish`
53
+ job is the one piece that has to live in your workflow file: PyPI's
54
+ Trusted Publisher feature filters OIDC tokens by `repository_owner` /
55
+ `repository_name` claims, which always reflect the caller's repo — so a
56
+ TP registered against `thekevinscott/putitoutthere` is filtered out
57
+ before `job_workflow_ref` is even checked. Running `pypa/gh-action-pypi-publish`
58
+ in your workflow context aligns the claims with your TP registration.
59
+ The job is skipped automatically for repos that don't publish to PyPI.
29
60
 
30
61
  Optional inputs — `with:` block at the call site:
31
62
 
@@ -216,11 +247,14 @@ releases without fighting registry-immutable-publish semantics.
216
247
 
217
248
  ## Trusted publishers
218
249
 
219
- OIDC trusted publishers — the only auth path supported by the reusable
220
- workflow. Long-lived registry tokens are not reachable through the workflow.
250
+ OIDC trusted publishers — the only auth path supported. Long-lived
251
+ registry tokens are not reachable through the workflow.
221
252
 
222
- The fields each registry needs are the same: repository owner/name, workflow
223
- filename (`release.yml`), and optionally a GitHub environment name.
253
+ For all three registries the fields are the same: **your** repository
254
+ owner/name, **your** workflow filename (`release.yml`), and optionally
255
+ a GitHub environment name. Note: you register against your *own*
256
+ repository, not against `thekevinscott/putitoutthere` — see "How
257
+ auth flows" below for the why.
224
258
 
225
259
  ### crates.io
226
260
 
@@ -228,15 +262,15 @@ filename (`release.yml`), and optionally a GitHub environment name.
228
262
  exists. (Trusted publishing needs a crate owner record.)
229
263
  2. Go to `https://crates.io/crates/<crate>/settings` → **Trusted Publishing**
230
264
  → **Add**.
231
- 3. Fill in: repository owner, repository name, workflow filename
265
+ 3. Fill in: your repo owner, your repo name, workflow filename
232
266
  (`release.yml`), environment (optional).
233
267
 
234
268
  ### PyPI
235
269
 
236
270
  1. Go to `https://pypi.org/manage/project/<name>/settings/publishing/` (or
237
271
  **Publishing** on the project page).
238
- 2. Add a **GitHub** trusted publisher: owner, repo, workflow filename,
239
- environment (optional).
272
+ 2. Add a **GitHub** trusted publisher: your repo owner, your repo name,
273
+ workflow filename (`release.yml`), environment (optional).
240
274
  3. Brand-new project? Use a [pending publisher](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/)
241
275
  to skip the bootstrap token.
242
276
 
@@ -247,9 +281,33 @@ filename (`release.yml`), and optionally a GitHub environment name.
247
281
  publisher requires an existing package.)
248
282
  2. Go to `https://www.npmjs.com/package/<name>/access` → **Require trusted
249
283
  publisher**.
250
- 3. Fill in: repository, workflow filename, environment (optional).
284
+ 3. Fill in: your repository, workflow filename (`release.yml`),
285
+ environment (optional).
251
286
  4. Delete the bootstrap token.
252
287
 
288
+ ### How auth flows
289
+
290
+ `crates.io` and `npm` validate OIDC tokens that are minted by the
291
+ reusable workflow's `publish` job. The reusable workflow already
292
+ sits in your release path, so the OIDC `repository` and
293
+ `job_workflow_ref` claims line up with your TP registration.
294
+
295
+ PyPI is different. Its TP matching filters candidates by
296
+ `repository_owner` + `repository_name` *before* checking
297
+ `job_workflow_ref` ([Warehouse implementation](https://github.com/pypi/warehouse/blob/main/warehouse/oidc/models/github.py)).
298
+ The `repository` claim always reflects the caller's repo — even
299
+ inside a reusable workflow — so a TP registered against the
300
+ reusable workflow's repo would be filtered out before
301
+ `job_workflow_ref` is even checked. PyPI documents this:
302
+ "[Reusable workflows cannot currently be used as the workflow in
303
+ a Trusted Publisher.](https://docs.pypi.org/trusted-publishers/troubleshooting/)"
304
+ Tracked at [pypi/warehouse#11096](https://github.com/pypi/warehouse/issues/11096).
305
+
306
+ That's why the canonical template puts the PyPI upload step
307
+ (`pypa/gh-action-pypi-publish`) directly in *your* workflow,
308
+ gated on `needs.release.outputs.has_pypi`. In your workflow context
309
+ both claims resolve to your repo, so your TP registration matches.
310
+
253
311
  ## Recipes
254
312
 
255
313
  ### Bundled-CLI npm family
package/action.yml CHANGED
@@ -7,10 +7,14 @@ inputs:
7
7
  description: 'Which CLI subcommand to run. Canonical values are `plan` and `publish`.'
8
8
  required: false
9
9
  default: 'plan'
10
- dry_run:
11
- description: 'If true, resolve the plan but skip all destructive operations.'
10
+ working_directory:
11
+ description: >-
12
+ Path the CLI runs against (forwarded as `--cwd`). Defaults to the
13
+ runner working directory. Internal use; the reusable workflow
14
+ supplies this when invoked against a non-root tree (e.g. e2e
15
+ fixtures materialized into a subdir).
12
16
  required: false
13
- default: 'false'
17
+ default: ''
14
18
  fail_on_error:
15
19
  description: 'If true, non-zero CLI exit codes fail the step.'
16
20
  required: false
@@ -21,5 +25,5 @@ outputs:
21
25
  description: "JSON matrix emitted by `plan`. Output key is omitted when the plan resolves to zero rows or when command is anything other than plan; downstream jobs should coalesce with `|| '[]'`."
22
26
 
23
27
  runs:
24
- using: 'node24'
28
+ using: 'node20'
25
29
  main: 'dist-action/index.js'
package/dist/action.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * GitHub Actions wrapper. Bundled to `dist-action/index.js` via ncc.
3
3
  *
4
- * ~50-line adapter: read `INPUT_COMMAND` / `INPUT_DRY_RUN` /
5
- * `INPUT_FAIL_ON_ERROR` → invoke the SDK's run() → surface the exit
6
- * code. No GHA-specific logic lives here beyond input parsing.
4
+ * ~50-line adapter: read `INPUT_COMMAND` / `INPUT_FAIL_ON_ERROR` →
5
+ * invoke the SDK's run() → surface the exit code. No GHA-specific
6
+ * logic lives here beyond input parsing.
7
7
  *
8
8
  * Issue #24. Plan: §5.2, §5.3.
9
9
  */
@@ -1 +1 @@
1
- {"version":3,"file":"action.d.ts","sourceRoot":"","sources":["../src/action.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,wBAAsB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAyB1C"}
1
+ {"version":3,"file":"action.d.ts","sourceRoot":"","sources":["../src/action.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,wBAAsB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAwB1C"}
package/dist/action.js CHANGED
@@ -1,25 +1,24 @@
1
1
  /**
2
2
  * GitHub Actions wrapper. Bundled to `dist-action/index.js` via ncc.
3
3
  *
4
- * ~50-line adapter: read `INPUT_COMMAND` / `INPUT_DRY_RUN` /
5
- * `INPUT_FAIL_ON_ERROR` → invoke the SDK's run() → surface the exit
6
- * code. No GHA-specific logic lives here beyond input parsing.
4
+ * ~50-line adapter: read `INPUT_COMMAND` / `INPUT_FAIL_ON_ERROR` →
5
+ * invoke the SDK's run() → surface the exit code. No GHA-specific
6
+ * logic lives here beyond input parsing.
7
7
  *
8
8
  * Issue #24. Plan: §5.2, §5.3.
9
9
  */
10
10
  import { run } from './cli.js';
11
11
  export async function main() {
12
12
  const command = process.env.INPUT_COMMAND ?? '';
13
- const dryRun = (process.env.INPUT_DRY_RUN ?? 'false').toLowerCase() === 'true';
13
+ const workingDirectory = process.env.INPUT_WORKING_DIRECTORY ?? '';
14
14
  const failOnError = (process.env.INPUT_FAIL_ON_ERROR ?? 'true').toLowerCase() !== 'false';
15
15
  if (!command) {
16
16
  process.stderr.write('putitoutthere action: missing required input `command`\n');
17
17
  process.exit(1);
18
18
  }
19
- const argv = ['node', 'putitoutthere', command];
20
- if (dryRun)
21
- argv.push('--dry-run');
22
- argv.push('--json');
19
+ const argv = ['node', 'putitoutthere', command, '--json'];
20
+ if (workingDirectory)
21
+ argv.push('--cwd', workingDirectory);
23
22
  const code = await run(argv);
24
23
  if (code !== 0 && !failOnError) {
25
24
  process.stderr.write(`putitoutthere action: ignoring non-zero exit (fail_on_error=false): ${code}\n`);
@@ -1 +1 @@
1
- {"version":3,"file":"action.js","sourceRoot":"","sources":["../src/action.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,GAAG,EAAE,MAAM,UAAU,CAAC;AAE/B,MAAM,CAAC,KAAK,UAAU,IAAI;IACxB,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,aAAa,IAAI,EAAE,CAAC;IAChD,MAAM,MAAM,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,aAAa,IAAI,OAAO,CAAC,CAAC,WAAW,EAAE,KAAK,MAAM,CAAC;IAC/E,MAAM,WAAW,GACf,CAAC,OAAO,CAAC,GAAG,CAAC,mBAAmB,IAAI,MAAM,CAAC,CAAC,WAAW,EAAE,KAAK,OAAO,CAAC;IAExE,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,OAAO,CAAC,MAAM,CAAC,KAAK,CAClB,0DAA0D,CAC3D,CAAC;QACF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;IAED,MAAM,IAAI,GAAG,CAAC,MAAM,EAAE,eAAe,EAAE,OAAO,CAAC,CAAC;IAChD,IAAI,MAAM;QAAE,IAAI,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;IACnC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAEpB,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,IAAI,CAAC,CAAC;IAC7B,IAAI,IAAI,KAAK,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC;QAC/B,OAAO,CAAC,MAAM,CAAC,KAAK,CAClB,uEAAuE,IAAI,IAAI,CAChF,CAAC;QACF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;IACD,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACrB,CAAC;AAED,oFAAoF;AACpF,IAAI,MAAM,CAAC,IAAI,CAAC,GAAG,KAAK,UAAU,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC;IACpD,KAAK,IAAI,EAAE,CAAC;AACd,CAAC"}
1
+ {"version":3,"file":"action.js","sourceRoot":"","sources":["../src/action.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,GAAG,EAAE,MAAM,UAAU,CAAC;AAE/B,MAAM,CAAC,KAAK,UAAU,IAAI;IACxB,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,aAAa,IAAI,EAAE,CAAC;IAChD,MAAM,gBAAgB,GAAG,OAAO,CAAC,GAAG,CAAC,uBAAuB,IAAI,EAAE,CAAC;IACnE,MAAM,WAAW,GACf,CAAC,OAAO,CAAC,GAAG,CAAC,mBAAmB,IAAI,MAAM,CAAC,CAAC,WAAW,EAAE,KAAK,OAAO,CAAC;IAExE,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,OAAO,CAAC,MAAM,CAAC,KAAK,CAClB,0DAA0D,CAC3D,CAAC;QACF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;IAED,MAAM,IAAI,GAAG,CAAC,MAAM,EAAE,eAAe,EAAE,OAAO,EAAE,QAAQ,CAAC,CAAC;IAC1D,IAAI,gBAAgB;QAAE,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,gBAAgB,CAAC,CAAC;IAE3D,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,IAAI,CAAC,CAAC;IAC7B,IAAI,IAAI,KAAK,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC;QAC/B,OAAO,CAAC,MAAM,CAAC,KAAK,CAClB,uEAAuE,IAAI,IAAI,CAChF,CAAC;QACF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;IACD,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACrB,CAAC;AAED,oFAAoF;AACpF,IAAI,MAAM,CAAC,IAAI,CAAC,GAAG,KAAK,UAAU,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC;IACpD,KAAK,IAAI,EAAE,CAAC;AACd,CAAC"}
package/dist/cli.d.ts CHANGED
@@ -12,8 +12,19 @@
12
12
  * Global flags:
13
13
  * --cwd <path> working directory (default: process.cwd())
14
14
  * --config <path> path to putitoutthere.toml
15
- * --dry-run for publish; no side effects
16
15
  * --json machine-readable output
16
+ *
17
+ * `--dry-run` was removed deliberately (#244). The library's job is
18
+ * publishing; a non-publishing mode of the publish command was a
19
+ * coverage hole pretending to be a feature. Passing `--dry-run` now
20
+ * errors out.
17
21
  */
22
+ interface ParsedFlags {
23
+ cwd: string;
24
+ config?: string | undefined;
25
+ json: boolean;
26
+ }
27
+ export declare function parseFlags(argv: readonly string[]): ParsedFlags;
18
28
  export declare function run(argv: readonly string[]): Promise<number>;
29
+ export {};
19
30
  //# sourceMappingURL=cli.d.ts.map
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AA2DH,wBAAsB,GAAG,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CA+FlE"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAoCH,UAAU,WAAW;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,IAAI,EAAE,OAAO,CAAC;CACf;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,WAAW,CA4B/D;AAED,wBAAsB,GAAG,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CA8FlE"}