From 242487933dca4ad45c1e88142d75e4e363fcbd53 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Fri, 2 Oct 2026 19:23:30 +1300 Subject: [PATCH 1/4] ci: add manual plugin dependency update workflow --- .github/workflows/update-opencode-plugin.yml | 75 +++++++++++++++++ scripts/update-opencode-plugin.ts | 87 ++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 .github/workflows/update-opencode-plugin.yml create mode 100644 scripts/update-opencode-plugin.ts diff --git a/.github/workflows/update-opencode-plugin.yml b/.github/workflows/update-opencode-plugin.yml new file mode 100644 index 0000000..487cb59 --- /dev/null +++ b/.github/workflows/update-opencode-plugin.yml @@ -0,0 +1,75 @@ +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 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.2 + + - name: Prepare update branch + run: | + if git ls-remote --exit-code --heads origin update-opencode-plugin; then + git fetch origin update-opencode-plugin + git checkout -B update-opencode-plugin origin/update-opencode-plugin + else + git checkout -b update-opencode-plugin + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - 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: + VERSION: ${{ steps.latest.outputs.version }} + run: | + git add package.json bun.lock .changeset + git commit -m "chore(deps): update @opencode/plugin to $VERSION" + git push --set-upstream origin update-opencode-plugin + + - 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 '.[0].number') + 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/scripts/update-opencode-plugin.ts b/scripts/update-opencode-plugin.ts new file mode 100644 index 0000000..4a5afdd --- /dev/null +++ b/scripts/update-opencode-plugin.ts @@ -0,0 +1,87 @@ +import { appendFile, readFile, writeFile } from "node:fs/promises"; + +const serializedVersionPattern = + /^"\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?"$/u; + +const parseVersion = (serialized: string, source: string): string => { + if (!serializedVersionPattern.test(serialized)) { + throw new TypeError(`${source} did not contain a valid package version`); + } + + return serialized.slice(1, -1); +}; + +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 = parseVersion( + JSON.stringify(catalog["@opencode/plugin"]) ?? "", + "Root package catalog" +); + +const response = await fetch( + "https://registry.npmjs.org/@opencode%2fplugin/latest" +); + +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 = parseVersion( + JSON.stringify(metadata.version) ?? "", + "npm registry response" +); + +const changed = currentVersion !== latestVersion; + +if (changed) { + Reflect.set(catalog, "@opencode/plugin", latestVersion); + await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); +} + +const outputPath = process.env.GITHUB_OUTPUT; + +if (outputPath) { + await appendFile( + outputPath, + `changed=${changed}\nversion=${latestVersion}\n` + ); +} + +console.log( + changed + ? `Updated @opencode/plugin from ${currentVersion} to ${latestVersion}` + : `@opencode/plugin is already current at ${currentVersion}` +); From 5ea10575b07d3cbef3a24c17f045ea808a02a2b6 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Fri, 2 Oct 2026 19:39:09 +1300 Subject: [PATCH 2/4] fix: address plugin update workflow review --- .github/workflows/pr-metadata.yml | 3 +- .github/workflows/update-opencode-plugin.yml | 23 +++- scripts/__tests__/semver.test.ts | 28 +++++ scripts/semver.ts | 126 +++++++++++++++++++ scripts/update-opencode-plugin.ts | 25 ++-- 5 files changed, 181 insertions(+), 24 deletions(-) create mode 100644 scripts/__tests__/semver.test.ts create mode 100644 scripts/semver.ts diff --git a/.github/workflows/pr-metadata.yml b/.github/workflows/pr-metadata.yml index a4f89ed..0d829d2 100644 --- a/.github/workflows/pr-metadata.yml +++ b/.github/workflows/pr-metadata.yml @@ -10,8 +10,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 index 487cb59..47f3a96 100644 --- a/.github/workflows/update-opencode-plugin.yml +++ b/.github/workflows/update-opencode-plugin.yml @@ -19,6 +19,7 @@ jobs: 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 @@ -26,15 +27,20 @@ jobs: bun-version: 1.4.2 - name: Prepare update branch + id: branch + env: + GH_TOKEN: ${{ github.token }} run: | - if git ls-remote --exit-code --heads origin update-opencode-plugin; then + 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 - git checkout -B update-opencode-plugin origin/update-opencode-plugin - else - git checkout -b 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 @@ -55,11 +61,18 @@ jobs: - 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" - git push --set-upstream origin update-opencode-plugin + 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' 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 index 4a5afdd..5f94603 100644 --- a/scripts/update-opencode-plugin.ts +++ b/scripts/update-opencode-plugin.ts @@ -1,15 +1,6 @@ import { appendFile, readFile, writeFile } from "node:fs/promises"; -const serializedVersionPattern = - /^"\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?"$/u; - -const parseVersion = (serialized: string, source: string): string => { - if (!serializedVersionPattern.test(serialized)) { - throw new TypeError(`${source} did not contain a valid package version`); - } - - return serialized.slice(1, -1); -}; +import { isNewerSemVer, parseSerializedSemVer } from "./semver"; const packageJsonPath = new URL("../package.json", import.meta.url); const packageJson: unknown = JSON.parse( @@ -34,7 +25,7 @@ if ( throw new TypeError("Root package.json must define a catalog object"); } -const currentVersion = parseVersion( +const currentVersion = parseSerializedSemVer( JSON.stringify(catalog["@opencode/plugin"]) ?? "", "Root package catalog" ); @@ -59,15 +50,15 @@ if ( throw new TypeError("npm registry response must contain package metadata"); } -const latestVersion = parseVersion( +const latestVersion = parseSerializedSemVer( JSON.stringify(metadata.version) ?? "", "npm registry response" ); -const changed = currentVersion !== latestVersion; +const changed = isNewerSemVer(latestVersion.value, currentVersion.value); if (changed) { - Reflect.set(catalog, "@opencode/plugin", latestVersion); + Reflect.set(catalog, "@opencode/plugin", latestVersion.value); await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); } @@ -76,12 +67,12 @@ const outputPath = process.env.GITHUB_OUTPUT; if (outputPath) { await appendFile( outputPath, - `changed=${changed}\nversion=${latestVersion}\n` + `changed=${changed}\nversion=${latestVersion.value}\n` ); } console.log( changed - ? `Updated @opencode/plugin from ${currentVersion} to ${latestVersion}` - : `@opencode/plugin is already current at ${currentVersion}` + ? `Updated @opencode/plugin from ${currentVersion.value} to ${latestVersion.value}` + : `@opencode/plugin is already current at ${currentVersion.value}` ); From 9177f51e7366982b1760cacad29fae881913c26c Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Fri, 2 Oct 2026 19:43:20 +1300 Subject: [PATCH 3/4] fix: address remaining plugin update review findings --- .github/workflows/update-opencode-plugin.yml | 2 +- scripts/update-opencode-plugin.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/update-opencode-plugin.yml b/.github/workflows/update-opencode-plugin.yml index 47f3a96..3718b19 100644 --- a/.github/workflows/update-opencode-plugin.yml +++ b/.github/workflows/update-opencode-plugin.yml @@ -80,7 +80,7 @@ jobs: GH_TOKEN: ${{ github.token }} VERSION: ${{ steps.latest.outputs.version }} run: | - pr_number=$(gh pr list --head update-opencode-plugin --base main --json number --jq '.[0].number') + 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 diff --git a/scripts/update-opencode-plugin.ts b/scripts/update-opencode-plugin.ts index 5f94603..86f02b4 100644 --- a/scripts/update-opencode-plugin.ts +++ b/scripts/update-opencode-plugin.ts @@ -31,7 +31,8 @@ const currentVersion = parseSerializedSemVer( ); const response = await fetch( - "https://registry.npmjs.org/@opencode%2fplugin/latest" + "https://registry.npmjs.org/@opencode%2fplugin/latest", + { signal: AbortSignal.timeout(15_000) } ); if (!response.ok) { From b513692fd1274b5c98d242ff7ceab923363cde58 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Fri, 2 Oct 2026 19:47:08 +1300 Subject: [PATCH 4/4] test: run script checks and retain label permissions --- .github/workflows/pr-metadata.yml | 1 + package.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-metadata.yml b/.github/workflows/pr-metadata.yml index 0d829d2..f7c4843 100644 --- a/.github/workflows/pr-metadata.yml +++ b/.github/workflows/pr-metadata.yml @@ -10,6 +10,7 @@ concurrency: permissions: contents: read + issues: write pull-requests: write jobs: 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",