diff --git a/.github/workflows/align-dist-tags.yml b/.github/workflows/align-dist-tags.yml new file mode 100644 index 0000000000..c9e9511088 --- /dev/null +++ b/.github/workflows/align-dist-tags.yml @@ -0,0 +1,105 @@ +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 + # `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="${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 + # 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})" + + - name: Align @next + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + TARGET: ${{ steps.target.outputs.target }} + run: | + 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 + env: + TARGET: ${{ steps.target.outputs.target }} + run: | + 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..f8ebb6dc33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ 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. - 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