From 1c316504cb220df9edf207bd8354f3c93f9f777a Mon Sep 17 00:00:00 2001 From: Vuong Nguyen Date: Wed, 26 Aug 2026 00:23:07 -0400 Subject: [PATCH] ci: rehearse the release path on rc and every pull request before any tag Signed-off-by: Vuong Nguyen --- .github/workflows/ci.yml | 30 +++ .github/workflows/release-checks.yml | 190 ++++++++++++++++++ .github/workflows/release.yml | 110 +++++----- RELEASING.md | 47 ++++- .../.github/workflows/npm-version.yml | 12 ++ .../pins/good/.github/workflows/pinned.yml | 7 + fixtures/release-path/twine-reject/PKG-INFO | 18 ++ fixtures/release-path/twine-reject/README.rst | 10 + scripts/check-release-pins.sh | 24 ++- scripts/lib/release-pins.sh | 8 +- scripts/release-pins.env | 7 +- scripts/smoke-prepublish.sh | 190 ++++++++++++++++-- 12 files changed, 559 insertions(+), 94 deletions(-) create mode 100644 .github/workflows/release-checks.yml create mode 100644 fixtures/release-path/pins/bad/7-npm-version/.github/workflows/npm-version.yml create mode 100644 fixtures/release-path/twine-reject/PKG-INFO create mode 100644 fixtures/release-path/twine-reject/README.rst diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4c9ba9..943ce8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,6 +108,36 @@ jobs: # exits 0, so the fixtures prove each rule still fires. - run: sh scripts/check-release-pins.sh --self-test + release-version: + name: Release version (packages/sdk/package.json) + runs-on: ubuntu-latest + outputs: + version: ${{ steps.read.outputs.version }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + # The rehearsal below validates a version the way the release workflow + # validates a tag. Off the release path there is no tag, so the version + # comes from the manifest the whole repository is versioned against. + - id: read + run: echo "version=$(node -p "require('./packages/sdk/package.json').version")" >> "$GITHUB_OUTPUT" + + release-rehearsal: + name: Release rehearsal + needs: release-version + # Everything a release does short of the writes, run here rather than + # meeting a runner for the first time at the tag. An `rc/*` candidate + # rehearses all of it, Node 22 container and cross-platform Go build + # included; every other run takes the cheaper half, so the artifact path is + # still proven on every pull request. + uses: ./.github/workflows/release-checks.yml + with: + version: ${{ needs.release-version.outputs.version }} + docker_required: ${{ startsWith(github.ref, 'refs/heads/rc/') }} + full: ${{ startsWith(github.ref, 'refs/heads/rc/') }} + node-sdk: name: Node SDK (build + test, Node ${{ matrix.node-version }}) runs-on: ubuntu-latest diff --git a/.github/workflows/release-checks.yml b/.github/workflows/release-checks.yml new file mode 100644 index 0000000..6a6b1f7 --- /dev/null +++ b/.github/workflows/release-checks.yml @@ -0,0 +1,190 @@ +name: Release checks + +# Everything the release path does EXCEPT the writes, in one place both callers +# reach: the pre-publish smoke, the version comparison across all five packages, +# the Python distribution gate, and the cross-platform Go build. Nothing here +# publishes, tags, or creates a release, so it runs on a candidate branch and on +# a pull request exactly as it runs on the tag. +# +# That is the whole point. A check that executes for the first time on the +# irreversible path is a check that reports after the tag exists, where it cannot +# be corrected in place; so the version it validates is an input, not a tag, and +# the callers pass whichever they have. +# +# Actions pinned to full commit SHAs for supply-chain hardening. + +on: + workflow_call: + inputs: + version: + description: "The version every package must agree on (no leading v)" + required: true + type: string + docker_required: + description: "Require the smoke's Node 22 container leg; without it the leg is not run at all" + required: false + default: false + type: boolean + full: + description: "Also build the cross-platform Go binaries (the candidate branch and the tag; not every pull request)" + required: false + default: false + type: boolean + +permissions: + contents: read + +jobs: + smoke: + name: Pre-publish smoke (all SDKs) + runs-on: ubuntu-latest + env: + # Read by scripts/smoke-prepublish.sh. The release path proves the + # published-package floor inside a Node 22 container and fails when it + # cannot; a pull request runs the cheaper artifact battery without it, + # rather than leaving the leg to whether a runner happens to have Docker. + REQUIRE_DOCKER: ${{ inputs.docker_required && '1' || '' }} + SKIP_DOCKER: ${{ inputs.docker_required && '' || '1' }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: "1.27.0" + cache-dependency-path: packages/sdk-go/go.sum + # The npm the publish jobs run through, installed here for the same reason + # the Python and Go versions are pinned here: a rehearsal on a different + # toolchain rehearses a release that will not happen. + - name: Install the publish npm (scripts/release-pins.env) + run: | + set -euo pipefail + . scripts/lib/release-pins.sh + load_release_pins scripts/release-pins.env + npm install -g "npm@${NPM_VERSION}" + INSTALLED="$(npm --version)" + test "$INSTALLED" = "$NPM_VERSION" || { echo "::error::npm $INSTALLED is not the pinned $NPM_VERSION"; exit 1; } + echo "npm $INSTALLED" + - run: npm ci + - name: Build artifacts, cold-install each, assert CLI battery + cross-SDK parity + run: npm run smoke:prepublish + + versions: + name: Version comparison (five packages) + runs-on: ubuntu-latest + env: + VERSION: ${{ inputs.version }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + # The comparison each publish job used to make about its own tag, made + # about all five packages at once and before any of them is written. Six + # manifests, because the JS SDK declares its version twice (npm and JSR) + # and one publish tag drives both. + - name: Every manifest names the release version + run: | + set -euo pipefail + status=0 + compare() { + if [ "$2" = "$VERSION" ]; then + echo "$1 = $VERSION" + else + echo "::error::$1 is '$2', not $VERSION" + status=1 + fi + } + compare "packages/sdk/package.json" "$(node -p "require('./packages/sdk/package.json').version")" + compare "packages/sdk/jsr.json" "$(node -p "require('./packages/sdk/jsr.json').version")" + compare "packages/create-leji/package.json" "$(node -p "require('./packages/create-leji/package.json').version")" + compare "packages/mcp/package.json" "$(node -p "require('./packages/mcp/package.json').version")" + compare "packages/sdk-py/pyproject.toml" "$(grep -m1 '^version' packages/sdk-py/pyproject.toml | sed -E 's/.*"([^"]+)".*/\1/')" + compare "packages/sdk-go SDKVersion" "$(sed -nE 's/^var SDKVersion = "([^"]+)".*/\1/p' packages/sdk-go/internal/schemas/schemas.go)" + exit "$status" + + python-dist: + name: Python distribution (test, build, twine) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - name: Test + working-directory: packages/sdk-py + run: python -m pip install pip==26.2.1 && pip install -e ".[dev]" && pytest -q + - name: Build sdist + wheel + working-directory: packages/sdk-py + run: python -m pip install build==1.5.0 && python -m build + # The gate the PyPI upload performs, on a distribution built the way the + # upload builds one. Same function as the smoke and the pre-push hook, same + # twine the publish action bundles. + - name: twine check --strict on the built distribution + run: | + . scripts/lib/twine-check.sh + twine_check python3 packages/sdk-py "$RUNNER_TEMP/twine-gate" packages/sdk-py/dist + + go-binaries: + name: Go binaries (goreleaser, no publish) + # Cross-compiling six targets is the expensive leg, and the thing it catches + # is a broken build or archive set, which a pull request has already had + # `goreleaser check` and `go test` say something about. So it runs where a + # release is actually being rehearsed. + if: inputs.full + runs-on: ubuntu-latest + env: + VERSION: ${{ inputs.version }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 # full history so goreleaser sees tags + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: "1.27.0" + cache-dependency-path: packages/sdk-go/go.sum + # goreleaser cannot parse a path-prefixed tag as a version, so the run gets + # a LOCAL bare tag (never pushed; no repo-wide vX.Y.Z ref exists). + - name: Prepare the goreleaser version tag (local only) + run: | + git tag -f "v$VERSION" + echo "GORELEASER_CURRENT_TAG=v$VERSION" >> "$GITHUB_ENV" + - name: Build cross-platform binaries (goreleaser, no publish) + uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 + with: + # The same goreleaser the release job installs (scripts/release-pins.env). + version: "v2.18.0" + workdir: packages/sdk-go + args: release --clean --skip=publish + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # A green goreleaser run says the build worked, not that the release will + # carry what a user is told to download. The set is named here, one entry + # per published platform, and every checksum is verified against its file. + - name: Every expected archive is present, and its checksum verifies + working-directory: packages/sdk-go/dist + run: | + set -euo pipefail + status=0 + archives="leji_${VERSION}_darwin_amd64.tar.gz leji_${VERSION}_darwin_arm64.tar.gz" + archives="$archives leji_${VERSION}_linux_amd64.tar.gz leji_${VERSION}_linux_arm64.tar.gz" + archives="$archives leji_${VERSION}_windows_amd64.zip leji_${VERSION}_windows_arm64.zip" + for f in $archives; do + if [ -s "$f" ]; then + echo "present: $f" + else + echo "::error::missing or empty: $f" + status=1 + fi + if ! grep -q " ${f}\$" checksums.txt; then + echo "::error::$f has no line in checksums.txt" + status=1 + fi + done + # And the checksums are checked, not merely counted. + sha256sum -c checksums.txt + exit "$status" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 97cf228..e991241 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,33 +25,38 @@ permissions: contents: read jobs: - # Pre-publish gate on EVERY release tag: builds the publishable artifacts, - # cold-installs each, and asserts the CLI battery + cross-SDK parity. - smoke: - name: Pre-publish smoke (all SDKs) + # The version the tag names, for the checks below to hold every package to. + # Each release tag carries the same shape, so one expression serves all five. + version: + name: Release version (from the tag) runs-on: ubuntu-latest + outputs: + version: ${{ steps.read.outputs.version }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 24 - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.12" - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - go-version: "1.27.0" - cache-dependency-path: packages/sdk-go/go.sum - - run: npm ci - - name: Build artifacts, cold-install each, assert CLI battery + cross-SDK parity - run: npm run smoke:prepublish + - id: read + run: echo "version=${GITHUB_REF_NAME##*/v}" >> "$GITHUB_OUTPUT" + + # Pre-publish gate on EVERY release tag: the pre-publish smoke, the version + # comparison across all five packages, the Python distribution gate, and the + # cross-platform Go build. CI runs this same reusable workflow on every pull + # request and, in full, on every `rc/*` candidate, so what happens here is a + # recheck on the exact tagged bytes rather than the first time these checks + # meet a runner. Every job below it does nothing but write. + checks: + name: Release checks + needs: version + uses: ./.github/workflows/release-checks.yml + with: + version: ${{ needs.version.outputs.version }} + docker_required: true + full: true # --- packages/sdk/v* : npm `@leji-org/leji` + JSR `@leji-org/leji` (same tag, two jobs) --- npm: name: Publish leji to npm if: startsWith(github.ref, 'refs/tags/packages/sdk/v') - needs: smoke + needs: checks runs-on: ubuntu-latest permissions: contents: read @@ -63,13 +68,15 @@ jobs: node-version: 24 registry-url: "https://registry.npmjs.org" # OIDC trusted publishing needs npm >= 11.5.1 (Node 24 may ship older). - - run: npm install -g npm@12.0.2 && npm --version - - run: npm ci - - name: Verify tag matches package version + # The version is the tuple's, so bumping it is one edit in one file. + - name: Install the publish npm (scripts/release-pins.env) run: | - TAG="${GITHUB_REF_NAME#packages/sdk/v}" - PKG="$(node -p "require('./packages/sdk/package.json').version")" - test "$TAG" = "$PKG" || { echo "tag packages/sdk/v$TAG != packages/sdk version $PKG"; exit 1; } + set -euo pipefail + . scripts/lib/release-pins.sh + load_release_pins scripts/release-pins.env + npm install -g "npm@${NPM_VERSION}" + npm --version + - run: npm ci - run: npm run assets:check - run: npm run build -w packages/sdk - run: npm test -w packages/sdk @@ -81,7 +88,7 @@ jobs: jsr: name: Publish @leji-org/leji to JSR (OIDC) if: startsWith(github.ref, 'refs/tags/packages/sdk/v') - needs: smoke + needs: checks runs-on: ubuntu-latest permissions: contents: read @@ -92,11 +99,6 @@ jobs: with: node-version: 24 - run: npm ci - - name: Verify tag matches jsr.json version - run: | - TAG="${GITHUB_REF_NAME#packages/sdk/v}" - PKG="$(node -p "require('./packages/sdk/jsr.json').version")" - test "$TAG" = "$PKG" || { echo "tag packages/sdk/v$TAG != jsr.json version $PKG"; exit 1; } # JSR publishes the TS source (per jsr.json), not the npm dist tarball. # The jsr CLI is pinned like every other tool on this path; the version # mirrors JSR_VERSION in scripts/release-pins.env. @@ -109,7 +111,7 @@ jobs: create-leji: name: Publish create-leji to npm if: startsWith(github.ref, 'refs/tags/packages/create-leji/v') - needs: smoke + needs: checks runs-on: ubuntu-latest permissions: contents: read @@ -120,13 +122,15 @@ jobs: with: node-version: 24 registry-url: "https://registry.npmjs.org" - - run: npm install -g npm@12.0.2 && npm --version - - run: npm ci - - name: Verify tag matches package version + # The publish npm, from the tuple (see the sdk job above). + - name: Install the publish npm (scripts/release-pins.env) run: | - TAG="${GITHUB_REF_NAME#packages/create-leji/v}" - PKG="$(node -p "require('./packages/create-leji/package.json').version")" - test "$TAG" = "$PKG" || { echo "tag packages/create-leji/v$TAG != create-leji version $PKG"; exit 1; } + set -euo pipefail + . scripts/lib/release-pins.sh + load_release_pins scripts/release-pins.env + npm install -g "npm@${NPM_VERSION}" + npm --version + - run: npm ci # Tag AFTER packages/sdk/v* is live so the @leji-org/leji dep resolves. - name: Publish (OIDC trusted publishing) working-directory: packages/create-leji @@ -137,7 +141,7 @@ jobs: npm-mcp: name: Publish @leji-org/mcp to npm if: startsWith(github.ref, 'refs/tags/packages/mcp/v') - needs: smoke + needs: checks runs-on: ubuntu-latest permissions: contents: read @@ -148,13 +152,15 @@ jobs: with: node-version: 24 registry-url: "https://registry.npmjs.org" - - run: npm install -g npm@12.0.2 && npm --version - - run: npm ci - - name: Verify tag matches package version + # The publish npm, from the tuple (see the sdk job above). + - name: Install the publish npm (scripts/release-pins.env) run: | - TAG="${GITHUB_REF_NAME#packages/mcp/v}" - PKG="$(node -p "require('./packages/mcp/package.json').version")" - test "$TAG" = "$PKG" || { echo "tag packages/mcp/v$TAG != packages/mcp version $PKG"; exit 1; } + set -euo pipefail + . scripts/lib/release-pins.sh + load_release_pins scripts/release-pins.env + npm install -g "npm@${NPM_VERSION}" + npm --version + - run: npm ci - run: npm run assets:check - run: npm run build -w packages/sdk - run: npm run build -w packages/mcp @@ -169,7 +175,7 @@ jobs: pypi: name: Publish leji to PyPI (trusted publishing) if: startsWith(github.ref, 'refs/tags/packages/sdk-py/v') - needs: smoke + needs: checks runs-on: ubuntu-latest environment: pypi permissions: @@ -180,11 +186,6 @@ jobs: - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - - name: Verify tag matches package version - run: | - TAG="${GITHUB_REF_NAME#packages/sdk-py/v}" - PKG="$(grep -m1 '^version' packages/sdk-py/pyproject.toml | sed -E 's/.*"([^"]+)".*/\1/')" - test "$TAG" = "$PKG" || { echo "tag packages/sdk-py/v$TAG != pyproject version $PKG"; exit 1; } - name: Test working-directory: packages/sdk-py run: python -m pip install pip==26.2.1 && pip install -e ".[dev]" && pytest -q @@ -211,7 +212,7 @@ jobs: go: name: Release Go binaries (goreleaser, draft) if: startsWith(github.ref, 'refs/tags/packages/sdk-go/v') - needs: smoke + needs: checks runs-on: ubuntu-latest permissions: contents: write # create/update the DRAFT GitHub Release @@ -223,11 +224,6 @@ jobs: with: go-version: "1.27.0" cache-dependency-path: packages/sdk-go/go.sum - - name: Verify tag matches SDK version - run: | - TAG="${GITHUB_REF_NAME#packages/sdk-go/v}" - PKG="$(sed -nE 's/^var SDKVersion = "([^"]+)".*/\1/p' packages/sdk-go/internal/schemas/schemas.go)" - test "$TAG" = "$PKG" || { echo "tag packages/sdk-go/v$TAG != SDKVersion $PKG"; exit 1; } # goreleaser can't parse the path-prefixed tag as a version, so create a # LOCAL bare tag for the run (never pushed; no repo-wide vX.Y.Z ref). - name: Prepare goreleaser version tag (local only) diff --git a/RELEASING.md b/RELEASING.md index 03fb11a..8dfc2b7 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -37,10 +37,19 @@ recorded exception, and direct pushes stay blocked either way. Four of its lines are preconditions of the **first** tag, not just of a green run, because the PyPI upload repeats them after the tag exists, where nothing can be corrected in place: `release-path pins exact`, `wheel built`, - `twine check --strict (wheel + sdist)`, and `invalid sdist fixture rejected by - twine check --strict` (the gate proving it can still fail). Read them: a tag - cut past any of the four is a publish that can still fail once it is too late - to change anything. + `twine check --strict (wheel + sdist)`, and `invalid sdist rejected by twine + check --strict (unrenderable long_description)` (the gate proving it can still + fail). Read them: a tag cut past any of the four is a publish that can still + fail once it is too late to change anything. That last line asserts twine's + own refusal, status and diagnostic both, so an infrastructure failure is + reported as one instead of counting as a gate that fired; the distribution it + refuses is built during the run from tracked, reviewable text (a metadata file + and the unrenderable long description it declares), so nothing on this path is + a stored binary. The smoke also clears stale build output, stopping the run if + it cannot, and refuses on CI to run against a tree carrying untracked or + ignored files on the paths it reads. + This local run is the earliest of several, never the only one: see the + rehearsal below. 6. For changes touching CLI behavior, adoption, templates, schemas, assets, or viewer packaging: complete one representative adoption run on a real repository using a PACKED artifact (`npm run cli:packed:refresh`; see @@ -54,6 +63,36 @@ recorded exception, and direct pushes stay blocked either way. `CHANGELOG.json` declares the context-changelog schema, so its date must stay `YYYY-MM-DD`; do not park a word there. +## The rehearsal: the release path runs before there is a tag + +A check that executes for the first time on the irreversible path reports after +the tag exists, where it cannot be corrected in place. So every release check +that writes nothing lives in one reusable workflow, +`.github/workflows/release-checks.yml`, and three callers run it: + +| Caller | When | What runs | +|---|---|---| +| `ci.yml` → `Release rehearsal` | every pull request | smoke (no Node 22 container leg), version comparison, Python test + build + twine | +| `ci.yml` → `Release rehearsal` | every push to `rc/*` | all of the above with the Node 22 leg required, plus the cross-platform Go build | +| `release.yml` → `Release checks` | every release tag | the full set again, on the tagged bytes | + +It carries the pre-publish smoke, the version comparison that holds all five +packages to one version, the Python test + build + `twine check --strict`, and +`goreleaser release --clean --skip=publish` with every expected archive and its +checksum asserted. `release.yml` calls it before anything else and its remaining +jobs do nothing but write: the npm, JSR, and PyPI publishes and the draft GitHub +release. + +Two consequences for the procedure: + +- **The binding proof is the `rc/*` run, not the local one.** A local run reads + the machine it runs on; the runner reads a clean checkout, which is what the + tag will publish from. After the last amend to the release commit, push the + candidate again and let the full rehearsal go green on those exact bytes. +- **Verify tree identity before tagging.** The tag must name a commit whose tree + equals the rc-proven one (`git rev-parse ^{tree}` against + `git rev-parse ^{tree}`). A rehearsal binds to the bytes it saw. + ## Tagging model: per-package, path-prefixed Each package publishes from its **own** path-prefixed tag, and each release diff --git a/fixtures/release-path/pins/bad/7-npm-version/.github/workflows/npm-version.yml b/fixtures/release-path/pins/bad/7-npm-version/.github/workflows/npm-version.yml new file mode 100644 index 0000000..3ee4a4d --- /dev/null +++ b/fixtures/release-path/pins/bad/7-npm-version/.github/workflows/npm-version.yml @@ -0,0 +1,12 @@ +name: Publish npm drifted +# Rule 7: exactly pinned, so rule 3 is satisfied, and still wrong: the version is +# a literal that no longer matches the tuple in scripts/release-pins.env. This is +# how a publish job keeps installing last release's npm after the tuple moves. + +on: [push] + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - run: npm install -g npm@11.0.0 && npm --version diff --git a/fixtures/release-path/pins/good/.github/workflows/pinned.yml b/fixtures/release-path/pins/good/.github/workflows/pinned.yml index 5000926..2948abb 100644 --- a/fixtures/release-path/pins/good/.github/workflows/pinned.yml +++ b/fixtures/release-path/pins/good/.github/workflows/pinned.yml @@ -18,6 +18,13 @@ jobs: with: go-version: "1.27.0" - run: npm install -g npm@12.0.2 && npm --version + # The same install written the other accepted way: the version comes from + # the tuple itself, so there is no literal here that could drift from it. + - name: Install the publish npm from the tuple + run: | + . scripts/lib/release-pins.sh + load_release_pins scripts/release-pins.env + npm install -g "npm@${NPM_VERSION}" - run: npx --yes jsr@0.14.3 publish --dry-run - name: Install working-directory: packages/sdk-py diff --git a/fixtures/release-path/twine-reject/PKG-INFO b/fixtures/release-path/twine-reject/PKG-INFO new file mode 100644 index 0000000..5d43ade --- /dev/null +++ b/fixtures/release-path/twine-reject/PKG-INFO @@ -0,0 +1,18 @@ +Metadata-Version: 2.1 +Name: leji-invalid +Version: 0.0.0 +Summary: A distribution whose long description cannot be rendered. +Author: Leji +License: Apache-2.0 +Description-Content-Type: text/x-rst + +Deliberately invalid reStructuredText +===================================== + +`this inline literal is never closed, so docutils refuses the document + +.. an-unknown-directive:: + + and the reference below has no target + +`missing target`_ diff --git a/fixtures/release-path/twine-reject/README.rst b/fixtures/release-path/twine-reject/README.rst new file mode 100644 index 0000000..7d2d40f --- /dev/null +++ b/fixtures/release-path/twine-reject/README.rst @@ -0,0 +1,10 @@ +Deliberately invalid reStructuredText +===================================== + +`this inline literal is never closed, so docutils refuses the document + +.. an-unknown-directive:: + + and the reference below has no target + +`missing target`_ diff --git a/scripts/check-release-pins.sh b/scripts/check-release-pins.sh index 72c780f..b25bfee 100644 --- a/scripts/check-release-pins.sh +++ b/scripts/check-release-pins.sh @@ -22,12 +22,13 @@ # 2 every pip install requirement is name==X.Y.Z; no expansion but a tuple # version, no glob, no requirements file (one marked exception, below) # 3 every `npm install -g` package names an exact @X.Y.Z, and no npm install -# target is a shell expansion (the same marked exception) +# target is a shell expansion (the same marked exception, plus npm itself as +# ${NPM_VERSION}: that expansion IS the tuple, so it cannot drift from it) # 4 every uses: is pinned to a 40-character commit SHA (the comment is not a pin) # 5 every go-version names a patch component # 6 the Python build requirements and dev extras are exact # 7 the pinned publish action, its version comment, the twine the local gate -# installs, and every pip/build/twine/jsr/goreleaser version in the tree +# installs, and every npm/pip/build/twine/jsr/goreleaser version in the tree # equal scripts/release-pins.env # 8 every `npx ` / `npm exec ` names an exact @X.Y.Z # 9 every `version:` input to a `*-action` names an exact version, never a range @@ -104,7 +105,7 @@ scan_one() { scan_errors=$((scan_errors + 1)) return fi - if awk -v relpath="$2" -v type="$3" \ + if awk -v relpath="$2" -v type="$3" -v npm_v="$NPM_VERSION" \ -v pip_v="$PIP_VERSION" -v build_v="$BUILD_VERSION" -v twine_v="$TWINE_VERSION" \ -v jsr_v="$JSR_VERSION" -v goreleaser_v="$GORELEASER_VERSION" \ -v action_v="$PYPI_PUBLISH_ACTION_VERSION" -v action_sha="$PYPI_PUBLISH_ACTION_SHA" ' @@ -331,6 +332,10 @@ scan_one() { used_marker = 1 continue } + # The one version an install may name by expansion: the npm the + # tuple names, read from scripts/release-pins.env by the caller. + # It cannot drift from the tuple, because it is the tuple. + if (tok ~ /^npm@\$\{?NPM_VERSION\}?$/) continue fail(ln, 3, "npm install target comes from a shell expansion: " tok) continue } @@ -345,10 +350,17 @@ scan_one() { nspecs++ SPECS[nspecs] = tok } - if (global) - for (k = 1; k <= nspecs; k++) - if (!spec_pinned(SPECS[k])) + if (global) { + for (k = 1; k <= nspecs; k++) { + if (!spec_pinned(SPECS[k])) { fail(ln, 3, "global npm install without an exact @X.Y.Z: " SPECS[k]) + continue + } + # A pinned literal is still wrong when it is not the pinned one. + if (spec_name(SPECS[k]) == "npm" && spec_version(SPECS[k]) != npm_v) + fail(ln, 7, "npm " spec_version(SPECS[k]) " does not equal the NPM_VERSION " npm_v " in scripts/release-pins.env") + } + } return } } diff --git a/scripts/lib/release-pins.sh b/scripts/lib/release-pins.sh index f474e0b..404af07 100644 --- a/scripts/lib/release-pins.sh +++ b/scripts/lib/release-pins.sh @@ -10,11 +10,11 @@ # . scripts/lib/release-pins.sh # load_release_pins scripts/release-pins.env || exit 2 # -# On success the seven values are set in the calling shell. On failure it prints +# On success the eight values are set in the calling shell. On failure it prints # `release-pins.env:: ` on stderr and returns 2. # The expected keys, and the shape each value must have. -# X.Y.Z PIP_VERSION BUILD_VERSION TWINE_VERSION JSR_VERSION +# X.Y.Z NPM_VERSION PIP_VERSION BUILD_VERSION TWINE_VERSION JSR_VERSION # vX.Y.Z PYPI_PUBLISH_ACTION_VERSION GORELEASER_VERSION # 40 hex PYPI_PUBLISH_ACTION_SHA load_release_pins() { @@ -47,7 +47,7 @@ load_release_pins() { ;; esac case "$_rp_key" in - PIP_VERSION | BUILD_VERSION | TWINE_VERSION | JSR_VERSION) _rp_shape='^[0-9]+\.[0-9]+\.[0-9]+$' ;; + NPM_VERSION | PIP_VERSION | BUILD_VERSION | TWINE_VERSION | JSR_VERSION) _rp_shape='^[0-9]+\.[0-9]+\.[0-9]+$' ;; PYPI_PUBLISH_ACTION_VERSION | GORELEASER_VERSION) _rp_shape='^v[0-9]+\.[0-9]+\.[0-9]+$' ;; PYPI_PUBLISH_ACTION_SHA) _rp_shape='^[0-9a-f]{40}$' ;; *) @@ -64,7 +64,7 @@ load_release_pins() { _rp_seen="$_rp_seen $_rp_key" done < "$_rp_file" - for _rp_key in PIP_VERSION BUILD_VERSION TWINE_VERSION JSR_VERSION \ + for _rp_key in NPM_VERSION PIP_VERSION BUILD_VERSION TWINE_VERSION JSR_VERSION \ PYPI_PUBLISH_ACTION_VERSION PYPI_PUBLISH_ACTION_SHA GORELEASER_VERSION; do case " $_rp_seen " in *" $_rp_key "*) ;; diff --git a/scripts/release-pins.env b/scripts/release-pins.env index 2059ac4..c4e5836 100644 --- a/scripts/release-pins.env +++ b/scripts/release-pins.env @@ -1,7 +1,7 @@ # Release-path pin tuple: the versions the publish path must agree on. # # Read only through scripts/lib/release-pins.sh, which validates this file -# lexically before sourcing it: blank lines, comments, and these seven +# lexically before sourcing it: blank lines, comments, and these eight # assignments, nothing else. Enforced against the tree by # scripts/check-release-pins.sh rule 7, so a bump here cannot leave a copy behind. # @@ -14,8 +14,13 @@ # JSR_VERSION is the jsr CLI the publish step runs through npx; GORELEASER_VERSION # is the goreleaser the goreleaser-action installs (its own pin is the action SHA). # +# NPM_VERSION is the npm the publish jobs install before publishing: OIDC trusted +# publishing needs >= 11.5.1, and the Node the runner ships may be older. It is +# read here rather than written into each job so the version lives in one place. +# # Refreshed as part of every release pre-flight. +NPM_VERSION=12.0.2 PIP_VERSION=26.2.1 BUILD_VERSION=1.5.0 TWINE_VERSION=7.0.0 diff --git a/scripts/smoke-prepublish.sh b/scripts/smoke-prepublish.sh index 4334eb1..c2537e9 100755 --- a/scripts/smoke-prepublish.sh +++ b/scripts/smoke-prepublish.sh @@ -7,9 +7,20 @@ # # scripts/smoke-prepublish.sh # -# Requires: node + npm, python3, go. (jsr step uses npx.) Docker is a release -# machine prerequisite too: without it the Node 22 leg is skipped, and the final -# line says so. +# Requires: node + npm, git, python3 (>= 3.10; the release series is 3.12), go, +# tar, gzip, unzip, and Docker. (The jsr step uses npx.) Every one of them is +# probed before anything is built, because a tool discovered late reads as an +# artifact failure. Docker is a release machine prerequisite: without it the Node +# 22 leg is skipped, and the final line says so. +# +# Environment: +# CI=true untracked or ignored files on the paths this run reads are +# a failure rather than a warning (a runner checkout has none) +# REQUIRE_DOCKER=1 the Node 22 leg is mandatory: no Docker is a failure, never +# a skip. Set by the workflows on the release path. +# SKIP_DOCKER=1 the Node 22 leg is deliberately not run (the lighter +# pull-request rehearsal). Overrides REQUIRE_DOCKER. +# PYTHON= the interpreter to run the PyPI battery with. # # Host temp dirs and fresh venvs give clean isolation. For a true "clean # machine", re-run the install+battery inside a container, e.g.: @@ -52,6 +63,96 @@ _md5(){ if command -v md5sum >/dev/null 2>&1; then md5sum | awk '{print $1}'; el # The `command` field of a scaffold --json document, read from stdin. _jsoncmd(){ node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8")).command' 2>/dev/null; } +echo "== Prerequisites: the tools this run must not discover late ==" +# A missing unzip, a stopped Docker daemon, or a Python below the wheel's floor +# surfaces as a battery failure that says nothing about the artifacts, several +# minutes after the run began. Each is named here instead, before anything is +# built, and a missing one ends the run rather than colouring a later line red. +MISSING="" +for _tool in bash node npm git tar gzip unzip go; do + command -v "$_tool" >/dev/null 2>&1 || MISSING="$MISSING $_tool" +done +# Not merely a Python: the wheel declares requires-python >= 3.10 and the release +# workflows install 3.12, so a runner on another series would rehearse a +# toolchain the tag does not publish through. Locally any supported series runs, +# and the version is printed either way. +PY_RELEASE_SERIES="3.12" # mirrors python-version in the release workflows +PYVER="$("$PYBIN" -c 'import sys; print("%d.%d.%d" % sys.version_info[:3])' 2>/dev/null)" +if [ -z "$PYVER" ]; then + MISSING="$MISSING python3(>=3.10)" +elif [ "${CI:-}" = "true" ] && [ "${PYVER%.*}" != "$PY_RELEASE_SERIES" ]; then + MISSING="$MISSING python$PY_RELEASE_SERIES(found $PYVER)" +fi +# An absent docker binary and a daemon that will not answer read the same here: +# the floor leg needs a working docker either way. What differs is the +# consequence, and the caller says which it wants. +N22_SKIPPED=0 +N22_SKIP_WHY="" +if [ "${SKIP_DOCKER:-}" = "1" ]; then + N22_SKIPPED=1 + N22_SKIP_WHY="SKIP_DOCKER=1" +elif ! command -v docker >/dev/null 2>&1 || ! docker info >/dev/null 2>&1; then + if [ "${REQUIRE_DOCKER:-}" = "1" ]; then + MISSING="$MISSING docker(daemon)" + else + N22_SKIPPED=1 + N22_SKIP_WHY="docker not found" + fi +fi +if [ -n "$MISSING" ]; then + echo " missing:$MISSING" + echo + echo "== RESULT: prerequisites missing ==" + echo "Pre-publish smoke RED. Do NOT tag until resolved." + exit 1 +fi +DOCKER_STATE="daemon reachable" +[ "$N22_SKIPPED" = 1 ] && DOCKER_STATE="node 22 leg skipped ($N22_SKIP_WHY)" +ok "prerequisites (node $(node -v), python $PYVER, $(go env GOVERSION), tar, gzip, unzip; docker: $DOCKER_STATE)" + +echo "== Clean inputs: stale output cleared, nothing untracked on the paths read ==" +# Outputs first. A stale dist directory or a tarball left by an earlier run is an +# input to the packing steps below, and nothing downstream would notice it is +# old: the build writes over what it produces now and leaves the rest in place. +# The incremental build state goes with the output it describes - tsc reads it, +# decides the deleted files are current, and reports success having written +# nothing - which is also why a clean checkout, carrying neither, is the case +# this run has to behave like. +# Both commands are checked, and so is what survives them: a read-only tree, a +# directory whose parent denies writes, a file another process holds - each ends +# with the run building against exactly the stale output this step exists to +# remove, and none of them announces itself. So an incomplete clean stops the run +# here rather than colouring a packing result red several minutes later. +CLEAN_FAILED="" +rm -rf "$ROOT"/packages/*/dist "$ROOT"/.cache/tsc 2>/dev/null || CLEAN_FAILED="dist + tsc build state" +rm -f "$ROOT"/packages/sdk/leji-*.tgz "$ROOT"/packages/create-leji/create-leji-*.tgz 2>/dev/null \ + || CLEAN_FAILED="$CLEAN_FAILED packed tarballs" +LEFTOVER="$(ls -d "$ROOT"/packages/*/dist "$ROOT"/.cache/tsc "$ROOT"/packages/sdk/leji-*.tgz \ + "$ROOT"/packages/create-leji/create-leji-*.tgz 2>/dev/null)" +if [ -z "$CLEAN_FAILED" ] && [ -z "$LEFTOVER" ]; then + ok "stale build output cleared (packages/*/dist, tsc build state, packed tarballs)" +else + no "stale build output not cleared${CLEAN_FAILED:+ (failed: $CLEAN_FAILED)}" + [ -n "$LEFTOVER" ] && printf '%s\n' "$LEFTOVER" | sed "s|^$ROOT/| still present: |" + echo + echo "== RESULT: $PASS passed, $FAIL failed ==" + echo "Pre-publish smoke RED. Do NOT tag until resolved." + exit 1 +fi +# Then the sources. `--others` with no exclusion list is deliberate: a file that +# git ignores is exactly as absent from a fresh clone as one that was never +# added, and either one changes what this run builds from what the tag will. +DIRTY="$(git -C "$ROOT" ls-files --others -- spec schemas templates fixtures 'packages/*/src' packages/sdk-go 2>/dev/null)" +if [ -z "$DIRTY" ]; then + ok "input paths carry no untracked or ignored file" +elif [ "${CI:-}" = "true" ]; then + no "untracked or ignored files under the paths this run reads" + printf '%s\n' "$DIRTY" | sed 's/^/ /' +else + printf " \033[33mWARN\033[0m %s\n" "untracked or ignored files under the paths this run reads (CI refuses them)" + printf '%s\n' "$DIRTY" | sed 's/^/ /' +fi + echo "== Layer 0: version coherence + assets sync + release pins + build ==" # All 9 version locations must agree before we build artifacts that bake the # version in; a drifting Go SDKVersion would otherwise ship mismatched. @@ -115,15 +216,13 @@ echo "== Node 22 (published-package floor) ==" # dependencies (ajv, yaml, the MCP SDK) come from the registry. The three Leji # packages never do, and the resolved SDK version is asserted below to prove it. N22_OUT="" -N22_SKIPPED=0 # One marker line per check, so a container that half-ran cannot read as a pass. n22_has() { printf '%s\n' "$N22_OUT" | grep -qF -- "$1"; } -if ! command -v docker >/dev/null 2>&1 || ! docker info >/dev/null 2>&1; then - # A daemon that does not answer reads the same as an absent binary here: the - # leg needs a working docker either way, and what a release machine looks for - # is one line it cannot miss. - N22_SKIPPED=1 - echo "SKIP node 22 leg (docker not found)" +if [ "$N22_SKIPPED" = 1 ]; then + # Decided in the prerequisite probe above, where a missing daemon is either a + # failure or a skip depending on what the caller asked for. What a release + # machine looks for is one line it cannot miss. + echo "SKIP node 22 leg ($N22_SKIP_WHY)" else # The MCP tarball is built and packed here rather than in layer 0: it is the # only artifact this leg adds, and nothing above it needs one. @@ -178,19 +277,66 @@ else no "twine check --strict (wheel + sdist)" sed -n '1,20p' "$TMP/twine.log" fi -# And the gate is proven able to fail: the checked-in unrenderable sdist goes -# through the same function, which must reject it. A missing fixture is a red -# line, not a silent pass: an empty dist would "fail" for the wrong reason. -REJ="$(ls -t "$ROOT"/fixtures/release-path/twine-reject/*.tar.gz 2>/dev/null | head -1)" -if [ -z "$REJ" ]; then - no "invalid sdist fixture missing (fixtures/release-path/twine-reject)" +# And the gate is proven able to fail: an unrenderable distribution goes through +# the same function, which must reject it. Its content is tracked, reviewable +# text under fixtures/release-path/twine-reject - a PKG-INFO declaring +# text/x-rst beside a long description docutils refuses - and only the archive +# around it is generated, so nothing on this path is a binary nobody can read in +# a diff. It is archived by Python rather than by tar because every field tar +# fills in from the machine is a field that makes the archive differ between two +# runs: the format, the ownership, the names, the modes, the member order, and +# both timestamps are written explicitly here, so the same sources yield the same +# bytes on any platform and in any timezone. Same interpreter as the battery. +REJ_SRC="$ROOT/fixtures/release-path/twine-reject" +REJ_TGZ="$TMP/reject-dist/leji-invalid-0.0.0.tar.gz" +mkdir -p "$TMP/reject-dist" +"$PYBIN" - "$REJ_SRC" "$REJ_TGZ" leji-invalid-0.0.0 <<'PY' +import gzip, io, os, sys, tarfile + +src, out, prefix = sys.argv[1], sys.argv[2], sys.argv[3] +members = ("PKG-INFO", "README.rst") # fixed order, not a directory listing +with gzip.GzipFile(out, "wb", mtime=0) as gz: + with tarfile.open(fileobj=gz, mode="w", format=tarfile.USTAR_FORMAT) as tar: + for name in members: + with open(os.path.join(src, name), "rb") as fh: + data = fh.read() + info = tarfile.TarInfo(prefix + "/" + name) + info.type = tarfile.REGTYPE + info.size = len(data) + info.mtime = 0 + info.mode = 0o644 + info.uid = info.gid = 0 + info.uname = info.gname = "" + tar.addfile(info, io.BytesIO(data)) +PY +# What was built is verified before it is judged. An archive that never got +# written, or got written without its metadata, would still be refused - for a +# reason that says nothing about the gate this line exists to prove. +REJ_MEMBERS="$(tar tzf "$REJ_TGZ" 2>/dev/null | tr '\n' ' ')" +if [ "$REJ_MEMBERS" = "leji-invalid-0.0.0/PKG-INFO leji-invalid-0.0.0/README.rst " ]; then + ok "invalid sdist built from the tracked sources (PKG-INFO + README.rst)" +else + no "invalid sdist not built as expected (members: ${REJ_MEMBERS:-none})" +fi +# And the rejection is asserted, never inferred from a non-zero exit. A generator +# that failed leaves an empty directory, and twine_check refuses an empty +# directory too; so does a venv that would not build or an install that could not +# reach the index. Each of those is an infrastructure failure of this smoke and +# is reported as one: the gate counts as fired only on twine's own refusal, which +# is status 1 carrying the diagnostic. The log is squeezed to one line first, +# because twine wraps that sentence at the console width. +if [ ! -s "$REJ_TGZ" ]; then + no "invalid sdist could not be generated; the rejection cannot be asserted" else - mkdir -p "$TMP/reject-dist" - cp "$REJ" "$TMP/reject-dist/" - if twine_check "$PYBIN" "$ROOT/packages/sdk-py" "$TMP/pybuild" "$TMP/reject-dist" >/dev/null 2>&1; then - no "invalid sdist fixture passed twine check --strict" + twine_check "$PYBIN" "$ROOT/packages/sdk-py" "$TMP/pybuild" "$TMP/reject-dist" > "$TMP/reject.log" 2>&1 + REJ_STATUS=$? + if [ "$REJ_STATUS" = 0 ]; then + no "invalid sdist passed twine check --strict" + elif [ "$REJ_STATUS" = 1 ] && tr -s '[:space:]' ' ' < "$TMP/reject.log" | grep -q 'long_description` has syntax errors in markup'; then + ok "invalid sdist rejected by twine check --strict (unrenderable long_description)" else - ok "invalid sdist fixture rejected by twine check --strict" + no "invalid sdist: infrastructure failure of the smoke, not a rejection (exit $REJ_STATUS)" + sed -n '1,20p' "$TMP/reject.log" fi fi WHL="$(ls -t "$TMP/pybuild/dist"/*.whl 2>/dev/null | head -1)" @@ -238,7 +384,7 @@ if [ "$FAIL" = 0 ]; then # A skipped floor leg is carried into the verdict line: a release machine that # reads a plain GREEN must be one where the floor actually ran. if [ "$N22_SKIPPED" = 1 ]; then - echo "Pre-publish smoke GREEN (node 22 leg skipped)." + echo "Pre-publish smoke GREEN (node 22 leg skipped: $N22_SKIP_WHY)." else echo "Pre-publish smoke GREEN." fi