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.
- package/CHANGELOG.md +57 -0
- package/MIGRATIONS.md +297 -0
- package/README.md +68 -10
- package/action.yml +8 -4
- package/dist/action.d.ts +3 -3
- package/dist/action.d.ts.map +1 -1
- package/dist/action.js +7 -8
- package/dist/action.js.map +1 -1
- package/dist/cli.d.ts +12 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +22 -8
- package/dist/cli.js.map +1 -1
- package/dist/completeness.js +6 -0
- package/dist/completeness.js.map +1 -1
- package/dist/error-codes.d.ts +31 -0
- package/dist/error-codes.d.ts.map +1 -0
- package/dist/error-codes.js +32 -0
- package/dist/error-codes.js.map +1 -0
- package/dist/handlers/crates.d.ts +2 -2
- package/dist/handlers/crates.d.ts.map +1 -1
- package/dist/handlers/crates.js +18 -6
- package/dist/handlers/crates.js.map +1 -1
- package/dist/handlers/npm-platform.d.ts.map +1 -1
- package/dist/handlers/npm-platform.js +11 -7
- package/dist/handlers/npm-platform.js.map +1 -1
- package/dist/handlers/npm.d.ts.map +1 -1
- package/dist/handlers/npm.js +0 -3
- package/dist/handlers/npm.js.map +1 -1
- package/dist/handlers/pypi.d.ts +30 -11
- package/dist/handlers/pypi.d.ts.map +1 -1
- package/dist/handlers/pypi.js +44 -157
- package/dist/handlers/pypi.js.map +1 -1
- package/dist/plan.js +21 -6
- package/dist/plan.js.map +1 -1
- package/dist/publish.d.ts +1 -2
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +14 -20
- package/dist/publish.js.map +1 -1
- package/dist/types.d.ts +22 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +16 -0
- package/dist/types.js.map +1 -1
- package/dist/verbose.d.ts.map +1 -1
- package/dist/verbose.js +40 -0
- package/dist/verbose.js.map +1 -1
- 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
|
|
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
|
|
220
|
-
|
|
250
|
+
OIDC trusted publishers — the only auth path supported. Long-lived
|
|
251
|
+
registry tokens are not reachable through the workflow.
|
|
221
252
|
|
|
222
|
-
|
|
223
|
-
filename (`release.yml`), and optionally
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
11
|
-
description:
|
|
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: '
|
|
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: '
|
|
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` / `
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
*/
|
package/dist/action.d.ts.map
CHANGED
|
@@ -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,
|
|
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` / `
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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 (
|
|
21
|
-
argv.push('--
|
|
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`);
|
package/dist/action.js.map
CHANGED
|
@@ -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,
|
|
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
|
|
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"}
|