From d8f24277d8f9e433ce346d66452386e5cb23afa3 Mon Sep 17 00:00:00 2001 From: Feng Qian Date: Mon, 7 Sep 2026 22:54:53 -0700 Subject: [PATCH 1/5] chore: release quality-core 0.3.1, quality-tools 0.3.3, quality-ui 0.1.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publishes the run-evidence work: the opaque `artifacts` pointer on observation records, the `host` transport seam and its `local-reports` provider, the Explorer evidence-file route, and the three config-schema CLI commands. Patch bumps, counted from what npm actually has — 0.3.0, 0.3.2 and 0.1.0 respectively. The repo manifests sat on those same numbers with this work on top, so they were not the base; `quality-core` 0.2.0 was set here in August and never published, and nothing counts from it. Records that rule in AGENTS.md, since it was not written down and the version this branch shipped under was briefly wrong because of it: patch by default even when a release adds API, minor or major only when the maintainer says so, and always counted from `npm view`. `approvedIncrease` moves to 0.3.3 with the bump — the gate matches it against `package.json` and rejects an approval recorded for another version. Its byte counts are re-measured for the bumped artifact (46290 -> 46291 packed): the same approval Feng Qian gave for these features, re-measured, not a new one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NhxpEed9pbcjT7MLtnDbiH --- AGENTS.md | 16 ++++++++++++++++ packages/core/package.json | 2 +- packages/quality-tools/package-size.json | 4 ++-- packages/quality-tools/package.json | 2 +- packages/ui/package.json | 2 +- 5 files changed, 21 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f7f5111..9166be7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,22 @@ Quality evaluates evidence independently of the systems that produce it. Do not introduce dependencies from the engine into evidence producers or from open-source packages into the Shiplight platform monorepo. +## Version bumps + +Bump the **patch** version by default: if `0.3.0` is published, the next release +is `0.3.1`. This holds even when the release adds API. Use a minor or major only +when the maintainer says so for that release. + +Count from the **published** version, never from what `package.json` currently +says. Between releases the manifest sits on the last published number with +unreleased work on top, so it is not the base — and a number that was set in the +repo but never published (`quality-core` `0.2.0`) is a dead end that nothing +counts from. Read the base with `npm view @shiplightai/ version`. + +A bump moves the size approval with it: `approvedIncrease.version` in +`packages/quality-tools/package-size.json` must equal the new `package.json` +version, or the gate rejects the recorded approval and the build fails. + ## Release size gate The `quality-tools` release artifact may grow by at most 1% in both packed and diff --git a/packages/core/package.json b/packages/core/package.json index e7910fc..3b56e00 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@shiplightai/quality-core", - "version": "0.3.0", + "version": "0.3.1", "type": "module", "description": "Deterministic analysis engine for evidence-backed software quality maps.", "license": "MIT", diff --git a/packages/quality-tools/package-size.json b/packages/quality-tools/package-size.json index 06cc600..65c745f 100644 --- a/packages/quality-tools/package-size.json +++ b/packages/quality-tools/package-size.json @@ -1,8 +1,8 @@ { "maxIncreasePercent": 1, "approvedIncrease": { - "version": "0.3.2", - "packedBytes": 46290, + "version": "0.3.3", + "packedBytes": 46291, "unpackedBytes": 168548, "approvedBy": "Feng Qian", "reason": "new features" diff --git a/packages/quality-tools/package.json b/packages/quality-tools/package.json index ea1b283..d98e72a 100644 --- a/packages/quality-tools/package.json +++ b/packages/quality-tools/package.json @@ -1,6 +1,6 @@ { "name": "@shiplightai/quality-tools", - "version": "0.3.2", + "version": "0.3.3", "type": "module", "description": "Quality graph analysis and canonical workflow-observation tools.", "license": "MIT", diff --git a/packages/ui/package.json b/packages/ui/package.json index 735b3b3..931ddd4 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@shiplightai/quality-ui", - "version": "0.1.0", + "version": "0.1.1", "description": "Shared React/Mantine presentation for Quality Explorer and Shiplight Quality Center.", "license": "MIT", "type": "module", From da36ce553817c62f8a90249aac597c254092db5d Mon Sep 17 00:00:00 2001 From: Feng Qian Date: Tue, 8 Sep 2026 16:43:15 -0700 Subject: [PATCH 2/5] ci: publish packages through npm trusted publishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publishing was manual, which meant a long-lived npm token or an interactive 2FA prompt in a maintainer's terminal — and npm is retiring the former. This adds a manually dispatched workflow that authenticates to npm by OIDC. The repository holds no npm credential; pnpm 11.1.2 exchanges the job's GitHub id-token for a short-lived one at publish time, which is why the job declares `id-token: write` and asserts the token endpoint is present before spending time on the gates. The workflow does not compute versions, unlike the equivalents in shiplight-cli. It publishes what package.json says on main and skips any package already at that version on npm. A workflow that bumped versions itself would move `approvedIncrease.version` in package-size.json away from the number a human approved, and only a human may write that approval. Packages publish in dependency order: quality-map, quality-core, then the two that consume it. pnpm rewrites the `workspace:` ranges while packing, so publishing a consumer first would leave a tarball on npm that nobody can install until the next step finishes. Each package still needs a trusted publisher configured on npmjs.com naming this repository and this workflow file. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NhxpEed9pbcjT7MLtnDbiH --- .github/workflows/publish.yml | 145 ++++++++++++++++++++++++++++++++++ AGENTS.md | 20 +++++ 2 files changed, 165 insertions(+) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..414ae2d --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,145 @@ +name: Publish packages + +# Manual release. npm authenticates this workflow by OIDC (trusted publishing), +# so there is no NPM_TOKEN anywhere in this repository and no maintainer 2FA +# prompt in the loop. Each package must have a trusted publisher configured on +# npmjs.com pointing at `ShiplightAI/quality` and this workflow file name; see +# docs/how-to/publish-a-release.md. +# +# Unlike shiplight-cli's publish workflows, this one does NOT compute or push a +# version bump. Versions are decided in a reviewed `chore: release` commit on +# main, because `packages/quality-tools/package-size.json` records a +# version-specific `approvedIncrease` that only a human maintainer may write. A +# workflow that bumped the version by itself would invalidate that approval on +# every run. +on: + workflow_dispatch: + inputs: + dry_run: + description: "Run every gate and `pnpm publish --dry-run`, but publish nothing" + type: boolean + default: false + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + # REQUIRED. npm authenticates this workflow by OIDC and there is no + # NPM_TOKEN to fall back on, so without an id-token every publish step + # fails with a bare ENEEDAUTH at the very end of the run. + id-token: write + steps: + # Releases come from main only. The version being published is whatever + # the reviewed release commit put in package.json. + - uses: actions/checkout@v7 + with: + ref: main + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: pnpm + # Writes the registry into .npmrc. No NODE_AUTH_TOKEN: pnpm mints a + # short-lived npm credential from the job's OIDC token instead. + registry-url: https://registry.npmjs.org + + # Catches the commonest misconfiguration — a job without `id-token: write` + # — before the gates below spend their time, instead of letting it surface + # as an unexplained auth failure after the whole suite has run. + - name: Assert the OIDC id-token is available to this job + run: | + set -euo pipefail + if [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then + printf '::%s::%s\n' error "No OIDC id-token in this job — trusted publishing needs 'permissions: id-token: write'" + exit 1 + fi + echo "OIDC id-token endpoint present. Releasing $(git rev-parse HEAD)." + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # The same three gates CI runs on every pull request, repeated here + # because a release publishes main as it stands now, not as it stood when + # the last pull request was green. + - name: Build + run: pnpm build + + - name: Typecheck + run: pnpm typecheck + + - name: Test + run: pnpm test + + - name: Check quality-tools package size + run: pnpm --filter @shiplightai/quality-tools check:size + + - name: Check quality-ui package size + run: pnpm --filter @shiplightai/quality-ui check:size + + # Dependency order: quality-map, then quality-core, then the two packages + # that consume it. pnpm rewrites the `workspace:` ranges to real versions + # while packing, so publishing a consumer before its dependency would put + # a tarball on npm that nobody can install until the next step finishes. + # + # A package whose repo version is already on npm is skipped, not failed: + # most releases move some of these four and leave the rest alone, and a + # re-run after a mid-release failure has to be able to finish the job. + - name: Publish + id: publish + env: + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + + published=() + skipped=() + + for dir in packages/quality-map packages/core packages/quality-tools packages/ui; do + name=$(node -p "require('./$dir/package.json').name") + version=$(node -p "require('./$dir/package.json').version") + + if npm view "$name@$version" version > /dev/null 2>&1; then + echo "Skipping $name@$version — already on npm." + skipped+=("$name@$version") + continue + fi + + # --no-git-checks: actions/checkout leaves a detached HEAD, which + # pnpm's publish-branch check rejects. The branch is pinned by the + # `ref: main` checkout above instead. + if [ "$DRY_RUN" = "true" ]; then + echo "Dry run: $name@$version" + (cd "$dir" && pnpm publish --dry-run --no-git-checks --access public) + else + echo "Publishing $name@$version" + (cd "$dir" && pnpm publish --no-git-checks --access public) + fi + published+=("$name@$version") + done + + if [ ${#published[@]} -eq 0 ]; then + printf '::%s::%s\n' error "Every package version on main is already on npm — nothing to publish. Land a release commit first." + exit 1 + fi + + { + echo "published=${published[*]}" + echo "skipped=${skipped[*]-}" + } >> "$GITHUB_OUTPUT" + + - name: Step summary + if: always() + run: | + { + echo "## Release from \`$(git rev-parse --short HEAD)\`" + echo "" + echo "- Dry run: \`${{ inputs.dry_run }}\`" + echo "- Published: \`${{ steps.publish.outputs.published }}\`" + echo "- Already on npm, skipped: \`${{ steps.publish.outputs.skipped }}\`" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/AGENTS.md b/AGENTS.md index 9166be7..c4421b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,26 @@ A bump moves the size approval with it: `approvedIncrease.version` in `packages/quality-tools/package-size.json` must equal the new `package.json` version, or the gate rejects the recorded approval and the build fails. +## Publishing + +Releases go out through the **Publish packages** workflow +(`.github/workflows/publish.yml`), run manually from the Actions tab against +`main`. npm authenticates it by OIDC (trusted publishing), so this repository +holds no npm token and a maintainer's 2FA never enters the loop. Each package +carries a trusted publisher on npmjs.com naming `ShiplightAI/quality` and that +workflow file; renaming the file breaks publishing until the npm side is +updated to match. + +The workflow does not decide versions. It publishes exactly what `package.json` +says on `main` and skips any package already at that version on npm, so the +release is whatever the reviewed `chore: release` commit landed. This is +deliberate: a workflow that bumped versions itself would move +`approvedIncrease.version` away from the number a human approved. + +Publishing by hand is the fallback, not the path. It needs a long-lived npm +token or an interactive 2FA prompt, and it publishes a working tree rather than +a reviewed commit. + ## Release size gate The `quality-tools` release artifact may grow by at most 1% in both packed and From 088044b08b92ee6bda90cfcfc3c3f87a9e3005dd Mon Sep 17 00:00:00 2001 From: Feng Qian Date: Tue, 8 Sep 2026 20:35:00 -0700 Subject: [PATCH 3/5] ci: pin the publish actions and fix its doc pointer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header pointed at docs/how-to/publish-a-release.md, which does not exist — the procedure went into AGENTS.md instead, because docs/how-to is written for people using Quality, not for people releasing it. Point at the section that exists. Pin the three actions to commit SHAs. ci.yml uses floating major tags and stays that way; this job is different because it holds `id-token: write`, the one credential in the repository that can push code to every consumer, so a force-moved upstream tag is worth designing out. The SHAs are what `@v7`, `@v6` and `@v7` resolved to today. The step summary rendered empty backticks when a run died before the publish loop wrote its outputs, which reads as "published nothing" rather than "never got that far". It now names what happened, and takes the released commit from the preflight step instead of re-running git in a workspace that may not have been checked out. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NhxpEed9pbcjT7MLtnDbiH --- .github/workflows/publish.yml | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 414ae2d..15a61ab 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -3,8 +3,8 @@ name: Publish packages # Manual release. npm authenticates this workflow by OIDC (trusted publishing), # so there is no NPM_TOKEN anywhere in this repository and no maintainer 2FA # prompt in the loop. Each package must have a trusted publisher configured on -# npmjs.com pointing at `ShiplightAI/quality` and this workflow file name; see -# docs/how-to/publish-a-release.md. +# npmjs.com pointing at `ShiplightAI/quality` and this workflow file name. +# `AGENTS.md` ("Publishing") carries the procedure and the version rule. # # Unlike shiplight-cli's publish workflows, this one does NOT compute or push a # version bump. Versions are decided in a reviewed `chore: release` commit on @@ -32,16 +32,19 @@ jobs: # NPM_TOKEN to fall back on, so without an id-token every publish step # fails with a bare ENEEDAUTH at the very end of the run. id-token: write + # Every action below is SHA-pinned, unlike ci.yml: a tag can be force-moved + # at the upstream org, and this is the only job in the repository whose + # credential can push code to every consumer. Bump them deliberately. steps: # Releases come from main only. The version being published is whatever # the reviewed release commit put in package.json. - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: main - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 cache: pnpm @@ -53,13 +56,16 @@ jobs: # — before the gates below spend their time, instead of letting it surface # as an unexplained auth failure after the whole suite has run. - name: Assert the OIDC id-token is available to this job + id: preflight run: | set -euo pipefail if [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then printf '::%s::%s\n' error "No OIDC id-token in this job — trusted publishing needs 'permissions: id-token: write'" exit 1 fi - echo "OIDC id-token endpoint present. Releasing $(git rev-parse HEAD)." + sha=$(git rev-parse HEAD) + echo "OIDC id-token endpoint present. Releasing $sha." + echo "sha=$sha" >> "$GITHUB_OUTPUT" - name: Install dependencies run: pnpm install --frozen-lockfile @@ -134,12 +140,12 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Step summary - if: always() + if: always() && steps.preflight.outcome == 'success' run: | { - echo "## Release from \`$(git rev-parse --short HEAD)\`" + echo "## Release from \`${{ steps.preflight.outputs.sha }}\`" echo "" echo "- Dry run: \`${{ inputs.dry_run }}\`" - echo "- Published: \`${{ steps.publish.outputs.published }}\`" - echo "- Already on npm, skipped: \`${{ steps.publish.outputs.skipped }}\`" + echo "- Published: \`${{ steps.publish.outputs.published || '(none — the run ended before publishing)' }}\`" + echo "- Already on npm, skipped: \`${{ steps.publish.outputs.skipped || '(none)' }}\`" } >> "$GITHUB_STEP_SUMMARY" From 1df2afb299d3600c10a818b8215c62848ea63e65 Mon Sep 17 00:00:00 2001 From: Feng Qian Date: Tue, 8 Sep 2026 20:40:18 -0700 Subject: [PATCH 4/5] ci: serialize publish dispatches The already-on-npm skip is a check, not a lock. Two simultaneous dispatches would both read "not published yet" and then race; the loser dies mid-release with a 403 having published some of the four packages and not others. No cancel-in-progress: interrupting a publish part-way leaves npm in exactly the state the group is meant to prevent, so the second dispatch waits instead. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NhxpEed9pbcjT7MLtnDbiH --- .github/workflows/publish.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 15a61ab..013fc45 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -20,6 +20,14 @@ on: type: boolean default: false +# One release at a time. The already-on-npm skip below is a check, not a lock: +# two simultaneous dispatches would both read "not published yet" and then race, +# and the loser dies mid-release with a 403 having published some packages but +# not others. No cancel-in-progress — interrupting a publish is worse than +# making the second dispatch wait. +concurrency: + group: publish-npm + permissions: contents: read From 4c607c9824cb52a4f9c04e2e98dd97ec5774151d Mon Sep 17 00:00:00 2001 From: Feng Qian Date: Tue, 8 Sep 2026 20:46:29 -0700 Subject: [PATCH 5/5] ci: let a dry run prove the credential without a pending release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dry run exists to exercise the OIDC exchange and the gates. Whether a release commit has landed is unrelated, so "nothing to publish" is now a notice on a dry run and an error only on a real one. The step summary reads its values from the environment instead of having them substituted into the script text. Scoped package names cannot carry shell metacharacters, so nothing changes today; it removes the class for whoever edits the loop next. Also records why there is no --provenance flag. pnpm's OIDC path already attaches an attestation: it consults the flag only when one was passed, and otherwise asks npm whether the package is public and signs when it is. Both packages are public and so is this repository, so the flag adds no attestation — it only turns a graceful "visibility unreadable, warn and publish" into a failed release. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NhxpEed9pbcjT7MLtnDbiH --- .github/workflows/publish.yml | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 013fc45..64e5b57 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -127,6 +127,14 @@ jobs: # --no-git-checks: actions/checkout leaves a detached HEAD, which # pnpm's publish-branch check rejects. The branch is pinned by the # `ref: main` checkout above instead. + # + # No --provenance. pnpm's OIDC path already attaches a provenance + # attestation on its own: it only consults the flag when one was + # passed, and otherwise asks npm whether the package is public and + # signs when it is (both are, and this repository is public). The + # flag would only change the failure mode — without it pnpm warns + # and publishes when visibility cannot be read, with it the publish + # dies. if [ "$DRY_RUN" = "true" ]; then echo "Dry run: $name@$version" (cd "$dir" && pnpm publish --dry-run --no-git-checks --access public) @@ -138,8 +146,12 @@ jobs: done if [ ${#published[@]} -eq 0 ]; then - printf '::%s::%s\n' error "Every package version on main is already on npm — nothing to publish. Land a release commit first." - exit 1 + if [ "$DRY_RUN" = "true" ]; then + printf '::%s::%s\n' notice "Every package version on main is already on npm. The gates and the npm credential were still exercised." + else + printf '::%s::%s\n' error "Every package version on main is already on npm — nothing to publish. Land a release commit first." + exit 1 + fi fi { @@ -149,11 +161,16 @@ jobs: - name: Step summary if: always() && steps.preflight.outcome == 'success' + env: + RELEASE_SHA: ${{ steps.preflight.outputs.sha }} + DRY_RUN: ${{ inputs.dry_run }} + PUBLISHED: ${{ steps.publish.outputs.published }} + SKIPPED: ${{ steps.publish.outputs.skipped }} run: | { - echo "## Release from \`${{ steps.preflight.outputs.sha }}\`" + echo "## Release from \`$RELEASE_SHA\`" echo "" - echo "- Dry run: \`${{ inputs.dry_run }}\`" - echo "- Published: \`${{ steps.publish.outputs.published || '(none — the run ended before publishing)' }}\`" - echo "- Already on npm, skipped: \`${{ steps.publish.outputs.skipped || '(none)' }}\`" + echo "- Dry run: \`$DRY_RUN\`" + echo "- Published: \`${PUBLISHED:-(none — the run ended before publishing)}\`" + echo "- Already on npm, skipped: \`${SKIPPED:-(none)}\`" } >> "$GITHUB_STEP_SUMMARY"