Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions .github/workflows/align-dist-tags.yml
Original file line number Diff line number Diff line change
@@ -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}"
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading