From 053f441b7ffbf6764f69699cf6e46319f4ffbd55 Mon Sep 17 00:00:00 2001 From: "open-swe[bot]" Date: Fri, 7 Aug 2026 20:11:04 -0400 Subject: [PATCH 1/3] feat(release): add a dispatchable workflow to repair @next drift without an OTP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit publish.yml aligns @next after every publish since #391, but that cannot help a release published BEFORE that fix — 0.44.3 shipped with next still on 0.44.2 — or an alignment that failed transiently. Doing it by hand is worse than it sounds: `npm dist-tag add` on a 2FA-protected account fails with EOTP and demands a live authenticator code, so it cannot be scripted or delegated, and in practice it does not get done. The NPM_TOKEN secret is 2FA-exempt (that is what automation tokens are for), so CI can do unattended what an interactive human cannot. Deliberately narrow: it moves a dist-tag and cannot publish. It refuses a prerelease target, since under the alias policy a prerelease holding @next would silently install for every user. And unlike publish.yml's verify step — which must never fail a job whose package is already published — this one FAILS when the tag did not move: a green manual run that changed nothing is the exact outcome this exists to prevent. Every run: block was executed against a stubbed `npm view` across all four branches (aligned / not-moved / unreadable-response / prerelease) rather than reviewed by eye. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/align-dist-tags.yml | 90 +++++++++++++++++++++++++++ CHANGELOG.md | 3 + 2 files changed, 93 insertions(+) create mode 100644 .github/workflows/align-dist-tags.yml diff --git a/.github/workflows/align-dist-tags.yml b/.github/workflows/align-dist-tags.yml new file mode 100644 index 0000000000..e6c036f083 --- /dev/null +++ b/.github/workflows/align-dist-tags.yml @@ -0,0 +1,90 @@ +name: Align dist-tags + +# Manual remedy for the `@next == @latest` alias invariant (CLAUDE.md). +# +# publish.yml aligns @next automatically after every publish (DIST-TAG-01, #391). This +# workflow exists for the cases that path cannot cover: a release published BEFORE that +# fix landed, or an alignment that failed transiently. Doing it by hand needs +# `npm dist-tag add`, which on a 2FA-protected account prompts EOTP for a one-time +# password — so it cannot be scripted, and in practice it does not get done. +# +# The NPM_TOKEN secret is 2FA-exempt (that is what automation tokens are for), so CI can +# do what an interactive human cannot do unattended. +# +# Scope is deliberately narrow: this moves a dist-tag and nothing else. It cannot publish. + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to point @next at (default: whatever @latest currently is)' + required: false + type: string + +permissions: + contents: read + +jobs: + align: + name: Point @next at @latest + runs-on: ubuntu-latest + environment: npm-publish + timeout-minutes: 5 + steps: + - name: Resolve target version + id: target + run: | + LATEST=$(npm view @nforma.ai/nforma dist-tags.latest 2>/dev/null || echo '') + TARGET="${{ inputs.version }}" + [ -z "$TARGET" ] && TARGET="$LATEST" + if [ -z "$TARGET" ]; then + echo "::error::could not resolve a target version (npm view returned nothing and no input was given)" + exit 1 + fi + # A prerelease must never hold a dist-tag under the alias policy — @next == @latest + # means anyone on @next would silently install it. + case "$TARGET" in + *-*) echo "::error::refusing to tag prerelease version ${TARGET} (alias policy: @next == @latest)"; exit 1 ;; + esac + echo "target=${TARGET}" >> "$GITHUB_OUTPUT" + echo "latest=${LATEST}" >> "$GITHUB_OUTPUT" + echo "Target: ${TARGET} (current @latest: ${LATEST:-unknown})" + + - name: Align @next + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + TARGET="${{ steps.target.outputs.target }}" + if [ -z "${NPM_TOKEN}" ]; then + echo "::error::NPM_TOKEN secret is not set — this workflow cannot authenticate" + exit 1 + fi + # npm does NOT read a bare NPM_TOKEN env var; auth comes from .npmrc. That exact + # mistake is what made every release's alignment silently 401 (DIST-TAG-01). + printf '//registry.npmjs.org/:_authToken=${NPM_TOKEN}\n' > .npmrc + trap 'rm -f .npmrc' EXIT + npm dist-tag add "@nforma.ai/nforma@${TARGET}" next + + - name: Verify the invariant holds + run: | + TARGET="${{ steps.target.outputs.target }}" + TAGS=$(npm view @nforma.ai/nforma dist-tags --json 2>/dev/null || echo '{}') + echo "$TAGS" + LATEST=$(printf '%s' "$TAGS" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).latest || ''") + NEXT=$(printf '%s' "$TAGS" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).next || ''") + # Unlike publish.yml's verify — which must never fail a job whose package is + # already published — a manual alignment that did not align is a failure. A + # green run that changed nothing is the outcome worth preventing here. + if [ -z "$LATEST" ] || [ -z "$NEXT" ]; then + echo "::error::could not read dist-tags back — alignment unverified" + exit 1 + fi + if [ "$NEXT" != "$TARGET" ]; then + echo "::error::@next is ${NEXT}, expected ${TARGET} — alignment did not take" + exit 1 + fi + if [ "$LATEST" != "$NEXT" ]; then + echo "::error::invariant still broken — latest=${LATEST} next=${NEXT}" + exit 1 + fi + echo "invariant OK — next == latest == ${LATEST}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d6772d130..29b7cd5ac6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added +- feat(release): **`Align dist-tags` — a manually dispatchable workflow that repairs `@next` drift without a one-time password.** `publish.yml` aligns `@next` automatically after every publish since #391, but that cannot help a release published *before* that fix, or an alignment that failed transiently. Doing it by hand needs `npm dist-tag add`, which on a 2FA-protected account fails with `EOTP` and demands an authenticator code — so it cannot be scripted, and in practice it simply does not get done (0.44.3 shipped with `next` still on 0.44.2). The `NPM_TOKEN` secret is 2FA-exempt, which is what automation tokens are for, so CI can do unattended what an interactive human cannot. Scope is deliberately narrow: it moves a dist-tag and cannot publish. It refuses a prerelease target (a prerelease holding `@next` would silently install for everyone under the alias policy), and — unlike `publish.yml`'s verify step, which must never fail a job whose package is already out — this one **fails** if the tag did not move, because a green manual run that changed nothing is the exact outcome worth preventing. Each `run:` block was executed against a stubbed `npm view` across all four branches (aligned / not-moved / unreadable / prerelease) rather than reviewed by eye. + ### Added - feat(quorum): **timeout records now say which config produced the window (I5).** `resolveTtfbTimeout` / `resolveInterChunkCeiling` each compute a `source` (`per-slot` / `default` / `idle-budget` / `legacy-stall`) and `recordTelemetry` was dropping both it and the resolved values, so a TIMEOUT in `quorum-rounds-*.jsonl` could be seen but never attributed — the number alone never answers "did *my* config do this?", since today's per-slot value can be tomorrow's default. Records now carry `ttfb_timeout_ms`, `ttfb_source`, `inter_chunk_ceiling_ms` and `inter_chunk_source`, always present (null when unknown) so the schema stays stable for analysis. Passed as a trailing options object rather than a 15th positional parameter. 4 tests drive the **real child** and read the actual log, so the fields are proven to survive the whole dispatch path; mutation-proven. Accepted as improvement I5 by the 2026-08-07 quorum. From 26a0d701b09a05874a10dd0f042a6a2cf7b1cbb4 Mon Sep 17 00:00:00 2001 From: "open-swe[bot]" Date: Fri, 7 Aug 2026 20:11:25 -0400 Subject: [PATCH 2/3] fix(changelog): merge the duplicate ### Added under [Unreleased] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third time this session: I keep PREPENDING a new section instead of merging into the existing one, and #394 had already added ### Added on main. The repo's changelog-sections gate catches it every time, which is the system working — but the fix belongs before the commit, not after. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29b7cd5ac6..f8ebb6dc33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,8 +28,6 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - feat(release): **`Align dist-tags` — a manually dispatchable workflow that repairs `@next` drift without a one-time password.** `publish.yml` aligns `@next` automatically after every publish since #391, but that cannot help a release published *before* that fix, or an alignment that failed transiently. Doing it by hand needs `npm dist-tag add`, which on a 2FA-protected account fails with `EOTP` and demands an authenticator code — so it cannot be scripted, and in practice it simply does not get done (0.44.3 shipped with `next` still on 0.44.2). The `NPM_TOKEN` secret is 2FA-exempt, which is what automation tokens are for, so CI can do unattended what an interactive human cannot. Scope is deliberately narrow: it moves a dist-tag and cannot publish. It refuses a prerelease target (a prerelease holding `@next` would silently install for everyone under the alias policy), and — unlike `publish.yml`'s verify step, which must never fail a job whose package is already out — this one **fails** if the tag did not move, because a green manual run that changed nothing is the exact outcome worth preventing. Each `run:` block was executed against a stubbed `npm view` across all four branches (aligned / not-moved / unreadable / prerelease) rather than reviewed by eye. - -### Added - feat(quorum): **timeout records now say which config produced the window (I5).** `resolveTtfbTimeout` / `resolveInterChunkCeiling` each compute a `source` (`per-slot` / `default` / `idle-budget` / `legacy-stall`) and `recordTelemetry` was dropping both it and the resolved values, so a TIMEOUT in `quorum-rounds-*.jsonl` could be seen but never attributed — the number alone never answers "did *my* config do this?", since today's per-slot value can be tomorrow's default. Records now carry `ttfb_timeout_ms`, `ttfb_source`, `inter_chunk_ceiling_ms` and `inter_chunk_source`, always present (null when unknown) so the schema stays stable for analysis. Passed as a trailing options object rather than a 15th positional parameter. 4 tests drive the **real child** and read the actual log, so the fields are proven to survive the whole dispatch path; mutation-proven. Accepted as improvement I5 by the 2026-08-07 quorum. ### Fixed From b5f092c957b7fea4d9edbe0f55d8b225fc7a9c28 Mon Sep 17 00:00:00 2001 From: "open-swe[bot]" Date: Fri, 7 Aug 2026 20:24:06 -0400 Subject: [PATCH 3/3] fix(release): close a script-injection hole in the dist-tag workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding, valid and serious. `TARGET="${{ inputs.version }}"` interpolated an attacker-controlled workflow_dispatch input directly into a shell script. GitHub expands ${{ }} BEFORE bash parses the line, so a crafted input could close the quote and run arbitrary commands with the job's token and NPM_TOKEN in scope. Every ${{ }} now lives in `env:` and is referenced as a shell variable, so the value is never parsed as script. Applied to the derived steps.target.outputs.target too, not just the raw input — it carries the same taint. Added a whitelist on top of the escaping: only a bare stable semver (x.y.z) may reach `npm dist-tag`. Anything else is rejected outright rather than relying on quoting alone, which also subsumes the prerelease refusal. Writing the injection tests then caught a residual my first fix missed: `grep` is LINE-oriented, so $'0.0.1\ntouch x' satisfied an anchored pattern on its first line while still carrying a newline — enough to inject extra keys into $GITHUB_OUTPUT. The check now strips the legal alphabet and requires nothing to remain, which rejects any embedded newline. 8 cases executed against the real step: quote-break, backtick, newline+command, newline+output-key, whitespace padding, prerelease, valid, and empty. A filesystem canary confirms no command executed in any injection case. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/align-dist-tags.yml | 31 ++++++++++++++++++++------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/.github/workflows/align-dist-tags.yml b/.github/workflows/align-dist-tags.yml index e6c036f083..c9e9511088 100644 --- a/.github/workflows/align-dist-tags.yml +++ b/.github/workflows/align-dist-tags.yml @@ -33,19 +33,33 @@ jobs: steps: - name: Resolve target version id: target + # `inputs.version` is attacker-controlled on a workflow_dispatch. GitHub expands + # ${{ }} BEFORE bash parses the script, so interpolating it inline would let a + # crafted input close the quote and run arbitrary commands. Passing it through + # `env:` keeps it a shell VALUE that is never parsed as script. + env: + INPUT_VERSION: ${{ inputs.version }} run: | LATEST=$(npm view @nforma.ai/nforma dist-tags.latest 2>/dev/null || echo '') - TARGET="${{ inputs.version }}" + TARGET="${INPUT_VERSION}" [ -z "$TARGET" ] && TARGET="$LATEST" if [ -z "$TARGET" ]; then echo "::error::could not resolve a target version (npm view returned nothing and no input was given)" exit 1 fi - # A prerelease must never hold a dist-tag under the alias policy — @next == @latest - # means anyone on @next would silently install it. - case "$TARGET" in - *-*) echo "::error::refusing to tag prerelease version ${TARGET} (alias policy: @next == @latest)"; exit 1 ;; - esac + # Whitelist the shape as well as escaping it: only a bare stable semver may + # reach `npm dist-tag`. This rejects anything with shell metacharacters + # outright rather than relying on quoting alone, and doubles as the + # prerelease refusal — under the alias policy @next == @latest, a prerelease + # holding @next would silently install for every user. + # Whole-string check, not line-wise: `grep` is line-oriented, so a value like + # $'0.0.1\ntouch x' passes an anchored pattern on its FIRST line while still + # carrying a newline — which then injects extra keys into $GITHUB_OUTPUT. + # Stripping the legal alphabet and requiring nothing remains catches that. + if [ -n "${TARGET//[0-9.]/}" ] || ! printf '%s' "$TARGET" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::refusing target '${TARGET}' — expected a bare stable semver (x.y.z); prereleases and any other shape are rejected" + exit 1 + fi echo "target=${TARGET}" >> "$GITHUB_OUTPUT" echo "latest=${LATEST}" >> "$GITHUB_OUTPUT" echo "Target: ${TARGET} (current @latest: ${LATEST:-unknown})" @@ -53,8 +67,8 @@ jobs: - name: Align @next env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + TARGET: ${{ steps.target.outputs.target }} run: | - TARGET="${{ steps.target.outputs.target }}" if [ -z "${NPM_TOKEN}" ]; then echo "::error::NPM_TOKEN secret is not set — this workflow cannot authenticate" exit 1 @@ -66,8 +80,9 @@ jobs: npm dist-tag add "@nforma.ai/nforma@${TARGET}" next - name: Verify the invariant holds + env: + TARGET: ${{ steps.target.outputs.target }} run: | - TARGET="${{ steps.target.outputs.target }}" TAGS=$(npm view @nforma.ai/nforma dist-tags --json 2>/dev/null || echo '{}') echo "$TAGS" LATEST=$(printf '%s' "$TAGS" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).latest || ''")