Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/pr-metadata.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ concurrency:
permissions:
contents: read
issues: write
pull-requests: read
pull-requests: write
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

jobs:
metadata:
Expand Down
88 changes: 88 additions & 0 deletions .github/workflows/update-opencode-plugin.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
28 changes: 28 additions & 0 deletions scripts/__tests__/semver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";

import { isNewerSemVer, parseSerializedSemVer } from "../semver";

describe("Semantic version comparison", () => {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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
);
});
});
126 changes: 126 additions & 0 deletions scripts/semver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
const serializedVersionPattern =
/^"(?<major>0|[1-9]\d*)\.(?<minor>0|[1-9]\d*)\.(?<patch>0|[1-9]\d*)(?:-(?<prerelease>(?: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;
};
79 changes: 79 additions & 0 deletions scripts/update-opencode-plugin.ts
Original file line number Diff line number Diff line change
@@ -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(
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
"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}`
);
Loading