diff --git a/.github/workflows/publish-js.yml b/.github/workflows/publish-js.yml index f3d0bfdd6..034c2eff3 100644 --- a/.github/workflows/publish-js.yml +++ b/.github/workflows/publish-js.yml @@ -17,6 +17,11 @@ on: release: types: [published] workflow_dispatch: + inputs: + tag: + description: 'Re-publish an existing release tag (e.g. v0.20.1) as npm latest; empty builds the selected ref' + required: false + type: string permissions: contents: read @@ -29,27 +34,29 @@ env: jobs: # ============================================================================ - # Verify the publish source is on main before running any repo code + # Resolve and verify the publish source before running any repo code # with secrets. Manual dispatch and release events can target arbitrary - # refs; this gate ensures all subsequent jobs see trusted code. + # refs; this gate ensures all subsequent jobs see trusted code. A `tag` + # input re-publishes that release tag's exact commit (see the script). # ============================================================================ verify-source: name: Verify publish source runs-on: ubuntu-latest + outputs: + sha: ${{ steps.source.outputs.sha }} + dist_tag: ${{ steps.source.outputs.dist_tag }} + version: ${{ steps.source.outputs.version }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + persist-credentials: false - - name: Verify publish source is reachable from main - run: | - git fetch --no-tags origin main:refs/remotes/origin/main - SOURCE_SHA=$(git rev-parse HEAD) - if ! git merge-base --is-ancestor "$SOURCE_SHA" origin/main; then - echo "Error: publish source $SOURCE_SHA is not reachable from origin/main" - exit 1 - fi - echo "Publish source verified on origin/main: $SOURCE_SHA" + - name: Resolve publish source + id: source + env: + INPUT_TAG: ${{ inputs.tag }} + run: bash scripts/resolve_publish_source.sh # ============================================================================ # Build native bindings for each platform @@ -87,6 +94,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ needs.verify-source.outputs.sha }} persist-credentials: false - name: Setup pnpm @@ -174,7 +182,7 @@ jobs: # ============================================================================ test-js-macos-windows: name: Test JS on ${{ matrix.settings.target }} - node@${{ matrix.node }} - needs: [build-js] + needs: [verify-source, build-js] strategy: fail-fast: false matrix: @@ -193,8 +201,21 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ needs.verify-source.outputs.sha }} persist-credentials: false + # Re-publishing an older tag: ship the tag's package bytes, but gate them + # with this workflow commit's tests (tests are not in the npm tarball). + # v0.20.1's own suite had a host-callback timeout too tight for the + # Rosetta lane, fixed in #2780. + - name: Use the workflow commit's JS tests + if: needs.verify-source.outputs.sha != github.sha + shell: bash + run: | + git fetch --no-tags --depth=1 origin "$GITHUB_SHA" + rm -rf crates/bashkit-js/__test__ + git checkout FETCH_HEAD -- crates/bashkit-js/__test__ + - name: Setup pnpm uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: @@ -304,7 +325,7 @@ jobs: # ============================================================================ test-js-linux: name: Test JS ${{ matrix.target }} - node@${{ matrix.node }} - needs: [build-js] + needs: [verify-source, build-js] strategy: fail-fast: false matrix: @@ -316,8 +337,21 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ needs.verify-source.outputs.sha }} persist-credentials: false + # Re-publishing an older tag: ship the tag's package bytes, but gate them + # with this workflow commit's tests (tests are not in the npm tarball). + # v0.20.1's own suite had a host-callback timeout too tight for the + # Rosetta lane, fixed in #2780. + - name: Use the workflow commit's JS tests + if: needs.verify-source.outputs.sha != github.sha + shell: bash + run: | + git fetch --no-tags --depth=1 origin "$GITHUB_SHA" + rm -rf crates/bashkit-js/__test__ + git checkout FETCH_HEAD -- crates/bashkit-js/__test__ + - name: Setup pnpm uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: @@ -480,6 +514,7 @@ jobs: name: Release to NPM runs-on: ubuntu-latest needs: + - verify-source - test-js-macos-windows - test-js-linux @@ -491,16 +526,14 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.verify-source.outputs.sha }} + persist-credentials: false - - name: Verify publish source is on main - run: | - git fetch --no-tags origin main:refs/remotes/origin/main - SOURCE_SHA=$(git rev-parse HEAD) - if ! git merge-base --is-ancestor "$SOURCE_SHA" origin/main; then - echo "Error: publish source $SOURCE_SHA is not reachable from origin/main" - exit 1 - fi - echo "Publish source verified on origin/main: $SOURCE_SHA" + - name: Confirm checkout is the verified publish source + env: + SOURCE_SHA: ${{ needs.verify-source.outputs.sha }} + run: test "$(git rev-parse HEAD)" = "$SOURCE_SHA" - name: Setup pnpm uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 @@ -550,22 +583,20 @@ jobs: - name: Determine npm dist-tag id: dist-tag working-directory: crates/bashkit-js + env: + DIST_TAG: ${{ needs.verify-source.outputs.dist_tag }} + TAG_VERSION: ${{ needs.verify-source.outputs.version }} run: | PKG_VERSION=$(node -p "require('./package.json').version") echo "version=$PKG_VERSION" # Only verified release tags may publish as npm latest. Branch or # manually selected refs must not become the default install target. - if [[ "$GITHUB_REF" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - TAG_VERSION="${GITHUB_REF#refs/tags/v}" - if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then - echo "Error: Tag version ($TAG_VERSION) does not match package.json version ($PKG_VERSION)" - exit 1 - fi - echo "tag=latest" >> "$GITHUB_OUTPUT" - else - echo "tag=next" >> "$GITHUB_OUTPUT" + if [ "$DIST_TAG" = latest ] && [ "$TAG_VERSION" != "$PKG_VERSION" ]; then + echo "Error: Tag version ($TAG_VERSION) does not match package.json version ($PKG_VERSION)" + exit 1 fi + echo "tag=$DIST_TAG" >> "$GITHUB_OUTPUT" - name: Publish run: | diff --git a/.github/workflows/publish-wasm.yml b/.github/workflows/publish-wasm.yml index 72bc6b681..bed566c8f 100644 --- a/.github/workflows/publish-wasm.yml +++ b/.github/workflows/publish-wasm.yml @@ -17,6 +17,11 @@ on: release: types: [published] workflow_dispatch: + inputs: + tag: + description: 'Re-publish an existing release tag (e.g. v0.20.1) as npm latest; empty builds the selected ref' + required: false + type: string permissions: contents: read @@ -25,22 +30,24 @@ env: CARGO_TERM_COLOR: always jobs: + # A `tag` input re-publishes that release tag's exact commit; see the script. verify-source: name: Verify publish source runs-on: ubuntu-latest + outputs: + sha: ${{ steps.source.outputs.sha }} + dist_tag: ${{ steps.source.outputs.dist_tag }} + version: ${{ steps.source.outputs.version }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - - name: Verify publish source is reachable from main - run: | - git fetch --no-tags origin main:refs/remotes/origin/main - SOURCE_SHA=$(git rev-parse HEAD) - if ! git merge-base --is-ancestor "$SOURCE_SHA" origin/main; then - echo "Error: publish source $SOURCE_SHA is not reachable from origin/main" - exit 1 - fi - echo "Publish source verified on origin/main: $SOURCE_SHA" + persist-credentials: false + - name: Resolve publish source + id: source + env: + INPUT_TAG: ${{ inputs.tag }} + run: bash scripts/resolve_publish_source.sh build-wasm: name: Build web bundle @@ -52,6 +59,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ needs.verify-source.outputs.sha }} persist-credentials: false - name: Install Rust @@ -109,7 +117,7 @@ jobs: publish-wasm: name: Publish to npm - needs: build-wasm + needs: [verify-source, build-wasm] runs-on: ubuntu-latest permissions: contents: read @@ -117,16 +125,13 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 + ref: ${{ needs.verify-source.outputs.sha }} + persist-credentials: false - - name: Verify publish source is reachable from main - run: | - git fetch --no-tags origin main:refs/remotes/origin/main - SOURCE_SHA=$(git rev-parse HEAD) - if ! git merge-base --is-ancestor "$SOURCE_SHA" origin/main; then - echo "Error: publish source $SOURCE_SHA is not reachable from origin/main" - exit 1 - fi + - name: Confirm checkout is the verified publish source + env: + SOURCE_SHA: ${{ needs.verify-source.outputs.sha }} + run: test "$(git rev-parse HEAD)" = "$SOURCE_SHA" - name: Setup node uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v6 @@ -154,20 +159,18 @@ jobs: - name: Determine npm dist-tag id: dist-tag working-directory: pkg + env: + DIST_TAG: ${{ needs.verify-source.outputs.dist_tag }} + TAG_VERSION: ${{ needs.verify-source.outputs.version }} run: | PKG_VERSION=$(node -p "require('./package.json').version") echo "version=$PKG_VERSION" # Only verified release tags publish as npm latest. - if [[ "$GITHUB_REF" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - TAG_VERSION="${GITHUB_REF#refs/tags/v}" - if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then - echo "Error: Tag version ($TAG_VERSION) does not match package.json version ($PKG_VERSION)" - exit 1 - fi - echo "tag=latest" >> "$GITHUB_OUTPUT" - else - echo "tag=next" >> "$GITHUB_OUTPUT" + if [ "$DIST_TAG" = latest ] && [ "$TAG_VERSION" != "$PKG_VERSION" ]; then + echo "Error: Tag version ($TAG_VERSION) does not match package.json version ($PKG_VERSION)" + exit 1 fi + echo "tag=$DIST_TAG" >> "$GITHUB_OUTPUT" - name: Publish working-directory: pkg diff --git a/knowledge/log.md b/knowledge/log.md index 84d9d623e..114cf1977 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -1,5 +1,9 @@ # Bashkit Knowledge Update Log +## 2026-10-29 + +* **npm release recovery**: `publish-js.yml` and `publish-wasm.yml` accept a `tag` input on manual dispatch to re-publish an existing release tag's exact commit as `latest`, from the current workflow definition. Added after 0.20.1 never reached npm (revoked token in the tag's workflow, Rosetta test timeout). See [Release Process](operations/release-process.md). + ## 2026-10-27 * **Windows Python packaging**: Document the reported 0.20.0 pin for the PondGuard SAC block and a fresh-environment A/B procedure with file hashes, Authenticode checks, and correlated Code Integrity events. Both published 0.20.0/0.20.1 x64 modules lack embedded signatures; both PyPI wheel provenance endpoints returned 404. No Windows reproduction or permanent signing fix is claimed. See [Python Package](runtimes/python-package.md). diff --git a/knowledge/operations/release-process.md b/knowledge/operations/release-process.md index 893165f21..8192b7073 100644 --- a/knowledge/operations/release-process.md +++ b/knowledge/operations/release-process.md @@ -308,11 +308,21 @@ npm >= 11.5.1, provenance automatic); no `NPM_TOKEN`. JS package version is sync from the workspace `Cargo.toml` by `build.rs` updating `package.json`. Only a run whose ref is the release tag (`refs/tags/vX.Y.Z`, version matching -`package.json`) publishes as npm `latest`; every other ref, including a manual -dispatch from `main`, publishes under `next`. So a failed npm publish cannot be -recovered by re-dispatching from `main`: fix the cause and cut a patch release, -whose tag then publishes `latest`. v0.17.0 landed on `next` this way, and -v0.17.1 is what put the native package back on `latest`. +`package.json`) publishes as npm `latest`; every other ref, including a plain +manual dispatch from `main`, publishes under `next`. v0.17.0 landed on `next` +this way, and v0.17.1 is what put the native package back on `latest`. + +To recover a release whose npm lane failed without cutting a new version, +dispatch `publish-js.yml` / `publish-wasm.yml` from `main` with the `tag` +input (`gh workflow run publish-js.yml --ref main -f tag=v0.20.1`). +`scripts/resolve_publish_source.sh` then builds the tag's exact commit and +publishes it as `latest`; the workflow definition must be the tag's or current +`main`'s, and the tag must be reachable from `main`. Package bytes come from +the tag, but `publish-js.yml` gates them with the workflow commit's +`crates/bashkit-js/__test__` (tests are not in the tarball), so a test fix +landed after the tag (v0.20.1's Rosetta timeout, #2780) still applies. Use this +when the failure was in the workflow or credentials, not in the shipped code; +a code bug still needs a patch release. The publish job never runs `tsc`; it ships whatever the `js-stubs` artifact carries. That artifact must list every `tsc` output (`.js` + `.d.ts` for each diff --git a/scripts/resolve_publish_source.sh b/scripts/resolve_publish_source.sh new file mode 100755 index 000000000..3b5359ddf --- /dev/null +++ b/scripts/resolve_publish_source.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Resolve the commit an npm publish workflow builds, and its npm dist-tag. +# +# Decisions: +# - Default: build the workflow's own commit. Only a release-tag ref +# (refs/tags/vX.Y.Z) publishes as `latest`; any other ref publishes `next`. +# - Recovery: a manual dispatch may name an existing release tag (`tag` input). +# The workflow definition then comes from that tag or current main, the +# package source from the tag's exact commit, and it publishes as `latest`. +# This re-publishes a release whose npm lane failed without cutting a new +# version (0.20.1: the tag's own workflow still used a revoked NPM_TOKEN). +# - The source commit must always be reachable from origin/main. +# +# Env: INPUT_TAG (may be empty), GITHUB_SHA, GITHUB_REF, GITHUB_OUTPUT. +# Outputs: sha, dist_tag, version (expected package version, empty for next). +set -euo pipefail + +git fetch --no-tags --force origin main:refs/remotes/origin/main +MAIN_SHA=$(git rev-parse origin/main) +TAG_RE='^v[0-9]+\.[0-9]+\.[0-9]+$' + +if [ -n "${INPUT_TAG:-}" ]; then + if [[ ! "$INPUT_TAG" =~ $TAG_RE ]]; then + echo "Error: tag must match vMAJOR.MINOR.PATCH, got '$INPUT_TAG'" >&2 + exit 1 + fi + git fetch --no-tags --force origin "refs/tags/$INPUT_TAG:refs/tags/$INPUT_TAG" + SOURCE_SHA=$(git rev-list -n 1 "refs/tags/$INPUT_TAG") + if [ "$GITHUB_SHA" != "$SOURCE_SHA" ] && [ "$GITHUB_SHA" != "$MAIN_SHA" ]; then + echo "Error: with a tag input the workflow ref must be $INPUT_TAG or current main" >&2 + exit 1 + fi + DIST_TAG=latest + VERSION="${INPUT_TAG#v}" +else + SOURCE_SHA="$GITHUB_SHA" + if [[ "$GITHUB_REF" == refs/tags/* ]] && [[ "${GITHUB_REF#refs/tags/}" =~ $TAG_RE ]]; then + DIST_TAG=latest + VERSION="${GITHUB_REF#refs/tags/v}" + else + DIST_TAG=next + VERSION="" + fi +fi + +if ! git merge-base --is-ancestor "$SOURCE_SHA" "$MAIN_SHA"; then + echo "Error: publish source $SOURCE_SHA is not reachable from origin/main" >&2 + exit 1 +fi + +echo "Publish source $SOURCE_SHA (dist-tag $DIST_TAG${VERSION:+, version $VERSION})" +{ + echo "sha=$SOURCE_SHA" + echo "dist_tag=$DIST_TAG" + echo "version=$VERSION" +} >> "$GITHUB_OUTPUT" diff --git a/scripts/tests/test_release_workflow.py b/scripts/tests/test_release_workflow.py index 24415fbf3..a9852e67a 100644 --- a/scripts/tests/test_release_workflow.py +++ b/scripts/tests/test_release_workflow.py @@ -176,6 +176,22 @@ def test_npm_publish_lanes_use_trusted_publishing(self) -> None: self.assertIn("id-token: write", body.split("\n steps:\n", 1)[0]) self.assertIn("npm install -g npm@11.6.2", body) + def test_npm_publish_lanes_build_only_the_resolved_source(self) -> None: + """A `tag` input re-publishes that tag; every job must build the same commit.""" + for name in ("publish-js.yml", "publish-wasm.yml"): + workflow = (ROOT / ".github/workflows" / name).read_text() + with self.subTest(workflow=name): + self.assertIn("run: bash scripts/resolve_publish_source.sh", workflow) + self.assertIn("INPUT_TAG: ${{ inputs.tag }}", workflow) + self.assertNotIn("GITHUB_REF", workflow) + jobs = workflow.split("\njobs:\n", 1)[1] + for job in re.split(r"\n (?=[a-z][a-z-]*:\n)", jobs): + if job.startswith(("verify-source:", "#")) or "actions/checkout@" not in job: + continue + with self.subTest(job=job.split(":", 1)[0]): + self.assertIn("ref: ${{ needs.verify-source.outputs.sha }}", job) + self.assertRegex(job, r"needs: \[?[^\n]*verify-source|needs:\n(\s+- [a-z-]+\n)*\s+- verify-source") + def test_web_ci_exercises_release_wasm_optimization(self) -> None: build_script = (ROOT / "crates/bashkit-wasm/scripts/build.sh").read_text() ci_workflow = (ROOT / ".github/workflows/ci.yml").read_text() diff --git a/scripts/tests/test_resolve_publish_source.py b/scripts/tests/test_resolve_publish_source.py new file mode 100644 index 000000000..d95cc1d4a --- /dev/null +++ b/scripts/tests/test_resolve_publish_source.py @@ -0,0 +1,102 @@ +"""npm publish source resolution: default ref vs. re-publishing a release tag.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + +SCRIPT = pathlib.Path(__file__).resolve().parents[1] / "resolve_publish_source.sh" + + +def git(cwd, *args): + return subprocess.run( + ["git", *args], cwd=cwd, check=True, capture_output=True, text=True + ).stdout.strip() + + +class ResolvePublishSourceTests(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + root = pathlib.Path(self.tmp.name) + origin = root / "origin" + origin.mkdir() + git(origin, "init", "-q", "-b", "main") + git(origin, "config", "user.email", "t@example.com") + git(origin, "config", "user.name", "t") + git(origin, "commit", "-q", "--allow-empty", "-m", "release") + git(origin, "tag", "v1.2.3") + self.tag_sha = git(origin, "rev-parse", "HEAD") + git(origin, "commit", "-q", "--allow-empty", "-m", "after") + self.main_sha = git(origin, "rev-parse", "HEAD") + git(origin, "checkout", "-q", "-b", "side", self.tag_sha) + git(origin, "commit", "-q", "--allow-empty", "-m", "off main") + self.side_sha = git(origin, "rev-parse", "HEAD") + git(origin, "tag", "v9.9.9") + git(origin, "checkout", "-q", "main") + self.work = root / "work" + git(root, "clone", "-q", str(origin), str(self.work)) + self.output = root / "out" + + def tearDown(self) -> None: + self.tmp.cleanup() + + def resolve(self, *, tag="", sha, ref): + self.output.write_text("") + env = { + **os.environ, + "INPUT_TAG": tag, + "GITHUB_SHA": sha, + "GITHUB_REF": ref, + "GITHUB_OUTPUT": str(self.output), + } + result = subprocess.run( + ["bash", str(SCRIPT)], cwd=self.work, env=env, capture_output=True, text=True + ) + outputs = dict( + line.split("=", 1) for line in self.output.read_text().splitlines() + ) + return result.returncode, outputs, result.stderr + + def test_release_tag_ref_publishes_latest(self) -> None: + code, out, _ = self.resolve(sha=self.tag_sha, ref="refs/tags/v1.2.3") + self.assertEqual(code, 0) + self.assertEqual(out, {"sha": self.tag_sha, "dist_tag": "latest", "version": "1.2.3"}) + + def test_branch_ref_publishes_next(self) -> None: + code, out, _ = self.resolve(sha=self.main_sha, ref="refs/heads/main") + self.assertEqual(code, 0) + self.assertEqual(out, {"sha": self.main_sha, "dist_tag": "next", "version": ""}) + + def test_branch_named_like_a_tag_is_not_latest(self) -> None: + _, out, _ = self.resolve(sha=self.main_sha, ref="refs/heads/v1.2.3") + self.assertEqual(out["dist_tag"], "next") + + def test_tag_input_from_main_builds_the_tag_commit(self) -> None: + code, out, _ = self.resolve(tag="v1.2.3", sha=self.main_sha, ref="refs/heads/main") + self.assertEqual(code, 0) + self.assertEqual(out, {"sha": self.tag_sha, "dist_tag": "latest", "version": "1.2.3"}) + + def test_tag_input_rejects_other_workflow_refs(self) -> None: + code, out, err = self.resolve(tag="v1.2.3", sha=self.side_sha, ref="refs/heads/side") + self.assertEqual(code, 1) + self.assertEqual(out, {}) + self.assertIn("must be v1.2.3 or current main", err) + + def test_tag_input_must_look_like_a_release(self) -> None: + code, out, err = self.resolve(tag="main", sha=self.main_sha, ref="refs/heads/main") + self.assertEqual(code, 1) + self.assertEqual(out, {}) + self.assertIn("vMAJOR.MINOR.PATCH", err) + + def test_source_off_main_is_rejected(self) -> None: + code, out, err = self.resolve(tag="v9.9.9", sha=self.side_sha, ref="refs/tags/v9.9.9") + self.assertEqual(code, 1) + self.assertEqual(out, {}) + self.assertIn("not reachable from origin/main", err) + + +if __name__ == "__main__": + unittest.main()