chore(release): add protected npm next prerelease channel - #688
Conversation
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.
📝 WalkthroughWalkthroughThe PR adds reusable release-validation CLIs, a protected ChangesRelease pipeline
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
Possibly related issues
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Comment |
- 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.
|
@coderabbitai full review This PR is now ready for review at head
Note the earlier npm-cache-poisoning finding from PR #686 is deliberately honoured here: no Not merging. Related tracking issue: #687. #654 remains open. |
🧩 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
doneLength 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.ymlLength 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.")
PYLength of output: 182416
I found no blocking issue. Verified controls:
The existing stable-path cache and mutable-action follow-ups remain outside this PR, as stated.
|
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (2)
tests/unit/release-pipeline.test.ts (1)
223-253: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDisable commit signing in the git fixture.
The fixture creates commits with the caller's git configuration. If a developer sets
commit.gpgsign=trueglobally,git commitprompts or fails, and this test errors for an environment reason rather than a code reason. Pin the signing setting alongsideuser.emailanduser.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 winAssert the reported line number, which no test currently covers.
formatReportin.github/scripts/assert-clean-vitest-log.mjsline 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
📒 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.ymlCONTRIBUTING.mddocs/release.mdtests/unit/assert-clean-vitest-log.test.tstests/unit/release-pipeline.test.ts
…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.
…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.
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— checkout, pinned npm bootstrap, full validation, and exactly one tarball built vianpm pack --jsonwith 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, nonpm 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. Thennpm publish ./<tarball>.tgz --tag next --access public --provenanceas its final substantive step — nothing runs after it.post_publish— verifies propagation, thatnextmoved, and thatlatestis unchanged; clean-installs the exact version and@nextand exercises the installed binary against a fresh workspace; creates or edits the GitHub prerelease idempotently.Because
publishends at the publish command, a failed verification or release step can never be "repaired" by republishing an immutable npm version —post_publishis rerunnable on its own.Cross-job metadata uses explicit step outputs
validate.outputspreviously readversion,commit, andtagfrom${{ env.* }}, but those were written at runtime via$GITHUB_ENV, which does not reliably populatejobs.<id>.outputs—publishcould have received empty values. All job outputs now map fromsteps.<id>.outputs.*via the stable IDsrelease-metaandrelease-commit. Tests assert the dataflow, not merely that anoutputs:block exists.Stable tag classification is now operational
release.ymltriggered onv*and skipped its job for any tag containing-. An unapproved form such asv0.33.0-alpha.1therefore matched the stable trigger, skipped, matched nopublish-next.ymltrigger, and produced no result at all — the classifier rejected it only when invoked directly, and nothing invoked it.release.ymlnow has two jobs:classify(contents: read) runs for everyv*tag and fails loudly on anything that is neither stable nor an approved prerelease form;release(contents: write) runs only whenneeds.classify.outputs.channel == 'stable'. Workflow-levelcontents: writeis removed.Verified across all eight forms:
v0.33.0→stable;-beta.1/-rc.1/-next.1→prereleasewith 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.tssetsmaxWorkers: 4with noretry. The forks pool respawns workers inside a single run, so files that loggedFailed to start forks workercan still be tallied as passed — exit code 0 plus a green summary was not sufficient evidence..github/scripts/assert-clean-vitest-log.mjsscans the captured raw output of bothtest:runandtest:coverage. The step capturesPIPESTATUS[0]so the real command status survivestee, 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 artifactif: failure()with bounded retention;publishnever downloads it. No retries were added and no timeouts raised.Qualification gate becomes mandatory on its own
check-qualification-gate.mjshas three outcomes: script present → run it; script absent anddocs/qualification/present → hard fail; neither present → record a notice. Since the contract directory and thequalify:validatescript both land with #681, the gate self-activates on the first tag that includes that merge — no flag, no date, no follow-up PR. Themissingoutcome exists so a vanished validator can never silently downgrade to a skip.Security properties
workflow_dispatch(the workflow lives only onnext, and GitHub requires the default branch for manual dispatch, so that path was inoperable), nopull_request, no branch push.id-token: writeandcontents: write. No workflow-levelid-token.setup-nodedependency cache in any release job, honouring the cache-poisoning finding from chore: synchronize next with the current stable baseline #686 — includingrelease.yml'sreleasejob, which now holdscontents: write.persist-credentials: false; checkout usesgithub.sha, so no untrusted input reachesactions/checkout'sref:.actions/*repositories:checkout3d3c42e5…(v7.0.1),setup-node82076278…(v7.0.0),upload-artifact043fb46d…(v7.0.1),download-artifact3e5f45b2…(v8.0.1).12.0.2, installed with--ignore-scripts, so no unpinned toolchain executes in a credentialed job...CodeRabbit review — five findings, all remediated
cache: npmremoved fromrelease.yml'sreleasejob, which holdscontents: write.npm viewfailures captured so the operator sees the registry error rather than a downstream JSON parse error.spawnSync.errorchecked, so a missinggitis reported as such.docs/release.md.commit.gpgsign=falsepinned in a git fixture.All review threads resolved.
Validation at
e6cad3b031639548536.Failed to start forks workerandTimeout waiting for worker to respond. Control check on the same extracted tree: an injected sample was detected, andTest Filesappeared 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.release:verify,registry:validate, andverify:pack-parityall pass.Known, not fixed here
stdio-slice-surface.test.tshas 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.ymlstill uses a dependency cache in a privileged job. Hardening the stable MCP path is separate follow-up.verify:pack-parityand the eval regression remain single-lane despite being path-sensitive.actionlint/shellcheckwere unavailable locally; everyrun:block wasbash -nsyntax-checked instead, and workflow structure is covered by the YAML policy tests.Human release controls required before any prerelease tag
npm-nextGitHub 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.@lubab/madar, bound tomohanagy/madar, workflowpublish-next.yml, environmentnpm-next. No token, provenance required.Verdicts
next: pending maintainer sign-off and the human controls above.#656 → #657 → #658 → #659; shipping after only [P0][Graph PR B] Add deterministic semantic multigraph storage and artifact v2 #657 or [P0][Graph PR C] Account for every normalized graph candidate and retain unresolved facts #658 would release artifact, integrity, and answerability behavior in a deliberately incomplete state.Related tracking issue: #687. #654 remains open.