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/package.json +3 -7
- package/CHANGELOG.md +0 -293
- package/MIGRATIONS.md +0 -4045
- package/README.md +0 -1237
- package/action.yml +0 -48
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.
|