Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
354 changes: 331 additions & 23 deletions .github/workflows/release.yml

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Cargo dependency sweep: napi 3.9.0 → 3.12.2, napi-derive 3.5.6 → 3.6.3, napi-build 2.3.2 → 2.4.1 (napi-sys 3.3.0, napi-derive-backend 6.1.2), pyo3 0.29.0 → 0.29.2, clap 4.6.1 → 4.6.6, similar 3.1.1 → 3.2.0, wasm-bindgen 0.2.121 → 0.2.126 (js-sys 0.3.103, wasm-bindgen-futures 0.4.76, wasm-bindgen-test 0.3.76), serde 1.0.228 → 1.0.229, serde_json 1.0.150 → 1.0.151, thiserror 2.0.18 → 2.0.20, libc 0.2.186 → 0.2.189. Supersedes Dependabot #354 #360 #359 #358 #280 #251 #249 #246 #243.
- npm dependency sweep: relaxed the three phantom floor pins to caret ranges — fast-uri 3.1.5 → ^3.1.6 (oldest release patching GHSA-5jgf-p345-68v8, GHSA-fph4-wmhf-6fwf, GHSA-f65p-4m7j-42xc, GHSA-jqff-g426-hqxp), nanoid 3.3.18 → ^3.3.18, js-yaml 4.3.1 → ^4.3.1 (#336); @napi-rs/cli ^3.0.0 → ^3.8.6 (lock 3.7.0 → 3.8.6); vite lock 8.1.5 → 8.2.2; Dependabot `ignore` rules for semver-major bumps of the three phantom pins. Supersedes Dependabot #315 #332 #346 #362 #355 #357 #279.
- GitHub Actions sweep: actions/checkout v6 → v7 (16 call sites: 9 ci.yml + 7 release.yml), actions/setup-node v6 → v7 (6 sites), actions/setup-python v5 → v7 (5 sites, ci.yml only; action runtime node20 → node24), PyO3/maturin-action pin normalized from the v1.51.0 annotated-tag object (`3e2bdf6`) to the commit it points to (`e83996d1`), same version (PF-040); Dependabot `ignore` for typescript semver-major version updates pending the TS 7 migration (#364). Supersedes Dependabot #111, #189, #241, #356; replaces #169.
- Release-surface PR gate: `release.yml` now triggers on `pull_request` events touching `.github/workflows/release.yml`, `.github/actions/**`, `crates/mds-napi/**`, `crates/mds-python/**`, and `scripts/verify-napi-names.mjs`; adds an unguarded `rehearse-publish-python` job (PF-039) that rehearses `publish-python` without uploading — pin shape (both the annotated-tag-object SHA a892a5a6 and the commit SHA dc37677b are rejected as positive controls), GHCR manifest (404 + `MANIFEST_UNKNOWN` body required), bounded `docker pull` (3-attempt loop), and `twine check` from the pinned image with `--network none`, each with a positive control (PF-013, PF-040) — plus a `publish-testpypi` opt-in leg (dispatch-guarded); the rehearsal never invokes `pypa/gh-action-pypi-publish` (the action has no dry-run mode) and is denied `id-token`, both pinned by specs S14/S15; credential and OIDC probes run on `pull_request` events and fail closed on fork/Dependabot PRs (no secrets, no `id-token: write`) with an actionable error; `verify-pr-checks.mjs` requires `Version gate`, `Stage + verify platform packages` and `Rehearse PyPI publish (no upload)` on release-surface PRs and fails closed when the changed-file list cannot be read (#342, #350).

## [0.4.2] — 2026-09-03

Expand Down
112 changes: 101 additions & 11 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,28 @@ These are **not** automated and must be done before the first release:
record causes version-gate to fail, aborting the release before any crates.io or
npm publish runs. The minted token expires unused — the probe is free and safe.
This step is NOT tag-guarded so it runs in the `workflow_dispatch` dry run too,
exercising the PyPI trust chain before the real tag push (PF-039).
exercising the PyPI trust chain before the real tag push (PF-039). It is
run on `pull_request` events too — the version-gate step fails closed on
fork and Dependabot PRs (they receive no `id-token: write` and no
repository secrets), and no PR run can reach a publish in any case.

6. **Configure TestPyPI trusted publisher** (optional, needed for `testpypi: true`
dispatch runs) at [test.pypi.org/manage/account/publishing](https://test.pypi.org/manage/account/publishing/):
- Project name: `markdown-script`
- Owner / repository: `dean0x/mdscript`
- Workflow filename: `release.yml`
- Environment name: **leave blank**

The `publish-testpypi` job is a dispatch-input-guarded opt-in leg (`testpypi: true`
on `workflow_dispatch`). It is skipped on all PR and standard dispatch runs;
`TIER_B_EXPECTED_SKIPPED` lists its name so the pre-merge verifier tolerates the
skipped conclusion. The trusted publisher for TestPyPI is independent of the PyPI
one — both must be configured separately.

**Publisher expiry:** a PyPI pending publisher auto-expires ~30 days after
creation unless an upload lands. For TestPyPI, the first `workflow_dispatch`
run with `testpypi: true` is what locks the name — it is the first upload.
Re-create the publisher if it has expired before that first dispatch.

## Pre-flight (before tagging)

Expand Down Expand Up @@ -125,17 +146,23 @@ gh workflow run release.yml # workflow_dispatch — builds the 7-target
# Python wheel matrix, stages packages,
# runs the A3 name<->loader gate and the
# Python readelf linkage gate, uploads
# artifacts. Publishes NOTHING.
# artifacts. Rehearses the publish-python
# step. Publishes NOTHING.
```

The dry-run workflow runs `version-gate` in full, which now includes the
**credential probe** (security-08): it calls `npm whoami` against the live
registry to verify the `NPM_TOKEN` is valid, guards `CARGO_REGISTRY_TOKEN`
for non-empty, and probes the PyPI trusted publisher via the OIDC mint-token
exchange. A revoked token, absent secret, or misconfigured trusted publisher
therefore fails the dry run — this closes the former gap where a bad credential
was only discovered after `cargo publish` had already made an irreversible
crates.io release.
therefore fails the dry run — all before any irreversible crates.io release.

Both probes **run on every event, including `pull_request`**. On fork and
Dependabot PRs — which receive no repository secrets and no `id-token:
write` — the probes fail closed with an actionable error: maintainers must
supersede with a first-party branch PR or dispatch `gh workflow run
release.yml --ref <branch>`. No PR run can reach a publish in any case,
so the fail-closed behaviour is informational, not a merge blocker by itself.

**Note:** `npm whoami` verifies authentication, not publish rights to the
`@mdscript` scope. A read-only or wrongly-scoped token passes the probe but
Expand All @@ -145,12 +172,68 @@ The dry run also exercises the **CI-history gate** (PF-017), asserting a
completed+success `CI` run for the dispatched ref's HEAD. Dispatch it only after
that ref's CI has finished, or the gate fails closed on a still-running run.

The dry run also runs the **`rehearse-publish-python` job**, which rehearses
everything about `publish-python` except the one irreversible act. It runs four
gates, each with a positive control (PF-013 — a gate never observed rejecting
anything is not evidence):

1. **Pin shape** — the `pypa/gh-action-pypi-publish` pin must be a `vX.Y.Z`
release tag. Control: the v0.4.1 annotated-tag-object SHA must be rejected.
2. **GHCR manifest** — GHCR must hold an image for that exact ref. Control: a ref
that cannot exist must not return HTTP 200.
3. **`docker pull`** — the image the runner actually fetches must pull. Control:
pulling the impossible ref must fail. ("The ref resolves in git" is necessary
and never sufficient — PF-040.)
4. **`twine check`** — twine runs out of that image with `--network none` and
`--entrypoint twine`, so the step physically cannot reach pypi.org. Control:
a deliberately corrupt wheel must be rejected.

> The job **must never** `uses: pypa/gh-action-pypi-publish`. The action has no
> dry-run/no-upload mode: an unrecognised `dry-run:` input is warned about and
> ignored, and the action then uploads for real. A `dry-run: true` rehearsal
> shipped briefly and attempted a live pypi.org upload from a pull request
> (run 34060146952); it failed only because that version was already published.
> Spec S14 in `scripts/__test__/release-auth-probe.spec.mjs` now pins the
> invocation to `publish-python` and `publish-testpypi` only, and the rehearsal
> is denied `id-token` so it holds no credential to upload with.

`publish-crates` needs this job, so a broken pin aborts the release before the
irreversible crates.io write. It is intentionally unguarded, so it runs on
`pull_request` and `workflow_dispatch`, not just tag pushes (PF-039).

Five jobs are expected-skipped on a standard `workflow_dispatch` dry run and
are listed in `TIER_B_EXPECTED_SKIPPED` in `scripts/verify-pr-checks.mjs`:
`Publish to crates.io`, `Publish to npm`, `Publish to PyPI`, `GitHub Release`,
and `Publish to TestPyPI (rehearsal)`. The same five are skipped on a
release-surface PR run.

Confirm the **A3 name-gate** step (`scripts/verify-napi-names.mjs`) passes in that
run. **This is a hard checkpoint** — if the generated platform package names or
their `.node` filenames drift from the hand-written `crates/mds-napi/index.js`
loader, the published universal package will fail to load the native binary at
runtime on the affected platform. Do not proceed past a failing gate.

### Release-surface PRs

Release-surface PRs — those touching `.github/workflows/release.yml`,
`.github/actions/**`, `crates/mds-napi/**`, `crates/mds-python/**`, or
`scripts/verify-napi-names.mjs` — also trigger the workflow via the
`pull_request` event, so a Dependabot bump to an action reachable only from a
tag-guarded job is exercised on the PR instead of first running on a tag push
after crates.io has published (PF-039).

On such PRs, `verify-pr-checks.mjs` requires three additional check-runs:
`Version gate`, `Stage + verify platform packages`, and `Rehearse PyPI publish
(no upload)`. All other publish jobs are skipped, and their skipped conclusions
are tolerated by the verifier.

That path list lives in **two** places that must stay identical: the
`on.pull_request.paths` filter in `release.yml` and `RELEASE_SURFACE` in
`scripts/verify-pr-checks.mjs`. Spec S10 compares them as sets — a filter the
verifier does not know about would let a release-surface PR pass as a silent
no-run (ADR-013 amendment). The verifier also fails closed (exit 2) if it
cannot enumerate the PR's changed files at all.

## Release

### Tag-push (the only path)
Expand Down Expand Up @@ -187,23 +270,30 @@ The release is driven by pushing a `vX.Y.Z` tag. This is how all versions have s
### What happens after tagging

The `release.yml` workflow runs, in order:
1. **version-gate** — synchronized-version check (fails fast).
1. **version-gate** — synchronized-version check, credential probe, PyPI OIDC
probe, source-hygiene gate, and CI-history gate (fails fast).
2. **build-napi** (parallel with build-python) — cross-compiles the addon for
all 7 targets.
3. **build-python** (parallel with build-napi) — builds `cp311-abi3` wheels
for 7 platforms + sdist, runs the readelf linkage gate on Linux legs.
4. **stage-and-verify-napi** — `napi create-npm-dirs` + `artifacts`, copies
LICENSE into each platform dir, runs the **A3 name-gate**.
5. **publish-crates** — blocked until BOTH `stage-and-verify-napi` and
`build-python` succeed (so a Python build failure aborts before crates.io,
which is irreversible — PF-023). `cargo publish` `mds-core`, polls the
5. **rehearse-publish-python** — pin shape, GHCR manifest, `docker pull` and
`twine check` (each with a positive control); uploads nothing and holds no
OIDC token. publish-crates blocks on this so a broken action pin aborts
before crates.io (irreversible).
6. **publish-crates** — blocked until `stage-and-verify-napi`, `build-python`,
AND `rehearse-publish-python` succeed. `cargo publish` `mds-core`, polls the
crates.io index for up to 5 min (bounded, max 20 × 15 s), then `mds-cli`.
6. **publish-npm** and **publish-python** (parallel, both after publish-crates)
7. **publish-npm** and **publish-python** (parallel, both after publish-crates)
— publish npm packages (with provenance) and PyPI `markdown-script` (OIDC
trusted publishing + PEP 740 attestations, `skip-existing: true`).
7. **github-release** — `gh release create` with generated notes; runs only
8. **github-release** — `gh release create` with generated notes; runs only
after all three publish jobs succeed.

`publish-testpypi` never runs on a tag: it is guarded by `inputs.testpypi`,
which only a `workflow_dispatch` can set. On a tag push it reports `skipped`.

## Post-release

- Verify each package on its registry (crates.io, npmjs.com) and that npm shows
Expand Down
Loading
Loading