Skip to content
Open
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
86 changes: 86 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -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."
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
53 changes: 53 additions & 0 deletions scripts/bump-version.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
182 changes: 182 additions & 0 deletions scripts/bump-version.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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<string, unknown>;
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();
Loading