From 80808b464e11e588c2204515eb715e61442211a5 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 18:56:49 -0800 Subject: [PATCH] ci: publish sim-skills to npm on merge to main Adds a publish workflow that auto-bumps the patch version, syncs both plugin manifests, verifies the packed tarball, publishes via npm trusted publishing, and tags a GitHub Release. CI now pins action SHAs and fails on plugin manifest version drift. --- .github/workflows/ci.yml | 22 ++-- .github/workflows/publish.yml | 159 ++++++++++++++++++++++++ README.md | 11 +- package.json | 2 + scripts/resolve-release-version.test.ts | 69 ++++++++++ scripts/resolve-release-version.ts | 121 ++++++++++++++++++ scripts/sync-plugin-version.test.ts | 35 +++++- scripts/sync-plugin-version.ts | 44 ++++++- scripts/verify-package.sh | 16 +++ 9 files changed, 459 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/publish.yml create mode 100644 scripts/resolve-release-version.test.ts create mode 100644 scripts/resolve-release-version.ts create mode 100755 scripts/verify-package.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 318caca..601999f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,16 +14,21 @@ jobs: timeout-minutes: 10 steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + with: + persist-credentials: false - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.3.14 - name: Install dependencies run: bun install --frozen-lockfile --ignore-scripts + - name: Check plugin manifest versions + run: bun run check:plugin-version + - name: Validate skills run: bun run check:skills @@ -38,14 +43,5 @@ jobs: - name: Verify package contents 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/sync-workspaces/SKILL.md" + bun pm pack --ignore-scripts --filename "$RUNNER_TEMP/sim-skills.tgz" --quiet + scripts/verify-package.sh "$RUNNER_TEMP/sim-skills.tgz" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..0a2a370 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,159 @@ +name: Publish + +on: + push: + branches: [main] + paths: + - 'package.json' + - 'bun.lock' + - 'src/**' + - 'skills/**' + - 'scripts/**' + - '.claude-plugin/**' + - '.codex-plugin/**' + - 'tsconfig*' + # A change to how the package is built or published has to republish it. + - '.github/workflows/publish.yml' + +permissions: + contents: read + +concurrency: + group: publish-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + name: build + runs-on: ubuntu-latest + timeout-minutes: 15 + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + with: + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.3.14 + + - name: Install dependencies + run: bun install --frozen-lockfile --ignore-scripts + + # Keeps package.json's version when it is ahead of npm (or nothing is + # published yet); otherwise takes the highest published patch plus one. + - name: Auto-bump package version + run: bun run release:version + + # Both plugin manifests ship inside the tarball, so they must carry the + # version being published, not the committed one. + - name: Sync plugin manifest versions + run: bun run sync:plugin-version + + - name: Read release version + id: version + run: echo "version=$(bun -p "require('./package.json').version")" >> "$GITHUB_OUTPUT" + + - name: Validate skills + run: bun run check:skills + + - name: Run tests + run: bun run test + + - name: Type-check package + run: bun run type-check + + - name: Build package + run: bun run build + + # The publish job ships this exact tarball, so what was verified is what + # ships. + - name: Pack package + run: bun pm pack --ignore-scripts --filename "$RUNNER_TEMP/package.tgz" --quiet + + - name: Verify package contents + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + scripts/verify-package.sh "$RUNNER_TEMP/package.tgz" + for manifest in package/package.json package/.claude-plugin/plugin.json package/.codex-plugin/plugin.json; do + PACKED_VERSION="$(tar -xzOf "$RUNNER_TEMP/package.tgz" "$manifest" | bun -e 'console.log(JSON.parse(await Bun.stdin.text()).version)')" + if [ "$PACKED_VERSION" != "$VERSION" ]; then + echo "$manifest has version '$PACKED_VERSION', expected '$VERSION'." >&2 + exit 1 + fi + done + + - name: Verify version is unpublished + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + if bun pm view "sim-skills@$VERSION" version > /dev/null 2>&1; then + echo "sim-skills@$VERSION is already published. The automatic version bump did not produce a unique release." >&2 + exit 1 + fi + + - name: Upload package + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: package + path: ${{ runner.temp }}/package.tgz + if-no-files-found: error + retention-days: 1 + + # npm trusted publishing trades this job's OIDC token for a short-lived npm + # credential, so no npm token is stored anywhere. Only this job can mint one, + # and it runs no repository code: it publishes the tarball the build job + # verified. npm runs no lifecycle scripts for a tarball, and provenance is + # attached automatically. + publish: + name: publish + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + id-token: write + steps: + # Node 24 bundles npm 11.5.1+, which trusted publishing requires. + - name: Setup Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v6 + with: + node-version: '24' + + - name: Download package + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: package + path: ${{ runner.temp }} + + - name: Publish to npm + run: npm publish "$RUNNER_TEMP/package.tgz" --access public --tag latest + + - name: Summarize release + env: + VERSION: ${{ needs.build.outputs.version }} + run: echo "Published sim-skills@$VERSION with the 'latest' tag." + + release: + name: release + needs: [build, publish] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + steps: + - name: Create tag and GitHub Release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: v${{ needs.build.outputs.version }} + run: | + if gh release view "$TAG" > /dev/null 2>&1; then + echo "Release $TAG already exists." + exit 0 + fi + gh release create "$TAG" --target "$GITHUB_SHA" --title "$TAG" --generate-notes --latest diff --git a/README.md b/README.md index 2a148b0..e0eaf67 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,8 @@ bunx skills add simstudioai/skills \ --yes ``` -Once published, the npm package provides a shorter interactive installer: +The [`sim-skills`](https://www.npmjs.com/package/sim-skills) npm package wraps the same installer +with the Sim skills preselected: ```bash bunx sim-skills @@ -96,3 +97,11 @@ The skills assume the `sim` CLI is installed and authenticated. They never store Workspace sync guidance requires a CLI and server exposing the v2 import-preview and fork sync commands. The skill checks command availability before changing resources. + +## Releasing + +Every merge to `main` that changes the package publishes `sim-skills` to npm with the `latest` tag +and creates a matching `v` GitHub Release. The publish workflow bumps the patch version +automatically, so a routine change needs no version edit. For a minor or major release, set the new +version in `package.json` and run `bun run sync:plugin-version` in the same pull request; CI fails if +the plugin manifests drift from `package.json`. diff --git a/package.json b/package.json index 3efcc2b..121a830 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "test": "vitest run", "check:skills": "bun run scripts/validate-skills.ts", "sync:plugin-version": "bun run scripts/sync-plugin-version.ts", + "check:plugin-version": "bun run scripts/sync-plugin-version.ts --check", + "release:version": "bun run scripts/resolve-release-version.ts", "prepublishOnly": "bun run check:skills && bun run test && bun run build" }, "files": [ diff --git a/scripts/resolve-release-version.test.ts b/scripts/resolve-release-version.test.ts new file mode 100644 index 0000000..da4f8de --- /dev/null +++ b/scripts/resolve-release-version.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { + fetchPublishedVersions, + resolveReleaseVersion, +} from "./resolve-release-version"; + +describe("resolveReleaseVersion", () => { + it("keeps the manifest version when nothing stable is published", () => { + expect(resolveReleaseVersion("0.1.1", [])).toBe("0.1.1"); + expect(resolveReleaseVersion("0.1.1", ["0.1.0-preview.3"])).toBe("0.1.1"); + }); + + it("keeps a manifest version ahead of the published line", () => { + expect(resolveReleaseVersion("0.3.0", ["0.2.9", "0.2.10"])).toBe("0.3.0"); + }); + + it("bumps the highest published patch when the manifest is behind or equal", () => { + expect(resolveReleaseVersion("0.1.1", ["0.1.1", "0.2.4", "0.2.10"])).toBe( + "0.2.11", + ); + expect(resolveReleaseVersion("0.2.10", ["0.2.10"])).toBe("0.2.11"); + }); + + it("ignores prereleases above the stable line", () => { + expect( + resolveReleaseVersion("0.2.0", [ + "0.2.0", + "0.3.0-preview.1", + "1.0.0-rc.1", + ]), + ).toBe("0.2.1"); + }); + + it("rejects a non-stable manifest version", () => { + expect(() => resolveReleaseVersion("0.2.0-preview.1", [])).toThrow( + "stable X.Y.Z", + ); + }); +}); + +describe("fetchPublishedVersions", () => { + const respond = (status: number, body: unknown): typeof fetch => + (async () => + new Response(JSON.stringify(body), { status })) as typeof fetch; + + it("treats a package npm has never seen as unpublished", async () => { + await expect( + fetchPublishedVersions( + "sim-skills", + respond(404, { error: "Not found" }), + ), + ).resolves.toEqual([]); + }); + + it("fails instead of guessing when the registry errors", async () => { + await expect( + fetchPublishedVersions("sim-skills", respond(503, {})), + ).rejects.toThrow("503"); + }); + + it("returns the published version keys", async () => { + await expect( + fetchPublishedVersions( + "sim-skills", + respond(200, { versions: { "0.1.1": {}, "0.1.2": {} } }), + ), + ).resolves.toEqual(["0.1.1", "0.1.2"]); + }); +}); diff --git a/scripts/resolve-release-version.ts b/scripts/resolve-release-version.ts new file mode 100644 index 0000000..0304ce4 --- /dev/null +++ b/scripts/resolve-release-version.ts @@ -0,0 +1,121 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const defaultPackageRoot = resolve( + dirname(fileURLToPath(import.meta.url)), + "..", +); +const registryUrl = "https://registry.npmjs.org"; +const stableVersionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; + +type StableVersion = readonly [major: number, minor: number, patch: number]; + +function parseStableVersion(version: string): StableVersion | undefined { + const match = stableVersionPattern.exec(version); + if (!match) return undefined; + const parts = [Number(match[1]), Number(match[2]), Number(match[3])] as const; + if (!parts.every(Number.isSafeInteger)) { + throw new Error(`${version}: version exceeds the safe integer range`); + } + return parts; +} + +function compareVersions(left: StableVersion, right: StableVersion): number { + for (let index = 0; index < 3; index++) { + const difference = left[index] - right[index]; + if (difference !== 0) return difference; + } + return 0; +} + +/** + * Resolves the version the next automated publish should use. + * + * With no published stable version, the manifest version is the first + * release. Otherwise a manifest version above every published stable version + * is an explicit minor or major bump and is kept; anything else becomes the + * highest published stable version plus one patch. Prereleases never advance + * the stable line. + */ +export function resolveReleaseVersion( + manifestVersion: string, + publishedVersions: readonly string[], +): string { + const manifest = parseStableVersion(manifestVersion); + if (!manifest) { + throw new Error( + `package.json version must be a stable X.Y.Z version, got '${manifestVersion}'`, + ); + } + + let latest: StableVersion | undefined; + for (const published of publishedVersions) { + const version = parseStableVersion(published); + if (version && (!latest || compareVersions(version, latest) > 0)) { + latest = version; + } + } + + if (!latest || compareVersions(manifest, latest) > 0) return manifestVersion; + const [major, minor, patch] = latest; + return `${major}.${minor}.${patch + 1}`; +} + +/** Lists every version of `name` on npm; an unpublished package has none. */ +export async function fetchPublishedVersions( + name: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const response = await fetchImpl( + `${registryUrl}/${encodeURIComponent(name)}`, + { headers: { accept: "application/vnd.npm.install-v1+json" } }, + ); + if (response.status === 404) return []; + if (!response.ok) { + throw new Error( + `npm registry returned ${response.status} for '${name}'; refusing to guess the release version`, + ); + } + const metadata: unknown = await response.json(); + if ( + typeof metadata !== "object" || + metadata === null || + !("versions" in metadata) || + typeof metadata.versions !== "object" || + metadata.versions === null + ) { + throw new Error(`npm registry returned no versions object for '${name}'`); + } + return Object.keys(metadata.versions); +} + +async function main(packageRoot = defaultPackageRoot): Promise { + const manifestPath = resolve(packageRoot, "package.json"); + const manifest: unknown = JSON.parse(await readFile(manifestPath, "utf8")); + if ( + typeof manifest !== "object" || + manifest === null || + !("name" in manifest) || + typeof manifest.name !== "string" || + !("version" in manifest) || + typeof manifest.version !== "string" + ) { + throw new Error(`${manifestPath}: name and version are required`); + } + + const current = manifest.version; + const next = resolveReleaseVersion( + current, + await fetchPublishedVersions(manifest.name), + ); + if (next !== current) { + manifest.version = next; + await writeFile(manifestPath, `${JSON.stringify(manifest, null, "\t")}\n`); + } + process.stdout.write(`${manifest.name}: ${current} -> ${next}\n`); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + await main(); +} diff --git a/scripts/sync-plugin-version.test.ts b/scripts/sync-plugin-version.test.ts index 8368155..35d346a 100644 --- a/scripts/sync-plugin-version.test.ts +++ b/scripts/sync-plugin-version.test.ts @@ -2,7 +2,10 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { resolve } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { syncPluginVersion } from "./sync-plugin-version"; +import { + findPluginVersionDrift, + syncPluginVersion, +} from "./sync-plugin-version"; describe("syncPluginVersion", () => { const temporaryDirectories: string[] = []; @@ -71,4 +74,34 @@ describe("syncPluginVersion", () => { await expect(syncPluginVersion(packageRoot)).rejects.toThrow("plugin.json"); }); + + it("reports only the plugin manifest whose version drifted", async () => { + const packageRoot = await mkdtemp(resolve(tmpdir(), "sim-skills-version-")); + temporaryDirectories.push(packageRoot); + await Promise.all([ + mkdir(resolve(packageRoot, ".codex-plugin")), + mkdir(resolve(packageRoot, ".claude-plugin")), + ]); + await Promise.all([ + writeFile( + resolve(packageRoot, "package.json"), + JSON.stringify({ name: "sim-skills", version: "0.2.0" }), + ), + writeFile( + resolve(packageRoot, ".codex-plugin", "plugin.json"), + JSON.stringify({ name: "sim", version: "0.2.0" }), + ), + writeFile( + resolve(packageRoot, ".claude-plugin", "plugin.json"), + JSON.stringify({ name: "sim", version: "0.1.1" }), + ), + ]); + + const drift = await findPluginVersionDrift(packageRoot); + expect(drift).toHaveLength(1); + expect(drift[0]).toContain(".claude-plugin/plugin.json version 0.1.1"); + + await syncPluginVersion(packageRoot); + await expect(findPluginVersionDrift(packageRoot)).resolves.toEqual([]); + }); }); diff --git a/scripts/sync-plugin-version.ts b/scripts/sync-plugin-version.ts index 8784de9..65a7ac6 100644 --- a/scripts/sync-plugin-version.ts +++ b/scripts/sync-plugin-version.ts @@ -21,15 +21,38 @@ async function readJsonObject(path: string): Promise> { return parsed; } -export async function syncPluginVersion( - packageRoot = defaultPackageRoot, -): Promise { +async function readPackageVersion(packageRoot: string): Promise { const packageManifestPath = resolve(packageRoot, "package.json"); const packageManifest = await readJsonObject(packageManifestPath); const version = packageManifest.version; if (typeof version !== "string" || !version) { throw new Error(`${packageManifestPath}: version is required`); } + return version; +} + +/** Lists each plugin manifest whose version differs from package.json's. */ +export async function findPluginVersionDrift( + packageRoot = defaultPackageRoot, +): Promise { + const version = await readPackageVersion(packageRoot); + const drift = await Promise.all( + pluginManifestPaths.map(async (relativePath) => { + const manifest = await readJsonObject(resolve(packageRoot, relativePath)); + return manifest.version === version + ? [] + : [ + `${relativePath} version ${String(manifest.version)} does not match package.json ${version}`, + ]; + }), + ); + return drift.flat(); +} + +export async function syncPluginVersion( + packageRoot = defaultPackageRoot, +): Promise { + const version = await readPackageVersion(packageRoot); await Promise.all( pluginManifestPaths.map(async (relativePath) => { @@ -44,6 +67,17 @@ export async function syncPluginVersion( } if (process.argv[1] === fileURLToPath(import.meta.url)) { - const version = await syncPluginVersion(); - process.stdout.write(`Synchronized Sim plugin manifests to ${version}.\n`); + if (process.argv.includes("--check")) { + const drift = await findPluginVersionDrift(); + if (drift.length > 0) { + process.stderr.write( + `${drift.join("\n")}\nRun bun run sync:plugin-version.\n`, + ); + process.exit(1); + } + process.stdout.write("Sim plugin manifest versions match package.json.\n"); + } else { + const version = await syncPluginVersion(); + process.stdout.write(`Synchronized Sim plugin manifests to ${version}.\n`); + } } diff --git a/scripts/verify-package.sh b/scripts/verify-package.sh new file mode 100755 index 0000000..fd42a51 --- /dev/null +++ b/scripts/verify-package.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Verifies a packed sim-skills tarball ships the CLI, both plugin manifests, +# and the skills. Usage: scripts/verify-package.sh +set -euo pipefail + +PACKAGE_PATH="${1:?usage: verify-package.sh }" +PACKAGE_DIR="$(mktemp -d)" +trap 'rm -rf "$PACKAGE_DIR"' EXIT + +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/sync-workspaces/SKILL.md" +echo "Package contents verified."