diff --git a/.github/workflows/release-modelparams.yml b/.github/workflows/release-modelparams.yml index fdeb19c..8beb0e8 100644 --- a/.github/workflows/release-modelparams.yml +++ b/.github/workflows/release-modelparams.yml @@ -7,10 +7,16 @@ name: Release modelparams # • any other catalog change → PATCH # • no semantic catalog change → skipped # -# Provenance: signed via npm OIDC. Configure a trusted publisher for the -# `modelparams` package on npmjs.com (Settings → Trusted Publishers → GitHub -# Actions → org=mnfst, repo=modelparams.dev, workflow=release-modelparams.yml). -# Once configured, `NPM_TOKEN` is no longer needed. +# Tag-only release: the workflow never pushes to the (protected) main branch. +# The published version is the latest `modelparams@x.y.z` git tag, bumped by the +# classifier; the first release seeds from packages/modelparams/package.json. +# The version bump is applied in-CI for the tarball but is not committed back, so +# the git tags are the source of truth for the published version. +# +# Auth: npm OIDC trusted publishing (no token). Requires npm >= 11.5.1, which +# ships with Node 24. Configure the trusted publisher for the `modelparams` +# package on npmjs.com (Settings → Trusted Publishers → GitHub Actions → +# org=mnfst, repo=modelparams.dev, workflow=release-modelparams.yml). on: push: @@ -41,18 +47,19 @@ jobs: name: Build and publish runs-on: ubuntu-latest permissions: - contents: write # tag + auto-bump commit + contents: write # create release + tag id-token: write # npm OIDC provenance steps: - name: Check out repo uses: actions/checkout@v4 with: fetch-depth: 0 # full history for diff-based version bump + fetch-tags: true # release tags drive the next version - name: Set up Node uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "24" # npm >= 11.5.1 for OIDC trusted publishing cache: "npm" registry-url: "https://registry.npmjs.org" @@ -82,56 +89,26 @@ jobs: run: npx tsx packages/modelparams/scripts/compute-version.ts env: BASE_REF: "HEAD~1" - - - name: Apply forced level (workflow_dispatch) - if: github.event_name == 'workflow_dispatch' && inputs.force_level != '' - id: force - run: | - CURRENT=$(node -p "require('./packages/modelparams/package.json').version") - case "${{ inputs.force_level }}" in - major) NEXT=$(node -e "const [M]=process.argv[1].split('.');console.log(\`\${+M+1}.0.0\`)" "$CURRENT");; - patch) NEXT=$(node -e "const [M,m,p]=process.argv[1].split('.');console.log(\`\${M}.\${m}.\${+p+1}\`)" "$CURRENT");; - esac - echo "level=${{ inputs.force_level }}" >> "$GITHUB_OUTPUT" - echo "next=$NEXT" >> "$GITHUB_OUTPUT" - - - name: Resolve effective version - id: resolved - run: | - LEVEL="${{ steps.force.outputs.level || steps.bump.outputs.level }}" - NEXT="${{ steps.force.outputs.next || steps.bump.outputs.next }}" - echo "level=$LEVEL" >> "$GITHUB_OUTPUT" - echo "next=$NEXT" >> "$GITHUB_OUTPUT" + FORCE_LEVEL: ${{ inputs.force_level }} - name: Skip publish (no semantic change) - if: steps.resolved.outputs.next == '' - run: echo "::notice::No semantic catalog change since HEAD~1 — nothing to publish." + if: steps.bump.outputs.next == '' + run: echo "::notice::No semantic catalog change since the last release — nothing to publish." - - name: Bump package.json + commit + tag - if: steps.resolved.outputs.next != '' + - name: Set package version (no commit) + if: steps.bump.outputs.next != '' env: - NEXT: ${{ steps.resolved.outputs.next }} - run: | - cd packages/modelparams - npm version "$NEXT" --no-git-tag-version - cd ../.. - git config user.name "modelparams-bot" - git config user.email "bot@modelparams.dev" - git add packages/modelparams/package.json packages/modelparams/src/generated - git commit -m "release: modelparams@$NEXT" - git tag "modelparams@$NEXT" - git push origin HEAD:main --follow-tags + NEXT: ${{ steps.bump.outputs.next }} + run: npm version "$NEXT" --no-git-tag-version --workspace=modelparams - name: Publish to npm - if: steps.resolved.outputs.next != '' + if: steps.bump.outputs.next != '' run: npm publish --workspace=modelparams --provenance --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: Create GitHub release - if: steps.resolved.outputs.next != '' + - name: Create GitHub release + tag + if: steps.bump.outputs.next != '' uses: softprops/action-gh-release@v2 with: - tag_name: "modelparams@${{ steps.resolved.outputs.next }}" - name: "modelparams@${{ steps.resolved.outputs.next }}" + tag_name: "modelparams@${{ steps.bump.outputs.next }}" + name: "modelparams@${{ steps.bump.outputs.next }}" generate_release_notes: true diff --git a/packages/modelparams/scripts/compute-version.ts b/packages/modelparams/scripts/compute-version.ts index c114357..e0271c0 100644 --- a/packages/modelparams/scripts/compute-version.ts +++ b/packages/modelparams/scripts/compute-version.ts @@ -1,13 +1,15 @@ import fs from "node:fs"; import path from "node:path"; +import { execFileSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { loadAllModels } from "../../../src/data/load.js"; import { loadModelsAtRef, refExists } from "../../../src/data/git-baseline.js"; import { findRemovedParams } from "../../../src/data/removals.js"; -import { canonicalCatalog, decideBump, bumpVersion } from "./lib/version.js"; +import { canonicalCatalog, decideBump, bumpVersion, type BumpLevel } from "./lib/version.js"; const here = path.dirname(fileURLToPath(import.meta.url)); const PKG_DIR = path.resolve(here, ".."); +const TAG_PREFIX = "modelparams@"; function resolveBaseRef(): string | null { const candidates = [ @@ -30,6 +32,53 @@ function readPackageVersion(): string { return pkg.version; } +function versionKey(v: string): number { + const [a, b, c] = v.split(".").map(Number); + return (a ?? 0) * 1_000_000 + (b ?? 0) * 1_000 + (c ?? 0); +} + +/** Latest published version, taken from `modelparams@x.y.z` git tags. null if none. */ +function readLatestTagVersion(): string | null { + let out = ""; + try { + out = execFileSync("git", ["tag", "--list", `${TAG_PREFIX}*`], { encoding: "utf8" }); + } catch { + return null; + } + const versions = out + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.startsWith(TAG_PREFIX)) + .map((line) => line.slice(TAG_PREFIX.length)) + .filter((v) => /^\d+\.\d+\.\d+$/.test(v)) + .sort((a, b) => versionKey(b) - versionKey(a)); + return versions[0] ?? null; +} + +function readForcedLevel(): BumpLevel | null { + const forced = (process.env.FORCE_LEVEL ?? "").trim(); + if (forced === "major" || forced === "patch") return forced; + return null; +} + +/** Auto-detect the bump level from the catalog diff against the base ref. */ +async function detectLevel( + current: Awaited>["models"], +): Promise { + const baseRef = resolveBaseRef(); + if (!baseRef) { + // No base ref to diff against — treat any run as a patch. + return "patch"; + } + const base = await loadModelsAtRef(baseRef); + const removals = findRemovedParams(base, current); + return decideBump({ + baseCanon: canonicalCatalog(base), + currentCanon: canonicalCatalog(current), + hasRemovals: removals.length > 0, + }); +} + function emit(name: string, value: string): void { const target = process.env.GITHUB_OUTPUT; const line = `${name}=${value}\n`; @@ -47,32 +96,30 @@ async function main(): Promise { process.exit(1); } - const baseRef = resolveBaseRef(); - if (!baseRef) { - const next = bumpVersion(readPackageVersion(), "patch"); - console.error("No base ref available — treating as patch release."); - emit("level", "patch"); + const latestTag = readLatestTagVersion(); + const level = readForcedLevel() ?? (await detectLevel(current)); + + // First release ever (no `modelparams@*` tag): publish the version that + // `package.json` already declares, so v1 matches the committed seed value. + if (latestTag === null) { + const next = readPackageVersion(); + console.error(`First release — publishing package.json version ${next}.`); + emit("level", level ?? "patch"); emit("next", next); return; } - const base = await loadModelsAtRef(baseRef); - const removals = findRemovedParams(base, current); - const level = decideBump({ - baseCanon: canonicalCatalog(base), - currentCanon: canonicalCatalog(current), - hasRemovals: removals.length > 0, - }); - + // Subsequent releases bump from the latest published tag (the repo's + // package.json is not written back, so tags are the source of truth). if (level === null) { - console.error(`No semantic catalog changes vs ${baseRef} — skipping publish.`); + console.error(`No semantic catalog change since ${TAG_PREFIX}${latestTag} — skipping publish.`); emit("level", ""); emit("next", ""); return; } - const next = bumpVersion(readPackageVersion(), level); - console.error(`Catalog change vs ${baseRef}: bump ${level} → ${next}`); + const next = bumpVersion(latestTag, level); + console.error(`Catalog change: bump ${level} from ${latestTag} → ${next}.`); emit("level", level); emit("next", next); }