From faa5a86a83d94d7fe2c226662ec54e8e126537b5 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 27 Aug 2026 15:04:41 -0700 Subject: [PATCH] Add automated npm publishing --- .github/workflows/publish.yml | 86 ++++++++++++++++ README.md | 7 ++ package.json | 1 + scripts/bump-version.test.ts | 53 ++++++++++ scripts/bump-version.ts | 182 ++++++++++++++++++++++++++++++++++ 5 files changed, 329 insertions(+) create mode 100644 .github/workflows/publish.yml create mode 100644 scripts/bump-version.test.ts create mode 100644 scripts/bump-version.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..2d5cc76 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,86 @@ +name: Publish npm Package + +on: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: publish-sim-skills + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22.20.0' + + - name: Install dependencies + run: bun install --frozen-lockfile --ignore-scripts + + - name: Verify npm authentication + env: + NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} + run: bun pm whoami + + - name: Resolve release version + run: | + bun run bump:version + bun run sync:plugin-version + + - name: Validate release + run: | + bun run check:skills + bun run test + bun run type-check + bun run lint:check + bun run build + + - name: Smoke-test package + run: | + set -euo pipefail + PACKAGE_DIR="$RUNNER_TEMP/sim-skills-package" + PACKAGE_PATH="$RUNNER_TEMP/sim-skills.tgz" + bun pm pack --ignore-scripts --filename "$PACKAGE_PATH" --quiet + mkdir -p "$PACKAGE_DIR" + tar -xzf "$PACKAGE_PATH" -C "$PACKAGE_DIR" + test -x "$PACKAGE_DIR/package/dist/index.js" + test -f "$PACKAGE_DIR/package/.codex-plugin/plugin.json" + test -f "$PACKAGE_DIR/package/.claude-plugin/plugin.json" + test -f "$PACKAGE_DIR/package/skills/build-workflow/SKILL.md" + test -f "$PACKAGE_DIR/package/skills/run-workflow/SKILL.md" + test -f "$PACKAGE_DIR/package/skills/deploy-workflow/SKILL.md" + test -f "$PACKAGE_DIR/package/skills/table/SKILL.md" + test -f "$PACKAGE_DIR/package/skills/knowledge-base/SKILL.md" + + - name: Verify version is unpublished + run: | + VERSION="$(bun -p "require('./package.json').version")" + if bun pm view "sim-skills@$VERSION" version > /dev/null 2>&1; then + echo "sim-skills@$VERSION is already published" >&2 + exit 1 + fi + + - name: Publish package + env: + NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} + run: bun publish --access public --tag latest --no-save + + - name: Summarize release + run: | + VERSION="$(bun -p "require('./package.json').version")" + echo "Published sim-skills@$VERSION with the 'latest' tag." diff --git a/README.md b/README.md index d2d55de..1820e6f 100644 --- a/README.md +++ b/README.md @@ -87,3 +87,10 @@ Direct installs through `bunx sim-skills` install the selected skills without th - `knowledge-base` — ingest and index documents, configure connectors and tags, and verify retrieval. The skills assume the `sim` CLI is installed and authenticated. They never store or print API keys. + +## Publishing + +Every push to `main` publishes a new stable `sim-skills` version to npm. The release workflow uses +the manifest version for the first release or an explicitly higher release, and otherwise increments +the highest published stable patch version. The repository must provide an `NPM_TOKEN` Actions +secret with permission to publish `sim-skills`. diff --git a/package.json b/package.json index a49e87b..43c2ab3 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "format:check": "biome format .", "test": "vitest run", "check:skills": "bun run scripts/validate-skills.ts", + "bump:version": "bun run scripts/bump-version.ts", "sync:plugin-version": "bun run scripts/sync-plugin-version.ts", "prepublishOnly": "bun run check:skills && bun run test && bun run build" }, diff --git a/scripts/bump-version.test.ts b/scripts/bump-version.test.ts new file mode 100644 index 0000000..3543107 --- /dev/null +++ b/scripts/bump-version.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { + isMissingPackageError, + resolveNextStableVersion, +} from "./bump-version"; + +describe("resolveNextStableVersion", () => { + it("uses the manifest for the first publish", () => { + expect(resolveNextStableVersion("0.1.0", [])).toBe("0.1.0"); + }); + + it("increments the highest published stable patch and ignores prereleases", () => { + expect( + resolveNextStableVersion("0.1.0", [ + "0.1.0", + "0.1.1-preview.2", + "0.0.9", + "0.1.1-dev.3", + ]), + ).toBe("0.1.1"); + }); + + it("preserves an explicitly higher manifest version", () => { + expect(resolveNextStableVersion("1.0.0", ["0.8.2", "0.9.0"])).toBe("1.0.0"); + }); + + it("advances from the registry when the manifest is stale", () => { + expect(resolveNextStableVersion("0.1.0", ["1.2.3", "1.3.0"])).toBe("1.3.1"); + }); + + it("fails on invalid manifest versions", () => { + expect(() => + resolveNextStableVersion("0.2.0-preview.1", ["0.1.0"]), + ).toThrow( + "Manifest version must be a stable X.Y.Z version, got '0.2.0-preview.1'", + ); + }); +}); + +describe("isMissingPackageError", () => { + it("matches only a missing sim-skills package", () => { + expect( + isMissingPackageError(` +404 Not Found: https://registry.npmjs.org/sim-skills + + - 'sim-skills@latest' does not exist in this registry +`), + ).toBe(true); + expect(isMissingPackageError("ConnectionRefused: request failed")).toBe( + false, + ); + }); +}); diff --git a/scripts/bump-version.ts b/scripts/bump-version.ts new file mode 100644 index 0000000..aa46719 --- /dev/null +++ b/scripts/bump-version.ts @@ -0,0 +1,182 @@ +#!/usr/bin/env bun + +import { execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const packageManifestPath = resolve(packageRoot, "package.json"); +const packageName = "sim-skills"; +const stableVersionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; + +interface PackageManifest extends Record { + name: string; + version: string; +} + +interface ParsedVersion { + major: number; + minor: number; + patch: number; +} + +function parseStableVersion(version: string, source: string): ParsedVersion { + const match = stableVersionPattern.exec(version); + if (!match) + throw new Error( + `${source} must be a stable X.Y.Z version, got '${version}'`, + ); + + const parsed = { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + }; + if (!Number.isSafeInteger(parsed.major)) { + throw new Error( + `${source} major version exceeds JavaScript's safe integer range`, + ); + } + if (!Number.isSafeInteger(parsed.minor)) { + throw new Error( + `${source} minor version exceeds JavaScript's safe integer range`, + ); + } + if (!Number.isSafeInteger(parsed.patch)) { + throw new Error( + `${source} patch version exceeds JavaScript's safe integer range`, + ); + } + return parsed; +} + +function compareVersions(left: ParsedVersion, right: ParsedVersion): number { + if (left.major !== right.major) return left.major - right.major; + if (left.minor !== right.minor) return left.minor - right.minor; + return left.patch - right.patch; +} + +function formatVersion(version: ParsedVersion): string { + return `${version.major}.${version.minor}.${version.patch}`; +} + +/** Resolves the next stable package version from the manifest and npm registry. */ +export function resolveNextStableVersion( + manifestVersion: string, + publishedVersions: readonly string[], +): string { + const manifest = parseStableVersion(manifestVersion, "Manifest version"); + const stableVersions = publishedVersions.flatMap((version) => + stableVersionPattern.test(version) + ? [parseStableVersion(version, "Published version")] + : [], + ); + + if (stableVersions.length === 0) return formatVersion(manifest); + + const latestPublished = stableVersions.reduce((latest, version) => + compareVersions(version, latest) > 0 ? version : latest, + ); + if (compareVersions(manifest, latestPublished) > 0) + return formatVersion(manifest); + if (latestPublished.patch === Number.MAX_SAFE_INTEGER) { + throw new Error("Published patch version cannot be incremented safely"); + } + return formatVersion({ + ...latestPublished, + patch: latestPublished.patch + 1, + }); +} + +export function isMissingPackageError(stderr: string): boolean { + return ( + stderr.includes("404 Not Found:") && + stderr.includes(`'${packageName}@latest' does not exist in this registry`) + ); +} + +function commandStderr(error: unknown): string | undefined { + if (typeof error !== "object" || error === null || !("stderr" in error)) + return undefined; + const stderr = error.stderr; + if (typeof stderr === "string") return stderr; + if (Buffer.isBuffer(stderr)) return stderr.toString("utf8"); + return undefined; +} + +function publishedVersions(): string[] { + let output: string; + try { + output = execFileSync( + "bun", + ["pm", "view", packageName, "versions", "--json"], + { + cwd: packageRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }, + ); + } catch (error) { + const stderr = commandStderr(error); + if (stderr && isMissingPackageError(stderr)) return []; + throw new Error( + `Could not read published versions for '${packageName}' from npm`, + { + cause: error, + }, + ); + } + + let metadata: unknown; + try { + metadata = JSON.parse(output); + } catch (error) { + throw new Error(`npm returned invalid JSON for '${packageName}'`, { + cause: error, + }); + } + if ( + !Array.isArray(metadata) || + metadata.some((version) => typeof version !== "string") + ) { + throw new Error( + `npm did not return a string version array for '${packageName}'`, + ); + } + return metadata; +} + +function readManifest(): PackageManifest { + const metadata: unknown = JSON.parse( + readFileSync(packageManifestPath, "utf8"), + ); + if (metadata === null || typeof metadata !== "object") { + throw new Error("package.json must contain a JSON object"); + } + + const manifest = metadata as Record; + if (manifest.name !== packageName) { + throw new Error( + `package.json must describe '${packageName}', got '${String(manifest.name)}'`, + ); + } + if (typeof manifest.version !== "string") { + throw new Error("package.json is missing a string version"); + } + return manifest as PackageManifest; +} + +function main(): void { + const manifest = readManifest(); + const currentVersion = manifest.version; + const nextVersion = resolveNextStableVersion( + currentVersion, + publishedVersions(), + ); + manifest.version = nextVersion; + writeFileSync(packageManifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + process.stdout.write(`${packageName}: ${currentVersion} -> ${nextVersion}\n`); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) main();