diff --git a/.github/workflows/pr-metadata.yml b/.github/workflows/pr-metadata.yml index a4f89ed..f7c4843 100644 --- a/.github/workflows/pr-metadata.yml +++ b/.github/workflows/pr-metadata.yml @@ -11,7 +11,7 @@ concurrency: permissions: contents: read issues: write - pull-requests: read + pull-requests: write jobs: metadata: diff --git a/.github/workflows/update-opencode-plugin.yml b/.github/workflows/update-opencode-plugin.yml new file mode 100644 index 0000000..3718b19 --- /dev/null +++ b/.github/workflows/update-opencode-plugin.yml @@ -0,0 +1,88 @@ +name: Update @opencode/plugin + +on: + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: update-opencode-plugin + cancel-in-progress: false + +jobs: + update: + runs-on: ubuntu-latest + steps: + - name: Checkout main + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: main + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.2 + + - name: Prepare update branch + id: branch + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh auth setup-git + branch_sha=$(git ls-remote --heads origin refs/heads/update-opencode-plugin | cut -f1) + if [ -n "$branch_sha" ]; then + git fetch origin update-opencode-plugin + fi + git checkout -B update-opencode-plugin main + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + echo "branch_sha=$branch_sha" >> "$GITHUB_OUTPUT" + + - name: Check npm for the latest version + id: latest + run: bun scripts/update-opencode-plugin.ts + + - name: Install dependencies and update lockfile + if: steps.latest.outputs.changed == 'true' + run: bun install + + - name: Add plugin Changesets + if: steps.latest.outputs.changed == 'true' + env: + VERSION: ${{ steps.latest.outputs.version }} + run: | + bun run changeset-add -- usage-limits patch "Update @opencode/plugin to $VERSION" + bun run changeset-add -- force-input patch "Update @opencode/plugin to $VERSION" + + - name: Commit and push update + if: steps.latest.outputs.changed == 'true' + env: + BRANCH_SHA: ${{ steps.branch.outputs.branch_sha }} + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.latest.outputs.version }} + run: | + gh auth setup-git + git add package.json bun.lock .changeset + git commit -m "chore(deps): update @opencode/plugin to $VERSION" + if [ -n "$BRANCH_SHA" ]; then + git push --force-with-lease="refs/heads/update-opencode-plugin:$BRANCH_SHA" origin update-opencode-plugin + else + git push --set-upstream origin update-opencode-plugin + fi + + - name: Open or update pull request + if: steps.latest.outputs.changed == 'true' + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.latest.outputs.version }} + run: | + pr_number=$(gh pr list --head update-opencode-plugin --base main --json number --jq 'if length > 0 then .[0].number else empty end') + if [ -n "$pr_number" ]; then + gh pr edit "$pr_number" --title "chore(deps): update @opencode/plugin" --body "Updates the shared @opencode/plugin dependency to $VERSION and includes patch Changesets for usage-limits and force-input." + else + gh pr create --base main --head update-opencode-plugin --title "chore(deps): update @opencode/plugin" --body "Updates the shared @opencode/plugin dependency to $VERSION and includes patch Changesets for usage-limits and force-input." + fi diff --git a/package.json b/package.json index caed38b..331645a 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "release:tag": "bun scripts/git-tag.ts", "release:stage": "bun scripts/stage-packages.ts", "version-pr-title": "bun scripts/version-pr-title.ts", - "test": "bun run scripts/run-workspaces.ts test", + "test": "bun run scripts/run-workspaces.ts test && bun node_modules/vitest/vitest.mjs run scripts/__tests__", "test:package": "bun run scripts/run-workspaces.ts test:package", "typecheck": "tsc --noEmit && bun run --filter '*' typecheck", "changeset": "changeset", diff --git a/scripts/__tests__/semver.test.ts b/scripts/__tests__/semver.test.ts new file mode 100644 index 0000000..05e147a --- /dev/null +++ b/scripts/__tests__/semver.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import { isNewerSemVer, parseSerializedSemVer } from "../semver"; + +describe("Semantic version comparison", () => { + it("returns true only when the candidate is newer", () => { + expect(isNewerSemVer("2.0.22", "2.0.21")).toBeTruthy(); + expect(isNewerSemVer("2.0.21", "2.0.21")).toBeFalsy(); + expect(isNewerSemVer("2.0.20", "2.0.21")).toBeFalsy(); + }); + + it("compares prereleases according to SemVer precedence", () => { + expect(isNewerSemVer("1.0.0", "1.0.0-rc.2")).toBeTruthy(); + expect(isNewerSemVer("1.0.0-rc.10", "1.0.0-rc.2")).toBeTruthy(); + expect(isNewerSemVer("1.0.0-rc.2", "1.0.0-rc.10")).toBeFalsy(); + }); + + it("ignores build metadata when comparing precedence", () => { + expect(isNewerSemVer("1.0.0+build.2", "1.0.0+build.1")).toBeFalsy(); + }); + + it("rejects malformed versions", () => { + expect(() => parseSerializedSemVer('"1.0"', "test")).toThrow(TypeError); + expect(() => parseSerializedSemVer('"1.0.0-01"', "test")).toThrow( + TypeError + ); + }); +}); diff --git a/scripts/semver.ts b/scripts/semver.ts new file mode 100644 index 0000000..7af7b5b --- /dev/null +++ b/scripts/semver.ts @@ -0,0 +1,126 @@ +const serializedVersionPattern = + /^"(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?0|[1-9]\d*)(?:-(?(?:0|[1-9]\d*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?"$/u; + +interface ParsedVersion { + major: bigint; + minor: bigint; + patch: bigint; + prerelease: string[]; + value: string; +} + +export const parseSerializedSemVer = ( + serialized: string, + source: string +): ParsedVersion => { + const match = serializedVersionPattern.exec(serialized); + + if (!match) { + throw new TypeError(`${source} did not contain a valid package version`); + } + + const { major, minor, patch, prerelease } = match.groups ?? {}; + + if (!major || !minor || !patch) { + throw new TypeError(`${source} did not contain a valid package version`); + } + + return { + major: BigInt(major), + minor: BigInt(minor), + patch: BigInt(patch), + prerelease: prerelease?.split(".") ?? [], + value: serialized.slice(1, -1), + }; +}; + +const compareBigInts = (left: bigint, right: bigint): number => { + if (left === right) { + return 0; + } + + return left > right ? 1 : -1; +}; + +const compareCoreVersions = ( + left: ParsedVersion, + right: ParsedVersion +): number => { + for (const key of ["major", "minor", "patch"] as const) { + if (left[key] !== right[key]) { + return compareBigInts(left[key], right[key]); + } + } + + return 0; +}; + +const comparePrereleaseIdentifier = (left: string, right: string): number => { + const leftIsNumeric = /^\d+$/u.test(left); + const rightIsNumeric = /^\d+$/u.test(right); + + if (leftIsNumeric && rightIsNumeric) { + return compareBigInts(BigInt(left), BigInt(right)); + } + + if (leftIsNumeric !== rightIsNumeric) { + return leftIsNumeric ? -1 : 1; + } + + if (left === right) { + return 0; + } + + return left > right ? 1 : -1; +}; + +const comparePrerelease = (left: string[], right: string[]): number => { + for (const [index, leftIdentifier] of left.entries()) { + const rightIdentifier = right[index]; + + if (rightIdentifier === undefined) { + return 1; + } + + const comparison = comparePrereleaseIdentifier( + leftIdentifier, + rightIdentifier + ); + + if (comparison !== 0) { + return comparison; + } + } + + return left.length === right.length ? 0 : -1; +}; + +const compareVersions = (left: ParsedVersion, right: ParsedVersion): number => { + const coreComparison = compareCoreVersions(left, right); + + if (coreComparison !== 0) { + return coreComparison; + } + + const leftIsRelease = left.prerelease.length === 0; + const rightIsRelease = right.prerelease.length === 0; + + if (leftIsRelease !== rightIsRelease) { + return leftIsRelease ? 1 : -1; + } + + return comparePrerelease(left.prerelease, right.prerelease); +}; + +export const isNewerSemVer = (candidate: string, current: string): boolean => { + const parsedCandidate = parseSerializedSemVer( + JSON.stringify(candidate) ?? "", + "Candidate version" + ); + const parsedCurrent = parseSerializedSemVer( + JSON.stringify(current) ?? "", + "Current version" + ); + + return compareVersions(parsedCandidate, parsedCurrent) > 0; +}; diff --git a/scripts/update-opencode-plugin.ts b/scripts/update-opencode-plugin.ts new file mode 100644 index 0000000..86f02b4 --- /dev/null +++ b/scripts/update-opencode-plugin.ts @@ -0,0 +1,79 @@ +import { appendFile, readFile, writeFile } from "node:fs/promises"; + +import { isNewerSemVer, parseSerializedSemVer } from "./semver"; + +const packageJsonPath = new URL("../package.json", import.meta.url); +const packageJson: unknown = JSON.parse( + await readFile(packageJsonPath, "utf-8") +); + +if ( + !(packageJson instanceof Object) || + Array.isArray(packageJson) || + !("catalog" in packageJson) +) { + throw new TypeError("Root package.json must contain an object"); +} + +const { catalog } = packageJson; + +if ( + !(catalog instanceof Object) || + Array.isArray(catalog) || + !("@opencode/plugin" in catalog) +) { + throw new TypeError("Root package.json must define a catalog object"); +} + +const currentVersion = parseSerializedSemVer( + JSON.stringify(catalog["@opencode/plugin"]) ?? "", + "Root package catalog" +); + +const response = await fetch( + "https://registry.npmjs.org/@opencode%2fplugin/latest", + { signal: AbortSignal.timeout(15_000) } +); + +if (!response.ok) { + throw new Error( + `npm registry returned ${response.status} for @opencode/plugin` + ); +} + +const metadata: unknown = await response.json(); + +if ( + !(metadata instanceof Object) || + Array.isArray(metadata) || + !("version" in metadata) +) { + throw new TypeError("npm registry response must contain package metadata"); +} + +const latestVersion = parseSerializedSemVer( + JSON.stringify(metadata.version) ?? "", + "npm registry response" +); + +const changed = isNewerSemVer(latestVersion.value, currentVersion.value); + +if (changed) { + Reflect.set(catalog, "@opencode/plugin", latestVersion.value); + await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); +} + +const outputPath = process.env.GITHUB_OUTPUT; + +if (outputPath) { + await appendFile( + outputPath, + `changed=${changed}\nversion=${latestVersion.value}\n` + ); +} + +console.log( + changed + ? `Updated @opencode/plugin from ${currentVersion.value} to ${latestVersion.value}` + : `@opencode/plugin is already current at ${currentVersion.value}` +);