Skip to content

chore(release): add protected npm next prerelease channel - #688

Merged
mohanagy merged 6 commits into
nextfrom
chore/next-prerelease-pipeline
Aug 13, 2026
Merged

chore(release): add protected npm next prerelease channel#688
mohanagy merged 6 commits into
nextfrom
chore/next-prerelease-pipeline

Conversation

@mohanagy

@mohanagy mohanagy commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Establishes the protected npm prerelease channel on next, per #687.

Final head: e6cad3b0bdeb43b1ce7803a32185597a74e652bd.

Nothing is published, tagged, or released by this PR. It adds the pipeline and its guards only.

Job architecture

validate  (contents: read, no environment)
   → publish      (contents: read + id-token: write, environment: npm-next)
      → post_publish (contents: write, no id-token)
  • validate — checkout, pinned npm bootstrap, full validation, and exactly one tarball built via npm pack --json with a recorded SHA-256, size, name, version, source commit, source tag, lockfile checksum, and Node/npm versions. Uploads the tarball, its checksum, and a receipt under a run/attempt-unique artifact name.
  • publish — no checkout, no npm ci, no tests, no build, no repository scripts, no dependency cache. Downloads the artifact, re-verifies its SHA-256 and embedded package name/version, verifies the registry, npm version, absence of tokens, and that the version is unpublished. Captures pre-publish dist-tags. Then npm publish ./<tarball>.tgz --tag next --access public --provenance as its final substantive step — nothing runs after it.
  • post_publish — verifies propagation, that next moved, and that latest is unchanged; clean-installs the exact version and @next and exercises the installed binary against a fresh workspace; creates or edits the GitHub prerelease idempotently.

Because publish ends at the publish command, a failed verification or release step can never be "repaired" by republishing an immutable npm version — post_publish is rerunnable on its own.

Cross-job metadata uses explicit step outputs

validate.outputs previously read version, commit, and tag from ${{ env.* }}, but those were written at runtime via $GITHUB_ENV, which does not reliably populate jobs.<id>.outputspublish could have received empty values. All job outputs now map from steps.<id>.outputs.* via the stable IDs release-meta and release-commit. Tests assert the dataflow, not merely that an outputs: block exists.

Stable tag classification is now operational

release.yml triggered on v* and skipped its job for any tag containing -. An unapproved form such as v0.33.0-alpha.1 therefore matched the stable trigger, skipped, matched no publish-next.yml trigger, and produced no result at all — the classifier rejected it only when invoked directly, and nothing invoked it.

release.yml now has two jobs: classify (contents: read) runs for every v* tag and fails loudly on anything that is neither stable nor an approved prerelease form; release (contents: write) runs only when needs.classify.outputs.channel == 'stable'. Workflow-level contents: write is removed.

Verified across all eight forms: v0.33.0stable; -beta.1/-rc.1/-next.1prerelease with the stable job skipping cleanly; -alpha.1, -preview.1, -beta, -beta.01 → classifier fails.

A green vitest summary can no longer pass as release evidence

vitest.config.ts sets maxWorkers: 4 with no retry. The forks pool respawns workers inside a single run, so files that logged Failed to start forks worker can still be tallied as passed — exit code 0 plus a green summary was not sufficient evidence.

.github/scripts/assert-clean-vitest-log.mjs scans the captured raw output of both test:run and test:coverage. The step captures PIPESTATUS[0] so the real command status survives tee, runs the scanner regardless of that status, and fails when either the tests failed or a signature appeared. Missing or unreadable log paths fail rather than counting as clean. Raw logs upload as a diagnostic artifact if: failure() with bounded retention; publish never downloads it. No retries were added and no timeouts raised.

Qualification gate becomes mandatory on its own

check-qualification-gate.mjs has three outcomes: script present → run it; script absent and docs/qualification/ present → hard fail; neither present → record a notice. Since the contract directory and the qualify:validate script both land with #681, the gate self-activates on the first tag that includes that merge — no flag, no date, no follow-up PR. The missing outcome exists so a vanished validator can never silently downgrade to a skip.

Security properties

  • Prerelease workflow triggers on approved prerelease tags only. No workflow_dispatch (the workflow lives only on next, and GitHub requires the default branch for manual dispatch, so that path was inoperable), no pull_request, no branch push.
  • No job holds both id-token: write and contents: write. No workflow-level id-token.
  • No setup-node dependency cache in any release job, honouring the cache-poisoning finding from chore: synchronize next with the current stable baseline #686 — including release.yml's release job, which now holds contents: write.
  • persist-credentials: false; checkout uses github.sha, so no untrusted input reaches actions/checkout's ref:.
  • All external actions pinned to full 40-character commit SHAs, each re-verified against the official actions/* repositories: checkout 3d3c42e5… (v7.0.1), setup-node 82076278… (v7.0.0), upload-artifact 043fb46d… (v7.0.1), download-artifact 3e5f45b2… (v8.0.1).
  • npm pinned to 12.0.2, installed with --ignore-scripts, so no unpinned toolchain executes in a credentialed job.
  • Long-lived tokens rejected outright; no provenance fallback; no publishing from ..
  • MCP Registry metadata deliberately untouched.

CodeRabbit review — five findings, all remediated

  1. cache: npm removed from release.yml's release job, which holds contents: write.
  2. npm view failures captured so the operator sees the registry error rather than a downstream JSON parse error.
  3. spawnSync .error checked, so a missing git is reported as such.
  4. The third qualification outcome documented in docs/release.md.
  5. commit.gpgsign=false pinned in a git fixture.

All review threads resolved.

Validation at e6cad3b0

  • Six-job matrix green — run 31639548536.
  • Raw-log signature counts: 0 / 0 on every lane across the 79-file run archive, for both Failed to start forks worker and Timeout waiting for worker to respond. Control check on the same extracted tree: an injected sample was detected, and Test Files appeared in 12 job logs, proving the logs were readable. Job conclusions and raw-log contents are reported separately on purpose — this PR exists partly because the first does not imply the second.
  • 78 focused release-pipeline tests pass; typecheck, build, release:verify, registry:validate, and verify:pack-parity all pass.

Known, not fixed here

  • stdio-slice-surface.test.ts has a failure that reproduces in isolation and on an unmodified earlier base commit of this branch. It is pre-existing and unrelated — nothing in this PR touches retrieval, context-pack, or stdio code. Flagged rather than worked around; it needs its own investigation.
  • publish-mcp-registry.yml still uses a dependency cache in a privileged job. Hardening the stable MCP path is separate follow-up.
  • verify:pack-parity and the eval regression remain single-lane despite being path-sensitive.
  • actionlint/shellcheck were unavailable locally; every run: block was bash -n syntax-checked instead, and workflow structure is covered by the YAML policy tests.

Human release controls required before any prerelease tag

  1. npm-next GitHub environment with a required reviewer and deployment restricted to approved prerelease tags. A workflow referencing a missing environment can have it auto-created without the intended protections, so no prerelease tag may be created until this exists and has been re-read.
  2. npm Trusted Publishing for @lubab/madar, bound to mohanagy/madar, workflow publish-next.yml, environment npm-next. No token, provenance required.
  3. Tag ruleset restricting creation, update, and deletion of approved prerelease tags.

Verdicts

Related tracking issue: #687. #654 remains open.

Separate the stable and prerelease release channels.

- ci.yml validates pushes to next as well as main
- release.yml is stable-only; SemVer prerelease tags skip it cleanly
- new classify-release-tag.mjs enforces approved prerelease forms
  (-beta.N, -rc.N, -next.N) and stable/prerelease channel expectations
- new publish-next.yml publishes prereleases from exact tagged commits
  under the protected npm-next environment using npm Trusted Publishing
  with provenance, proving next ancestry, tag/version/changelog match,
  and preservation of the latest dist-tag
- new verify-next-release-state.mjs backs the event, ancestry,
  unpublished, and post-publish dist-tag assertions
- release-pipeline.test.ts covers classification, guards and workflow policy
- release and contribution docs describe both channels

Refs #654.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds reusable release-validation CLIs, a protected next prerelease workflow, stable-release classification gates, expanded branch and release documentation, CI coverage for next, and tests for workflow policy and Vitest log failures.

Changes

Release pipeline

Layer / File(s) Summary
Release validation scripts
.github/scripts/*.mjs
Adds tag classification, release-state checks, qualification gating, and Vitest worker-failure scanning CLIs.
Protected prerelease publishing
.github/workflows/publish-next.yml
Adds prerelease validation, artifact creation, tokenless npm publication with provenance, registry verification, smoke tests, and prerelease notes.
Stable release gating and branch policy
.github/workflows/release.yml, .github/workflows/ci.yml, CONTRIBUTING.md, docs/release.md
Adds stable tag classification, scoped permissions, pinned actions, next CI triggers, and channel-based release procedures.
Pipeline and workflow validation
tests/unit/assert-clean-vitest-log.test.ts, tests/unit/release-pipeline.test.ts
Adds tests for scanner behavior, release assertions, qualification outcomes, workflow dataflow, permissions, artifact integrity, and publication ordering.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant GitHubActions
  participant validate
  participant publish
  participant npmRegistry
  participant post_publish
  GitHubActions->>validate: receive approved prerelease tag push
  validate->>validate: run release and package checks
  validate-->>publish: upload validated tarball and receipt
  publish->>npmRegistry: publish exact tarball with next tag and provenance
  npmRegistry-->>post_publish: expose version and dist-tags
  post_publish->>npmRegistry: verify propagation and install results
Loading

Possibly related issues

  • mohanagy/madar#687: Implements the protected npm next prerelease channel and its validation requirements.
  • mohanagy/madar#676: Covers CI publishing with provenance, immutable artifacts, registry verification, and post-publication checks.

Possibly related PRs

  • mohanagy/madar#617: Modifies release workflows and documentation for prerelease and npm next publication.
  • mohanagy/madar#628: Adds related release-validation and version-gating changes.
  • mohanagy/madar#636: Strengthens validation and publication safeguards in the release workflow.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: adding a protected npm prerelease channel for next.
Description check ✅ Passed The description thoroughly covers the change, testing, security controls, documentation, known issues, and related issue, despite not following the template headings exactly.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chore/next-prerelease-pipeline

Comment @coderabbitai help to get the list of available commands.

mohanagy and others added 3 commits August 12, 2026 20:23
- check out github.sha so workflow_dispatch input cannot reach checkout ref
- validate on Node 22, a version the CI matrix actually tests
- install a pinned npm 12.0.2 with --ignore-scripts rather than npm@latest,
  so no unpinned toolchain executes inside the privileged publish job
- assert all three properties in release-pipeline.test.ts

Refs #654.
…pin actions, remove workflow_dispatch

Remediates four findings from review of #688 / #687:

- Finding A: removed the workflow_dispatch trigger and its release_tag input.
  publish-next.yml lives only on `next`, but the repository default branch is
  `main`, so a workflow_dispatch trigger here was a dead control surface that
  could never be invoked from the Actions UI. The workflow now triggers only
  on push of an approved prerelease tag; the tag is derived solely from
  github.ref_name. verify-next-release-state.mjs's assertPublishEventAllowed
  no longer accepts workflow_dispatch either.

- Finding B: split the single privileged `publish` job into three:
  validate (contents: read, no environment) builds, tests, and packs the one
  tarball that will ever be published, records its SHA-256/size/identity/
  source commit/tag/lockfile hash/toolchain versions in a receipt, and
  uploads it as one artifact.
  publish (needs: validate, contents: read + id-token: write, environment:
  npm-next) has no checkout, no npm ci, no tests, no build, no repository
  scripts, and no dependency cache -- it downloads and independently
  re-verifies the exact artifact validate produced, then npm publish is its
  last step with nothing after it.
  post_publish (needs: [validate, publish], contents: write, no id-token)
  verifies latest was preserved, runs the clean-install smoke test, and
  creates/edits the GitHub prerelease. It is idempotent so a failure here can
  be rerun without ever republishing an immutable npm version.

- Finding C: pinned every action in publish-next.yml (checkout, setup-node,
  upload-artifact, download-artifact) to full 40-character commit SHAs with
  a readable version comment. ci.yml and release.yml are untouched.

- Finding D: publish ends at the publish command with nothing after it, and
  post_publish's create-or-edit release step makes reruns safe.

qualify:validate stays a presence-detection gate (hard fail when the script
exists, notice when it doesn't) -- this is already deterministic since it is
driven by the checked-out commit's package.json content, not a flag; once
#681 lands the script on `next`, the next tag push automatically takes the
hard-fail branch.

tests/unit/release-pipeline.test.ts: 27 -> 44 tests. Every prior test is
retained (relocated to the job it now covers); new tests assert the trigger
change, the three-job graph and its needs/permissions/environment placement,
that publish has no checkout/ci/tests/build/repo-scripts, that exactly one
live npm publish exists and is publish's final step, exact-tarball
publication, artifact SHA-256 recording/verification, latest preservation,
and that every action uses a 40-character SHA (mutable refs rejected).

Nothing published, tagged, or released. PR #688 stays a draft.

Refs #687, #688.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…present

Lead review found the qualify:validate gate was only half-deterministic:
running and hard-failing when the script exists covers one direction, but
if the script is ever removed after landing (bad refactor, lost merge, a
dependency bump rewriting package.json) the old presence-only check
silently fell back to the notice path and would publish anyway -- the exact
"permanently optional gate" the requirement forbids.

Add a second, independent signal via new .github/scripts/check-qualification-gate.mjs:
compares whether docs/qualification/ (the qualification contract, landing
with #681 in the same merge as the script) exists against whether
package.json still defines qualify:validate (the contract's validator).

  script present                    -> run: execute for real, hard-fail on
                                        non-zero exit
  script absent, contract present   -> missing: hard fail with a message
                                        naming exactly what's wrong
  script absent, contract absent    -> notice: today's ordinary state

publish-next.yml's qualification step now delegates to this script instead
of inlining the presence check. Both fail-closed directions are unit
tested directly against the script (27 -> 48 tests total in this
remediation round).

Refs #687, #688.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…-log gate

Three operational defects in PR #688's release pipeline, confirmed by
review before this change:

- validate's job outputs read `version`/`commit` from `env.PACKAGE_VERSION`
  and `env.RELEASE_COMMIT`, which are written at runtime via
  `>> "$GITHUB_ENV"` and do not reliably populate `jobs.<id>.outputs`.
  `publish` could receive empty version/commit. Fixed by giving the
  producing steps stable ids (`release-meta`, `release-commit`), writing
  version/tag/commit to `$GITHUB_OUTPUT` as well as `$GITHUB_ENV`, and
  mapping the job outputs from `steps.<id>.outputs.<name>`.

- release.yml's job-level `if: !contains(github.ref_name, '-')` made an
  unsupported prerelease tag (e.g. `v0.33.0-alpha.1`) match this
  workflow's `v*` trigger, skip silently because it contains a hyphen,
  and match no trigger in publish-next.yml either -- the tag vanished
  with no failed run anywhere. Split into a `classify` job that runs for
  every `v*` tag and fails visibly on anything classify-release-tag.mjs
  does not recognize as stable or an approved prerelease, and a `release`
  job that only runs once `classify` succeeds and reports `stable`.
  Workflow-level `contents: write` is removed; `classify` is
  `contents: read` only.

- vitest's forks pool (maxWorkers: 4, no retry configured) can respawn a
  worker mid-run when one fails to start or stops responding, and the
  run's own tally can still count the respawned worker's files as
  passed -- a green summary and exit 0 are not sufficient release
  evidence. Added .github/scripts/assert-clean-vitest-log.mjs, which
  scans raw captured log text for "Failed to start forks worker" and
  "Timeout waiting for worker to respond" independently of vitest's own
  exit code, and fails closed on a missing or unreadable log instead of
  treating it as clean. Wired into validate's test:run and test:coverage
  steps with explicit status capture (not `&&`) so the scanner always
  runs even when the test command itself fails; raw logs are uploaded as
  a bounded-retention diagnostic artifact only on failure, and publish
  never downloads it.

Adds targeted tests proving each fix: job-output dataflow (including a
synthetic fixture proving the check catches an env.*-sourced output and
a step missing its GITHUB_OUTPUT write), the classify step's real
behavior across all four tag classes (stable, the three approved
prerelease forms, and four unsupported forms), and the vitest-log
scanner (signature detection, counting, both-signatures, no
false-positives, missing/unreadable-file handling, and a control case).

No tag, npm package, or GitHub release was published or created at any
point. PR #688 stays a draft.

Refs #654, #687.
@mohanagy
mohanagy marked this pull request as ready for review August 12, 2026 20:31
@mohanagy

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

This PR is now ready for review at head f71e1c97. Please pay particular attention to the release-security surface:

  • Privilege boundaries in publish-next.ymlvalidate (contents: read) → publish (contents: read + id-token: write, environment: npm-next, no checkout and no repository code) → post_publish (contents: write, no OIDC). Confirm nothing executes project code under id-token: write, and that npm publish is genuinely the last substantive step so a failed post-publication step can never be repaired by republishing an immutable version.
  • Cross-job dataflow — job outputs must come from step outputs, never from runtime $GITHUB_ENV values, which do not reliably populate jobs.<id>.outputs.
  • Tag policy in release.yml — the classify job must run for every v* tag so an unapproved form such as v0.33.0-alpha.1 fails visibly rather than matching no workflow at all.
  • The raw-log gateassert-clean-vitest-log.mjs must fail the build on an absorbed Failed to start forks worker even when vitest exits 0 with a green summary, without suppressing a genuine test failure.
  • Action pinning — all external actions in the privileged workflow are pinned to full commit SHAs.

Note the earlier npm-cache-poisoning finding from PR #686 is deliberately honoured here: no setup-node dependency cache in any release job. Hardening the pre-existing publish-mcp-registry.yml remains separate follow-up work.

Not merging. Related tracking issue: #687. #654 remains open.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '== Current HEAD =='
git rev-parse HEAD
git status --short
printf '%s\n' '== PR metadata =='
gh pr view 688 --repo mohanagy/madar --json number,headRefOid,baseRefName,headRefName,changedFiles,url
printf '%s\n' '== Changed files =='
gh pr diff 688 --repo mohanagy/madar --name-only

printf '%s\n' '== Candidate file outlines =='
for f in \
  .github/workflows/publish-next.yml \
  .github/workflows/release.yml \
  .github/scripts/assert-clean-vitest-log.mjs \
  .github/scripts/classify-release-tag.mjs \
  .github/scripts/verify-next-release-state.mjs \
  .github/scripts/check-qualification-gate.mjs \
  tests/unit/release-pipeline.test.ts
do
  if [ -f "$f" ]; then
    echo "-- $f"
    ast-grep outline "$f" --items all || true
  else
    echo "MISSING: $f"
  fi
done

printf '%s\n' '== Workflow and script content =='
for f in \
  .github/workflows/publish-next.yml \
  .github/workflows/release.yml \
  .github/scripts/assert-clean-vitest-log.mjs \
  .github/scripts/classify-release-tag.mjs \
  .github/scripts/verify-next-release-state.mjs \
  .github/scripts/check-qualification-gate.mjs
do
  if [ -f "$f" ]; then
    echo "-- $f"
    cat -n "$f"
  fi
done

Length of output: 68726


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '== Exact-head checks =='
gh pr checks 688 --repo mohanagy/madar || true
gh api "repos/mohanagy/madar/commits/f71e1c97626b2dab06127dc8f8559e4f6a0b3fb4/check-runs?per_page=100" \
  --jq '.check_runs[] | [.name, .status, .conclusion, .details_url] | `@tsv`' || true

printf '%s\n' '== Full PR diff, excluding prose documentation =='
git diff --no-ext-diff --unified=80 "origin/next...HEAD" -- \
  .github/scripts/assert-clean-vitest-log.mjs \
  .github/scripts/check-qualification-gate.mjs \
  .github/scripts/classify-release-tag.mjs \
  .github/scripts/verify-next-release-state.mjs \
  .github/workflows/ci.yml \
  .github/workflows/publish-next.yml \
  .github/workflows/release.yml \
  tests/unit/assert-clean-vitest-log.test.ts \
  tests/unit/release-pipeline.test.ts

printf '%s\n' '== Test outlines and focused ranges =='
ast-grep outline tests/unit/assert-clean-vitest-log.test.ts --items all
sed -n '1,260p' tests/unit/assert-clean-vitest-log.test.ts
sed -n '600,940p' tests/unit/release-pipeline.test.ts

printf '%s\n' '== Release policy static summary =='
python3 - <<'PY'
from pathlib import Path
import re
p = Path(".github/workflows/publish-next.yml").read_text()
for job in ("validate", "publish", "post_publish"):
    start = p.index(f"\n  {job}:")
    candidates = [p.find(f"\n  {other}:", start + 1) for other in ("validate", "publish", "post_publish") if p.find(f"\n  {other}:", start + 1) != -1]
    block = p[start:min(candidates) if candidates else len(p)]
    print(f"\n[{job}]")
    print("checkout:", "actions/checkout@" in block)
    print("id-token:", "id-token: write" in block)
    print("cache setting:", bool(re.search(r'^\s+cache\s*:', block, re.M)))
    print("npm ci:", bool(re.search(r'\bnpm ci\b', block)))
    print("repository script:", ".github/scripts/" in block)
    publish_steps = [(m.start(), m.group(0)) for m in re.finditer(r'^\s*-\s+name:.*$', block, re.M)]
    print("steps:", [x[1].strip() for x in publish_steps])
    print("uses:", re.findall(r'uses:\s*([^\s#]+)', block))
PY

printf '%s\n' '== Package scripts that privileged-job commands could invoke =='
rg -n -A2 -B2 '"(preinstall|install|postinstall|prepare|prepublishOnly|prepack|postpack|prepublish)"|"test:run"|"test:coverage"' package.json package-lock.json

printf '%s\n' '== All action references in changed workflows =='
rg -n '^\s*uses:\s*' .github/workflows/publish-next.yml .github/workflows/release.yml .github/workflows/ci.yml

Length of output: 4439


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '== PR patch for security-relevant files =='
gh pr diff 688 --repo mohanagy/madar --patch | awk '
  /^diff --git a\/(\.github\/(scripts|workflows)\/|tests\/unit\/(assert-clean-vitest-log|release-pipeline)\.test\.ts)/ {show=1}
  /^diff --git / && !/^diff --git a\/(\.github\/(scripts|workflows)\/|tests\/unit\/(assert-clean-vitest-log|release-pipeline)\.test\.ts)/ {show=0}
  show {print}
'

printf '%s\n' '== Raw-log unit tests =='
ast-grep outline tests/unit/assert-clean-vitest-log.test.ts --items all
cat -n tests/unit/assert-clean-vitest-log.test.ts

printf '%s\n' '== Release-pipeline policy tests =='
sed -n '1,220p' tests/unit/release-pipeline.test.ts
sed -n '580,940p' tests/unit/release-pipeline.test.ts

printf '%s\n' '== Release policy static summary =='
python3 - <<'PY'
from pathlib import Path
import re

text = Path(".github/workflows/publish-next.yml").read_text()
jobs = ["validate", "publish", "post_publish"]
for index, job in enumerate(jobs):
    start = text.index(f"\n  {job}:")
    end = text.index(f"\n  {jobs[index + 1]}:", start + 1) if index + 1 < len(jobs) else len(text)
    block = text[start:end]
    names = re.findall(r'^\s{6}- name: (.+)$', block, re.M)
    uses = re.findall(r'^\s+uses:\s*([^\s#]+)', block, re.M)
    print(f"\n[{job}]")
    print("step_names=", names)
    print("uses=", uses)
    print("checkout=", "actions/checkout@" in block)
    print("id_token_write=", "id-token: write" in block)
    print("dependency_cache=", bool(re.search(r'^\s+cache\s*:', block, re.M)))
    print("npm_ci=", bool(re.search(r'\bnpm ci\b', block)))
    print("repository_script=", ".github/scripts/" in block)
    print("live_publish=", bool(re.search(r'\bnpm publish\b(?![^\n]*--dry-run)', block)))
PY

printf '%s\n' '== Lifecycle scripts and package manager configuration =='
rg -n -A2 -B2 '"(preinstall|install|postinstall|prepare|prepublishOnly|prepack|postpack|prepublish)"|"packageManager"' package.json
fd -HI -a '(^|/)\.npmrc$|npmrc' . -x sh -c 'echo "-- $1"; cat -n "$1"' sh {} \;

printf '%s\n' '== Action references and SHA format in privileged workflow =='
rg -n '^\s*uses:\s*' .github/workflows/publish-next.yml
python3 - <<'PY'
from pathlib import Path
import re, sys
for n, line in enumerate(Path(".github/workflows/publish-next.yml").read_text().splitlines(), 1):
    if "uses:" not in line:
        continue
    reference = line.split("uses:", 1)[1].split("#", 1)[0].strip()
    if not re.fullmatch(r"[^@]+@[0-9a-f]{40}", reference):
        print(f"Invalid action pin on Line {n}: {reference}")
        sys.exit(1)
print("All publish-next.yml action references use 40-character lowercase hexadecimal SHAs.")
PY

Length of output: 182416


@mohanagy Full review completed for f71e1c97626b2dab06127dc8f8559e4f6a0b3fb4.

I found no blocking issue.

Verified controls:

  • publish-next.yml separates validate, publish, and post_publish.
  • Only publish has id-token: write and the npm-next environment.
  • publish has no checkout, dependency cache, npm ci, test/build command, or repository-script invocation.
  • The only live npm publish command publishes the downloaded tarball. It is the final publish step.
  • post_publish has contents: write but no OIDC permission. A rerun cannot publish to npm.
  • validate job outputs map from step outputs. The metadata-producing steps write version, tag, and commit to $GITHUB_OUTPUT.
  • release.yml runs classify for every v* tag. Unsupported prerelease forms fail visibly. The stable release job requires successful stable classification.
  • The raw-log gate scans both test logs. It fails on either configured worker signature and also preserves a genuine Vitest failure.
  • All external actions in publish-next.yml use 40-character SHA pins with version comments.
  • The exact-head CI run 31637129196 completed successfully in all six matrix lanes.

The existing stable-path cache and mutable-action follow-ups remain outside this PR, as stated.

⚠️ Action not completed

Review rate limited.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 58 minutes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (2)
tests/unit/release-pipeline.test.ts (1)

223-253: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Disable commit signing in the git fixture.

The fixture creates commits with the caller's git configuration. If a developer sets commit.gpgsign=true globally, git commit prompts or fails, and this test errors for an environment reason rather than a code reason. Pin the signing setting alongside user.email and user.name.

♻️ Proposed fix
       execFileSync('git', ['config', 'user.email', 'madar@example.com'], { cwd: fixtureDir })
       execFileSync('git', ['config', 'user.name', 'Madar Test'], { cwd: fixtureDir })
+      execFileSync('git', ['config', 'commit.gpgsign', 'false'], { cwd: fixtureDir })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/release-pipeline.test.ts` around lines 223 - 253, Update the git
fixture setup in the “rejects a tagged commit outside next” test to configure
commit signing as disabled alongside user.email and user.name, ensuring all
fixture commits run non-interactively regardless of the caller’s global Git
configuration.
tests/unit/assert-clean-vitest-log.test.ts (1)

173-189: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the reported line number, which no test currently covers.

formatReport in .github/scripts/assert-clean-vitest-log.mjs line 104 emits <path>:<lineNumber>: <text> for each match. The script documents this as the mechanism that reports a failure precisely. No test asserts the line number, so a regression in the 1-indexed offset would pass. This control test already writes a fixture with a known signature position, so the assertion is a one-line addition.

💚 Proposed test addition
       const result = runScanner([controlPath])
       expect(result.status).not.toBe(0)
       expect(result.stderr).toContain('Failed to start forks worker')
+      // The signature sits on line 2 of the fixture; the report is 1-indexed.
+      expect(result.stderr).toContain(`${controlPath}:2: Failed to start forks worker`)
     })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/assert-clean-vitest-log.test.ts` around lines 173 - 189, Extend
the control test in assert-clean-vitest-log.test.ts to assert the reported line
number in result.stderr. Use the known line position of the injected “Failed to
start forks worker” signature in control.log and verify the formatted output
includes the expected 1-indexed path-and-line entry, covering formatReport’s
line-number behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/scripts/verify-next-release-state.mjs:
- Around line 76-90: Update gitCommitIsAncestor to handle result.error
immediately after spawnSync returns, reporting that error before inspecting
status or stderr. When constructing the failure message, use optional chaining
on result.stderr before trim, while preserving the existing status handling for
successful and non-ancestor results.

In @.github/workflows/publish-next.yml:
- Around line 535-564: Update the “Verify published version and dist-tags” retry
loop to capture the npm view command’s failure status and report it before the
fallback invocation of verify-next-release-state.mjs. Preserve the existing
retry behavior, but ensure the final diagnostic exposes the registry command
failure instead of only parsing the truncated npm-dist-tags-after.json file.

In @.github/workflows/release.yml:
- Around line 85-89: Remove the cache: npm input from the setup-node step in the
release job, and change its node-version to match the version used by the
classify job. Extend the release-pipeline policy test to verify that every
contents: write job in both release workflows omits the setup-node cache
configuration.

In `@docs/release.md`:
- Line 55: Update the qualification instructions in the release runbook to
document all three outcomes: a passing script, an absent qualification directory
recorded as unavailable, and a missing `qualify:validate` script when
`docs/qualification/` exists, which returns `missing` and blocks the release.

---

Nitpick comments:
In `@tests/unit/assert-clean-vitest-log.test.ts`:
- Around line 173-189: Extend the control test in
assert-clean-vitest-log.test.ts to assert the reported line number in
result.stderr. Use the known line position of the injected “Failed to start
forks worker” signature in control.log and verify the formatted output includes
the expected 1-indexed path-and-line entry, covering formatReport’s line-number
behavior.

In `@tests/unit/release-pipeline.test.ts`:
- Around line 223-253: Update the git fixture setup in the “rejects a tagged
commit outside next” test to configure commit signing as disabled alongside
user.email and user.name, ensuring all fixture commits run non-interactively
regardless of the caller’s global Git configuration.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 82f3102d-2e97-4fea-88f4-5ce7dec1a05c

📥 Commits

Reviewing files that changed from the base of the PR and between 3371ada and f71e1c9.

📒 Files selected for processing (11)
  • .github/scripts/assert-clean-vitest-log.mjs
  • .github/scripts/check-qualification-gate.mjs
  • .github/scripts/classify-release-tag.mjs
  • .github/scripts/verify-next-release-state.mjs
  • .github/workflows/ci.yml
  • .github/workflows/publish-next.yml
  • .github/workflows/release.yml
  • CONTRIBUTING.md
  • docs/release.md
  • tests/unit/assert-clean-vitest-log.test.ts
  • tests/unit/release-pipeline.test.ts

Comment thread .github/scripts/verify-next-release-state.mjs
Comment thread .github/workflows/publish-next.yml
Comment thread .github/workflows/release.yml Outdated
Comment thread docs/release.md
…anner change

Five findings, all valid:

- release.yml: remove `cache: npm` from the `release` job. That job now
  holds `contents: write` and runs `npm ci`/tests/build, the same
  privileged shape publish-next.yml's `validate` job documents must never
  restore a cache an untrusted PR job could poison. This PR restructured
  release.yml and moved `contents: write` onto this job, so it has to
  hold itself to the policy it asserts elsewhere.

- publish-next.yml: the post-publish propagation-wait loop truncates its
  output files every attempt; if the final attempt's `npm view` itself
  failed (registry error, network blip), the fallback JSON parse only
  ever reported "Unexpected end of JSON input" and hid the real cause.
  Capture each `npm view` failure explicitly and surface it via
  `::error::` before falling into the diagnostic verify call.

- verify-next-release-state.mjs: `spawnSync` does not throw when the
  child process itself fails to launch (e.g. `git` missing from PATH) --
  it sets `.error` and leaves `status: null`. Check `.error` explicitly
  so that case is reported as an environment problem, not misread as an
  ordinary non-ancestor verification failure.

- docs/release.md: document the qualification gate's third outcome
  (contract present, validator script missing -> intentional hard fail)
  alongside the two already documented.

- tests/unit/release-pipeline.test.ts: pin `commit.gpgsign=false` in the
  ancestor-check git fixture so it does not depend on the host's global
  git config.

No tag, npm package, or GitHub release was published or created. PR
#688 stays out of draft per the lead's instruction but nothing changes
about publication status.

Refs #654, #687.
@mohanagy
mohanagy merged commit 2e8a373 into next Aug 13, 2026
7 checks passed
mohanagy added a commit that referenced this pull request Aug 13, 2026
…691)

Makes the ordinary complete-suite commands enforce the same evidence standard the
prerelease workflow already applies.

`npm run test:run` and `npm run test:coverage` now route through a canonical
cross-platform Node runner that spawns Vitest via `process.execPath`, streams
stdout and stderr live while retaining a complete log, preserves the child's exit
code and signal, and fails when a known forks-worker start signature appears --
even when Vitest itself exits zero with a green summary.

The signature list is imported from `.github/scripts/assert-clean-vitest-log.mjs`
rather than duplicated, so the ordinary suite, protected CI, stable release, and
prerelease release all enforce one policy. The prerelease guard added by #688 is
unchanged.

Termination handling forwards at most one signal through a one-shot latch, with
the child's process group isolated on POSIX so a single Ctrl-C cannot reach the
child twice and escalate a graceful stop into a forced kill.

No retries, no timeout inflation, no worker-count reduction, and no test
quarantine. `vitest.config.ts` is untouched at `maxWorkers: 4` with no `retry`.
One real-signal test is POSIX-gated because the mechanism has no observable form
on Windows for any implementation; the platform-independent latch test covers the
guarantee on all six lanes and is disclosed in the pull request.

Refs #690.
#654 remains open.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant