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
73 changes: 25 additions & 48 deletions .github/workflows/release-modelparams.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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
81 changes: 64 additions & 17 deletions packages/modelparams/scripts/compute-version.ts
Original file line number Diff line number Diff line change
@@ -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 = [
Expand All @@ -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<ReturnType<typeof loadAllModels>>["models"],
): Promise<BumpLevel | null> {
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`;
Expand All @@ -47,32 +96,30 @@ async function main(): Promise<void> {
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);
}
Expand Down
Loading