From 4bc90fd30f86d8dc11227edf32177c220187dea7 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Tue, 6 Oct 2026 23:52:43 +1300 Subject: [PATCH 01/17] refactor: organize repository automation scripts --- .github/README.md | 40 ++++++ .github/scripts/__tests__/json-value.test.ts | 44 +++++++ .../scripts}/__tests__/semver.test.ts | 2 +- .../scripts/create-git-tag.ts | 20 +-- .../scripts}/create-signed-github-commit.ts | 0 .../scripts/get-version-pr-title.ts | 0 .../scripts}/pr-metadata-helpers.ts | 0 {scripts => .github/scripts}/semver.ts | 0 .../scripts}/stage-packages.ts | 21 ++-- .../scripts}/update-opencode-plugin.ts | 4 +- .../scripts/update-pr-metadata.ts | 119 ++++++++++++++++-- .github/workflows/ci.yml | 6 + .github/workflows/pr-metadata.yml | 2 +- .github/workflows/release.yml | 13 +- .github/workflows/update-opencode-plugin.yml | 8 +- .node-version | 1 + AGENTS.md | 2 +- README.md | 6 +- knip.json | 7 ++ package.json | 14 +-- packages/opencode-force-input/CONTRIBUTING.md | 2 +- packages/opencode-force-input/package.json | 2 +- .../scripts/test-package.ts | 4 +- packages/opencode-force-input/tsconfig.json | 4 + .../opencode-usage-limits/CONTRIBUTING.md | 2 +- .../__tests__/pr-metadata.test.ts | 2 +- .../__tests__/providers/runtime.test.ts | 13 +- packages/opencode-usage-limits/package.json | 2 +- .../scripts/test-package.ts | 4 +- packages/opencode-usage-limits/tsconfig.json | 5 +- .../opencode-usage-limits/vitest.config.ts | 19 ++- scripts/README.md | 25 ++++ scripts/changeset-add.ts | 2 +- scripts/check-coverage.ts | 4 +- .../__tests__/package-tarball.test.ts | 2 +- .../check.ts} | 90 ++++++++----- .../package-tarball-helpers.ts | 0 scripts/run-workspaces.ts | 73 ----------- scripts/shared/json-value.ts | 66 ++++++++++ scripts/shared/node-executable.ts | 31 +++++ scripts/shared/npm-cli.ts | 28 +++++ tsconfig.json | 7 +- vitest.config.ts | 15 +++ 43 files changed, 534 insertions(+), 177 deletions(-) create mode 100644 .github/README.md create mode 100644 .github/scripts/__tests__/json-value.test.ts rename {scripts => .github/scripts}/__tests__/semver.test.ts (92%) rename scripts/git-tag.ts => .github/scripts/create-git-tag.ts (73%) rename {scripts => .github/scripts}/create-signed-github-commit.ts (100%) rename scripts/version-pr-title.ts => .github/scripts/get-version-pr-title.ts (100%) rename {scripts => .github/scripts}/pr-metadata-helpers.ts (100%) rename {scripts => .github/scripts}/semver.ts (100%) rename {scripts => .github/scripts}/stage-packages.ts (91%) rename {scripts => .github/scripts}/update-opencode-plugin.ts (92%) rename scripts/pr-metadata.ts => .github/scripts/update-pr-metadata.ts (65%) create mode 100644 .node-version create mode 100644 scripts/README.md rename {packages/opencode-usage-limits => scripts/package-tarball}/__tests__/package-tarball.test.ts (93%) rename scripts/{check-package-tarball.ts => package-tarball/check.ts} (51%) rename scripts/{ => package-tarball}/package-tarball-helpers.ts (100%) delete mode 100644 scripts/run-workspaces.ts create mode 100644 scripts/shared/json-value.ts create mode 100644 scripts/shared/node-executable.ts create mode 100644 scripts/shared/npm-cli.ts create mode 100644 vitest.config.ts diff --git a/.github/README.md b/.github/README.md new file mode 100644 index 0000000..94c6b1f --- /dev/null +++ b/.github/README.md @@ -0,0 +1,40 @@ +# GitHub repository configuration + +This directory contains GitHub Actions workflows, automation scripts, repository templates, and configuration used to maintain this repository. + +## Workflows + +| Workflow | Purpose | Trigger | +| --- | --- | --- | +| [`ci.yml`](workflows/ci.yml) | Runs typechecking, lint/check, tests, package tests, build, and Knip. | Pull requests and pushes to `main` | +| [`deploy-preview.yml`](workflows/deploy-preview.yml) | Deploys documentation previews and cleans them up when pull requests close. | CI completion and pull request closure | +| [`deploy.yml`](workflows/deploy.yml) | Deploys production documentation after a release workflow completes. | Release workflow completion | +| [`link-check.yml`](workflows/link-check.yml) | Checks documentation links. | Relevant pull requests, weekly schedule, or manual dispatch | +| [`pr-metadata.yml`](workflows/pr-metadata.yml) | Applies managed pull request labels and checks Changeset coverage. | Pull request activity | +| [`release.yml`](workflows/release.yml) | Verifies packages and runs the Changesets release/publish flow. | Pushes to `main` or manual dispatch | +| [`update-opencode-plugin.yml`](workflows/update-opencode-plugin.yml) | Checks for a newer `@opencode/plugin` and opens or updates its pull request. | Manual dispatch | +| [`workflow-security.yml`](workflows/workflow-security.yml) | Scans workflow and automation changes for security issues. | Relevant pull requests, pushes to `main`, or schedule | + +## Workflow scripts + +Workflow-specific TypeScript entrypoints live in [`scripts/`](scripts/) so their implementation sits beside the workflows that call them. Workflows invoke them with Bun; scripts use Node standard APIs and launch npm through the explicitly configured Node executable where needed. + +| Script | Purpose | Used by | +| --- | --- | --- | +| [`create-git-tag.ts`](scripts/create-git-tag.ts) | Publishes release tags after Changesets publishing. | `release.yml` | +| [`create-signed-github-commit.ts`](scripts/create-signed-github-commit.ts) | Creates a verified GitHub commit for the plugin update. | `update-opencode-plugin.yml` | +| [`get-version-pr-title.ts`](scripts/get-version-pr-title.ts) | Produces the release pull request title. | `release.yml` | +| [`pr-metadata-helpers.ts`](scripts/pr-metadata-helpers.ts) | Pure helpers for PR labels and Changeset coverage. | `update-pr-metadata.ts` and tests | +| [`semver.ts`](scripts/semver.ts) | Parses and compares serialized semantic versions. | `update-opencode-plugin.ts` and tests | +| [`stage-packages.ts`](scripts/stage-packages.ts) | Stages workspace packages for release publishing. | `release.yml` | +| [`update-opencode-plugin.ts`](scripts/update-opencode-plugin.ts) | Checks for and reports a newer `@opencode/plugin` version. | `update-opencode-plugin.yml` | +| [`update-pr-metadata.ts`](scripts/update-pr-metadata.ts) | Reconciles PR labels and validates Changeset coverage. | `pr-metadata.yml` | + +## Other GitHub files + +| Path | Purpose | +| --- | --- | +| [`ISSUE_TEMPLATE/`](ISSUE_TEMPLATE/) | Bug report and feature request forms, plus shared issue-template configuration. | +| [`dependabot.yml`](dependabot.yml) | Weekly dependency update configuration for Bun and GitHub Actions. | +| [`pull_request_template.md`](pull_request_template.md) | Pull request summary, compatibility, validation, and release checklist. | +| [`zizmor.yml`](zizmor.yml) | Line-specific ignores for intentionally reviewed workflow-security findings. | diff --git a/.github/scripts/__tests__/json-value.test.ts b/.github/scripts/__tests__/json-value.test.ts new file mode 100644 index 0000000..6bfa2b6 --- /dev/null +++ b/.github/scripts/__tests__/json-value.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { + parseJsonArray, + parseJsonNumber, + parseJsonObject, + parseJsonString, +} from "@/scripts/shared/json-value.ts"; + +describe("JSON value parsing", () => { + it("parses objects into a typed map of JSON values", () => { + const object = parseJsonObject( + JSON.parse('{"name":"plugin","count":2}'), + "test object" + ); + + expect(object.get("name")).toBe("plugin"); + expect(object.get("count")).toBe(2); + }); + + it("parses strings, finite numbers, and arrays", () => { + expect(parseJsonString("plugin", "name")).toBe("plugin"); + expect(parseJsonNumber(2, "count")).toBe(2); + expect(parseJsonArray(["plugin", 2], "values")).toStrictEqual([ + "plugin", + 2, + ]); + }); + + it("rejects values with the wrong JSON shape", () => { + expect(() => parseJsonObject(null, "object")).toThrow( + "object must be an object" + ); + expect(() => parseJsonString(2, "string")).toThrow( + "string must be a string" + ); + expect(() => parseJsonNumber("2", "number")).toThrow( + "number must be a number" + ); + expect(() => parseJsonArray(JSON.parse("{}"), "array")).toThrow( + "array must be an array" + ); + }); +}); diff --git a/scripts/__tests__/semver.test.ts b/.github/scripts/__tests__/semver.test.ts similarity index 92% rename from scripts/__tests__/semver.test.ts rename to .github/scripts/__tests__/semver.test.ts index 05e147a..13734cc 100644 --- a/scripts/__tests__/semver.test.ts +++ b/.github/scripts/__tests__/semver.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { isNewerSemVer, parseSerializedSemVer } from "../semver"; +import { isNewerSemVer, parseSerializedSemVer } from "@/github/semver.ts"; describe("Semantic version comparison", () => { it("returns true only when the candidate is newer", () => { diff --git a/scripts/git-tag.ts b/.github/scripts/create-git-tag.ts similarity index 73% rename from scripts/git-tag.ts rename to .github/scripts/create-git-tag.ts index 4f1fda7..6980649 100644 --- a/scripts/git-tag.ts +++ b/.github/scripts/create-git-tag.ts @@ -2,6 +2,8 @@ import { execFileSync } from "node:child_process"; import { readdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; +import { getNodeExecutablePath } from "@/scripts/shared/node-executable.ts"; + interface PackageManifest { name: string; version: string; @@ -17,15 +19,13 @@ const outputPath = process.env.CHANGESETS_OUTPUT; if (!outputPath) { throw new Error("CHANGESETS_OUTPUT is required by the Changesets action"); } - -const gitPath = Bun.which("git"); -const bunxPath = Bun.which("bunx"); -if (!gitPath || !bunxPath) { - throw new Error("git and bunx are required to create release tags"); +const gitExecutable = process.env.GIT_EXECUTABLE; +if (!gitExecutable || !path.isAbsolute(gitExecutable)) { + throw new Error("GIT_EXECUTABLE must be an absolute path to Git"); } const existingTags = new Set( - execFileSync(gitPath, ["ls-remote", "--tags", "origin"], { + execFileSync(gitExecutable, ["ls-remote", "--tags", "origin"], { encoding: "utf-8", }) .split("\n") @@ -33,9 +33,11 @@ const existingTags = new Set( .filter((tag): tag is string => tag !== undefined && !tag.endsWith("^{}")) ); -execFileSync(bunxPath, ["changeset", "git-tag"], { - stdio: "inherit", -}); +execFileSync( + getNodeExecutablePath(), + [path.resolve("node_modules/@changesets/cli/bin.js"), "git-tag"], + { stdio: "inherit" } +); const events: ChangesetsOutputEvent[] = []; for (const directory of readdirSync("packages", { withFileTypes: true })) { diff --git a/scripts/create-signed-github-commit.ts b/.github/scripts/create-signed-github-commit.ts similarity index 100% rename from scripts/create-signed-github-commit.ts rename to .github/scripts/create-signed-github-commit.ts diff --git a/scripts/version-pr-title.ts b/.github/scripts/get-version-pr-title.ts similarity index 100% rename from scripts/version-pr-title.ts rename to .github/scripts/get-version-pr-title.ts diff --git a/scripts/pr-metadata-helpers.ts b/.github/scripts/pr-metadata-helpers.ts similarity index 100% rename from scripts/pr-metadata-helpers.ts rename to .github/scripts/pr-metadata-helpers.ts diff --git a/scripts/semver.ts b/.github/scripts/semver.ts similarity index 100% rename from scripts/semver.ts rename to .github/scripts/semver.ts diff --git a/scripts/stage-packages.ts b/.github/scripts/stage-packages.ts similarity index 91% rename from scripts/stage-packages.ts rename to .github/scripts/stage-packages.ts index 50d6702..9cc97df 100644 --- a/scripts/stage-packages.ts +++ b/.github/scripts/stage-packages.ts @@ -10,6 +10,9 @@ import { import { tmpdir } from "node:os"; import path from "node:path"; +import { getNodeExecutablePath } from "@/scripts/shared/node-executable.ts"; +import { getNpmCliPath } from "@/scripts/shared/npm-cli.ts"; + type DependencyField = | "dependencies" | "devDependencies" @@ -67,22 +70,19 @@ const resolveManifestCatalog = (manifest: PackageManifest): void => { } }; -const npmPath = Bun.which("npm"); -if (!npmPath) { - throw new Error("npm is required to stage packages"); -} - const stagePackage = (directory: { name: string }): void => { const packageDirectory = path.join("packages", directory.name); const manifest: PackageManifest = JSON.parse( readFileSync(path.join(packageDirectory, manifestFilename), "utf-8") ); const packageSpec = `${manifest.name}@${manifest.version}`; + const npmCliPath = getNpmCliPath(); try { execFileSync( - npmPath, + getNodeExecutablePath(), [ + npmCliPath, "view", packageSpec, "version", @@ -111,8 +111,9 @@ const stagePackage = (directory: { name: string }): void => { "utf-8" ); execFileSync( - npmPath, + getNodeExecutablePath(), [ + npmCliPath, "stage", "publish", "--access", @@ -122,7 +123,11 @@ const stagePackage = (directory: { name: string }): void => { "--provenance", "--ignore-scripts", ], - { cwd: stagedDirectory, stdio: ["ignore", "inherit", "pipe"] } + { + cwd: stagedDirectory, + encoding: "utf-8", + stdio: ["ignore", "inherit", "pipe"], + } ); } catch (error) { const details = diff --git a/scripts/update-opencode-plugin.ts b/.github/scripts/update-opencode-plugin.ts similarity index 92% rename from scripts/update-opencode-plugin.ts rename to .github/scripts/update-opencode-plugin.ts index 86f02b4..f9a6a87 100644 --- a/scripts/update-opencode-plugin.ts +++ b/.github/scripts/update-opencode-plugin.ts @@ -1,8 +1,8 @@ import { appendFile, readFile, writeFile } from "node:fs/promises"; -import { isNewerSemVer, parseSerializedSemVer } from "./semver"; +import { isNewerSemVer, parseSerializedSemVer } from "@/github/semver.ts"; -const packageJsonPath = new URL("../package.json", import.meta.url); +const packageJsonPath = new URL("../../package.json", import.meta.url); const packageJson: unknown = JSON.parse( await readFile(packageJsonPath, "utf-8") ); diff --git a/scripts/pr-metadata.ts b/.github/scripts/update-pr-metadata.ts similarity index 65% rename from scripts/pr-metadata.ts rename to .github/scripts/update-pr-metadata.ts index d5107a8..53252c9 100644 --- a/scripts/pr-metadata.ts +++ b/.github/scripts/update-pr-metadata.ts @@ -1,3 +1,5 @@ +import assert from "node:assert/strict"; + import { getComponentLabels, getMissingChangesets, @@ -9,7 +11,14 @@ import { packageLabels, parseChangesetEntries, reconcileLabels, -} from "./pr-metadata-helpers.ts"; +} from "@/github/pr-metadata-helpers.ts"; +import type { JsonValue } from "@/scripts/shared/json-value.ts"; +import { + parseJsonArray, + parseJsonNumber, + parseJsonObject, + parseJsonString, +} from "@/scripts/shared/json-value.ts"; const owner = process.env.GITHUB_REPOSITORY?.split("/")[0]; const repository = process.env.GITHUB_REPOSITORY?.split("/")[1]; @@ -35,7 +44,10 @@ class GitHubApiError extends Error { } } -const api = async (endpoint: string, init?: RequestInit): Promise => { +const api = async ( + endpoint: string, + init?: RequestInit +): Promise => { const response = await fetch(`https://api.github.com${endpoint}`, { ...init, headers: { @@ -50,11 +62,10 @@ const api = async (endpoint: string, init?: RequestInit): Promise => { throw new GitHubApiError(response.status, await response.text()); } if (response.status === 204) { - // SAFETY: The DELETE label endpoint returns no response body. - return undefined as T; + return undefined; } - // SAFETY: Each caller pairs this response with the schema for the requested GitHub API endpoint. - return (await response.json()) as T; + const result: JsonValue = JSON.parse(await response.text()); + return result; }; interface PullRequestFile { @@ -73,8 +84,90 @@ interface PullRequest { changed_files: number; } -const pullRequest = await api( - `/repos/${owner}/${repository}/pulls/${pullRequestNumber}` +interface PullRequestContents { + content?: string; + encoding?: string; +} + +const parsePullRequest = (value: JsonValue): PullRequest => { + const pullRequest = parseJsonObject(value, "Pull request response"); + const head = parseJsonObject(pullRequest.get("head"), "Pull request head"); + const repositoryValue = head.get("repo"); + let repo: PullRequest["head"]["repo"]; + if (repositoryValue === null) { + repo = null; + } else if (repositoryValue !== undefined) { + const headRepositoryDetails = parseJsonObject( + repositoryValue, + "Pull request head repository" + ); + const fullName = headRepositoryDetails.get("full_name"); + repo = + fullName === undefined + ? {} + : { full_name: parseJsonString(fullName, "Repository full name") }; + } + + const labels = parseJsonArray( + pullRequest.get("labels"), + "Pull request labels" + ).map((label, index) => { + const parsedLabel = parseJsonObject(label, `Pull request label ${index}`); + return { name: parseJsonString(parsedLabel.get("name"), "Label name") }; + }); + const changedFiles = parseJsonNumber( + pullRequest.get("changed_files"), + "Pull request changed file count" + ); + assert.ok( + Number.isInteger(changedFiles), + "Pull request changed file count must be an integer" + ); + assert.ok( + changedFiles >= 0, + "Pull request changed file count cannot be negative" + ); + + return { + changed_files: changedFiles, + head: { + ref: parseJsonString(head.get("ref"), "Pull request head ref"), + repo, + sha: parseJsonString(head.get("sha"), "Pull request head SHA"), + }, + labels, + }; +}; + +const parsePullRequestFiles = (value: JsonValue): PullRequestFile[] => + parseJsonArray(value, "Pull request file list").map((file, index) => { + const parsedFile = parseJsonObject(file, `Pull request file ${index}`); + return { + additions: parseJsonNumber(parsedFile.get("additions"), "File additions"), + deletions: parseJsonNumber(parsedFile.get("deletions"), "File deletions"), + filename: parseJsonString(parsedFile.get("filename"), "File name"), + }; + }); + +const parseContentsResponse = (value: JsonValue): PullRequestContents => { + const contents = parseJsonObject(value, "GitHub contents response"); + const parsedContents: PullRequestContents = {}; + const content = contents.get("content"); + if (content !== undefined) { + parsedContents.content = parseJsonString(content, "GitHub contents data"); + } + const encoding = contents.get("encoding"); + if (encoding !== undefined) { + parsedContents.encoding = parseJsonString( + encoding, + "GitHub contents encoding" + ); + } + return parsedContents; +}; + +const pullRequest = parsePullRequest( + await api(`/repos/${owner}/${repository}/pulls/${pullRequestNumber}`) ); if (headSha && pullRequest.head.sha !== headSha) { console.log( @@ -85,12 +178,12 @@ if (headSha && pullRequest.head.sha !== headSha) { const pageCount = getPullRequestFilePageCount(pullRequest.changed_files); const filePages = await Promise.all( Array.from({ length: pageCount }, (_, index) => - api( + api( `/repos/${owner}/${repository}/pulls/${pullRequestNumber}/files?per_page=100&page=${index + 1}` ) ) ); -const files = filePages.flat(); +const files = filePages.flatMap(parsePullRequestFiles); const componentLabels = new Set(packageLabels.values()); const managedLabels = new Set([ @@ -114,8 +207,10 @@ const changesetContents = await Promise.all( changesetFiles.map(async ({ filename }) => { try { const encodedPath = filename.split("/").map(encodeURIComponent).join("/"); - const file = await api<{ content?: string; encoding?: string }>( - `/repos/${headRepository}/contents/${encodedPath}?ref=${encodeURIComponent(headSha ?? pullRequest.head.sha)}` + const file = parseContentsResponse( + await api( + `/repos/${headRepository}/contents/${encodedPath}?ref=${encodeURIComponent(headSha ?? pullRequest.head.sha)}` + ) ); if (file.encoding !== "base64" || !file.content) { return []; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2829569..3ffca32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,12 @@ jobs: uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.4.2 + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .node-version + - name: Export Node executable path + run: echo "NODE_EXECUTABLE=$(node -p 'process.execPath')" >> "$GITHUB_ENV" - name: Install dependencies run: bun install --frozen-lockfile - name: Run task diff --git a/.github/workflows/pr-metadata.yml b/.github/workflows/pr-metadata.yml index 90bc8db..3e712db 100644 --- a/.github/workflows/pr-metadata.yml +++ b/.github/workflows/pr-metadata.yml @@ -34,4 +34,4 @@ jobs: GITHUB_REPOSITORY: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }} PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: bun scripts/pr-metadata.ts + run: bun .github/scripts/update-pr-metadata.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2352a9b..6f3db4a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,9 +28,11 @@ jobs: - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 24 + node-version-file: .node-version registry-url: https://registry.npmjs.org package-manager-cache: false + - name: Export Node executable path + run: echo "NODE_EXECUTABLE=$(node -p 'process.execPath')" >> "$GITHUB_ENV" - name: Upgrade npm run: npm install --global npm@11.15.0 - name: Verify npm staged publishing support @@ -58,17 +60,18 @@ jobs: - name: Determine version PR title id: version-title run: | - title=$(bun run version-pr-title) + title=$(bun .github/scripts/get-version-pr-title.ts) echo "title=$title" >> "$GITHUB_OUTPUT" - name: Stage packages on npm - run: bun run release:stage + run: bun .github/scripts/stage-packages.ts - name: Create release pull request or publish id: changesets uses: changesets/action@ae32849d5ba541f9ae29e40e22a623bc13562f51 # v2.1.2 with: version-script: bunx changeset version - publish-script: bun run release:tag + publish-script: bun .github/scripts/create-git-tag.ts pr-title: ${{ steps.version-title.outputs.title }} commit-message: "chore: release packages" env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.CHANGESETS_GITHUB_TOKEN }} + GIT_EXECUTABLE: /usr/bin/git diff --git a/.github/workflows/update-opencode-plugin.yml b/.github/workflows/update-opencode-plugin.yml index 7cb52b9..4f9df64 100644 --- a/.github/workflows/update-opencode-plugin.yml +++ b/.github/workflows/update-opencode-plugin.yml @@ -32,7 +32,7 @@ jobs: - name: Check npm for the latest version id: latest - run: bun scripts/update-opencode-plugin.ts + run: bun .github/scripts/update-opencode-plugin.ts - name: Install dependencies and update lockfile if: steps.latest.outputs.changed == 'true' @@ -43,8 +43,8 @@ jobs: 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" + 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: Create verified update commit if: steps.latest.outputs.changed == 'true' @@ -52,7 +52,7 @@ jobs: GH_TOKEN: ${{ github.token }} MAIN_SHA: ${{ steps.main.outputs.sha }} VERSION: ${{ steps.latest.outputs.version }} - run: bun scripts/create-signed-github-commit.ts + run: bun .github/scripts/create-signed-github-commit.ts - name: Open or update pull request if: steps.latest.outputs.changed == 'true' diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..8a94b99 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +26.10.0 diff --git a/AGENTS.md b/AGENTS.md index 70e0578..d66556d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ ## Changesets -Create Changesets at the root with `bun run changeset-add -- docs|force-input|usage-limits patch|minor|major "summary"`. Meaningful plugin changes and documentation-site content changes require a matching Changeset. Tests, package-local scripts, and listed development-only metadata/configuration changes are exempt. Apply `skip-changeset` only for a justified non-release change and with maintainer agreement. +Create Changesets at the root with `bun run changeset-add "summary"`. Meaningful plugin changes and documentation-site content changes require a matching Changeset. Tests, package-local scripts, and listed development-only metadata/configuration changes are exempt. Apply `skip-changeset` only for a justified non-release change and with maintainer agreement. ## Pull request automation diff --git a/README.md b/README.md index 7ee98c7..5ae7287 100644 --- a/README.md +++ b/README.md @@ -69,9 +69,9 @@ bun run build Use the root Changesets helper for user-facing changes: ```powershell -bun run changeset-add -- docs patch "Describe the documentation change" -bun run changeset-add -- force-input patch "Describe the change" -bun run changeset-add -- usage-limits minor "Describe the change" +bun run changeset-add docs patch "Describe the documentation change" +bun run changeset-add force-input patch "Describe the change" +bun run changeset-add usage-limits minor "Describe the change" ``` Packages are independently publishable. Build output is generated during packaging and is not committed. diff --git a/knip.json b/knip.json index 33a8fd0..387ca52 100644 --- a/knip.json +++ b/knip.json @@ -7,6 +7,13 @@ "react-grab" ], "workspaces": { + ".": { + "entry": [ + ".github/scripts/create-git-tag.ts", + ".github/scripts/__tests__/**/*.test.ts", + "scripts/package-tarball/__tests__/**/*.test.ts" + ] + }, "apps/web": { "entry": ["alchemy.run.ts", "blume.config.ts"] } diff --git a/package.json b/package.json index a6282a0..32a5a26 100644 --- a/package.json +++ b/package.json @@ -9,16 +9,13 @@ ], "type": "module", "scripts": { - "build": "bun run scripts/run-workspaces.ts build", + "build": "bun run --filter './packages/*' --filter './apps/*' --if-present build", "check": "ultracite check", "fix": "ultracite fix", "knip": "knip", - "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 && 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", + "test": "bun run --sequential --filter './packages/*' --filter './apps/*' --if-present test && bun run --bun vitest run .github/scripts/__tests__ scripts/package-tarball/__tests__", + "test:package": "bun run --filter './packages/*' --filter './apps/*' --if-present test:package", + "typecheck": "tsc --noEmit && bun run --filter './packages/*' --filter './apps/*' --if-present typecheck", "changeset": "changeset", "changeset-add": "bun run scripts/changeset-add.ts" }, @@ -69,8 +66,7 @@ "@scalar/json-magic>undici": "7.29.1", "@vercel/routing-utils>path-to-regexp": "6.3.0", "chevrotain>lodash": "4.18.1", - "chevrotain>lodash-es": "4.18.1", - "effect": "4.0.0" + "chevrotain>lodash-es": "4.18.1" }, "packageManager": "bun@1.4.2", "catalog": { diff --git a/packages/opencode-force-input/CONTRIBUTING.md b/packages/opencode-force-input/CONTRIBUTING.md index fd48427..ea336e0 100644 --- a/packages/opencode-force-input/CONTRIBUTING.md +++ b/packages/opencode-force-input/CONTRIBUTING.md @@ -29,7 +29,7 @@ Run `bun run fix` to apply Ultracite formatting and lint fixes. Do not change Ox Add a Changeset for every user-facing change: ```powershell -bun run changeset-add patch "describe the change" +bun run changeset-add force-input patch "describe the change" ``` Changesets live at the repository root and releases are coordinated from `main` with npm `latest`. Never manually edit package versions. diff --git a/packages/opencode-force-input/package.json b/packages/opencode-force-input/package.json index 9d31dbc..77bc8f9 100644 --- a/packages/opencode-force-input/package.json +++ b/packages/opencode-force-input/package.json @@ -63,7 +63,7 @@ "fix": "ultracite fix", "knip": "knip", "prepack": "bun run build", - "test": "bun ../../node_modules/vitest/vitest.mjs run --coverage && bun ../../scripts/check-coverage.ts coverage/lcov.info 1", + "test": "bun run --bun vitest run --coverage && bun ../../scripts/check-coverage.ts coverage/lcov.info 0.95", "test:package": "bun run build && bun ./scripts/test-package.ts", "typecheck": "tsc --noEmit" }, diff --git a/packages/opencode-force-input/scripts/test-package.ts b/packages/opencode-force-input/scripts/test-package.ts index 9eb6d2d..780892a 100644 --- a/packages/opencode-force-input/scripts/test-package.ts +++ b/packages/opencode-force-input/scripts/test-package.ts @@ -1,4 +1,4 @@ -import { checkPackageTarball } from "../../../scripts/check-package-tarball.ts"; +import { checkPackageTarball } from "@/package-tarball/check.ts"; const tuiEntrypoint = new URL("../dist/index.mjs", import.meta.url); const tuiModule = await import(tuiEntrypoint.href); @@ -15,4 +15,4 @@ if (!hasValidTuiPlugin) { } console.log(`Package smoke test passed: ${tuiPlugin.id}`); -await checkPackageTarball("packages/opencode-force-input"); +checkPackageTarball("packages/opencode-force-input"); diff --git a/packages/opencode-force-input/tsconfig.json b/packages/opencode-force-input/tsconfig.json index 36443d1..a927045 100644 --- a/packages/opencode-force-input/tsconfig.json +++ b/packages/opencode-force-input/tsconfig.json @@ -5,6 +5,10 @@ "allowJs": true, "jsx": "react-jsx", "jsxImportSource": "@opentui/solid", + "paths": { + "@/package-tarball/*": ["../../scripts/package-tarball/*"], + "@/scripts/*": ["../../scripts/*"] + }, "types": ["bun"], "noUnusedLocals": false, "noUnusedParameters": false, diff --git a/packages/opencode-usage-limits/CONTRIBUTING.md b/packages/opencode-usage-limits/CONTRIBUTING.md index 9350bed..42dbaf9 100644 --- a/packages/opencode-usage-limits/CONTRIBUTING.md +++ b/packages/opencode-usage-limits/CONTRIBUTING.md @@ -60,7 +60,7 @@ bun run knip Use a changeset for user-facing changes: ```bash -bun run changeset-add patch "short summary" +bun run changeset-add usage-limits patch "short summary" ``` Use `minor` for new features and `major` for breaking changes. diff --git a/packages/opencode-usage-limits/__tests__/pr-metadata.test.ts b/packages/opencode-usage-limits/__tests__/pr-metadata.test.ts index aee9902..ca818e5 100644 --- a/packages/opencode-usage-limits/__tests__/pr-metadata.test.ts +++ b/packages/opencode-usage-limits/__tests__/pr-metadata.test.ts @@ -10,7 +10,7 @@ import { isDuplicateLabelError, parseChangesetEntries, reconcileLabels, -} from "../../../scripts/pr-metadata-helpers.ts"; +} from "@/github/pr-metadata-helpers.ts"; const forceInput = "@mynameistito/opencode-force-input"; const usageLimits = "@mynameistito/opencode-usage-limits"; diff --git a/packages/opencode-usage-limits/__tests__/providers/runtime.test.ts b/packages/opencode-usage-limits/__tests__/providers/runtime.test.ts index be314cd..4e2cc7d 100644 --- a/packages/opencode-usage-limits/__tests__/providers/runtime.test.ts +++ b/packages/opencode-usage-limits/__tests__/providers/runtime.test.ts @@ -475,19 +475,24 @@ describe("provider runtime services", () => { tmpdir(), `oc-usage-limits-command-${crypto.randomUUID()}.txt` ); - temporaryFiles.push(file); + const scriptFile = path.join( + tmpdir(), + `oc-usage-limits-command-${crypto.randomUUID()}.cjs` + ); + temporaryFiles.push(file, scriptFile); const script = ` const { writeFile } = require("node:fs/promises"); - const file = process.argv[1]; + const file = process.argv[2]; const write = () => writeFile(file, String(Date.now())).then(() => setTimeout(write, 10)); write(); - `; + `; + await writeFile(scriptFile, script); const result = await Effect.runPromiseExit( Effect.gen(function* execute() { const commands = yield* ProviderCommandExecutor; return yield* commands.execute({ - args: ["-e", script, file], + args: [scriptFile, file], command: process.execPath, providerID: "qwen", timeoutMs: 200, diff --git a/packages/opencode-usage-limits/package.json b/packages/opencode-usage-limits/package.json index a65e05b..0c7c034 100644 --- a/packages/opencode-usage-limits/package.json +++ b/packages/opencode-usage-limits/package.json @@ -76,7 +76,7 @@ "fix": "ultracite fix", "knip": "knip", "prepack": "bun run build", - "test": "bun ../../node_modules/vitest/vitest.mjs run --coverage && bun ../../scripts/check-coverage.ts coverage/lcov.info 0.95", + "test": "bun run --bun vitest run --coverage && bun ../../scripts/check-coverage.ts coverage/lcov.info 0.95", "test:package": "bun run build && bun ./scripts/test-package.ts", "typecheck": "tsc --noEmit" }, diff --git a/packages/opencode-usage-limits/scripts/test-package.ts b/packages/opencode-usage-limits/scripts/test-package.ts index 2eb488b..f88a5fc 100644 --- a/packages/opencode-usage-limits/scripts/test-package.ts +++ b/packages/opencode-usage-limits/scripts/test-package.ts @@ -1,6 +1,6 @@ import { createRequire } from "node:module"; -import { checkPackageTarball } from "../../../scripts/check-package-tarball.ts"; +import { checkPackageTarball } from "@/package-tarball/check.ts"; const expectedId = "mynameistito.usage-limits"; const entrypoint = new URL("../dist/index.mjs", import.meta.url); @@ -57,4 +57,4 @@ if (!isPlugin) { } console.log(`Package smoke test passed: ${expectedId}`); -await checkPackageTarball("packages/opencode-usage-limits"); +checkPackageTarball("packages/opencode-usage-limits"); diff --git a/packages/opencode-usage-limits/tsconfig.json b/packages/opencode-usage-limits/tsconfig.json index d606cce..834d651 100644 --- a/packages/opencode-usage-limits/tsconfig.json +++ b/packages/opencode-usage-limits/tsconfig.json @@ -6,7 +6,10 @@ "jsxImportSource": "@opentui/solid", "lib": ["ESNext", "DOM"], "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@/github/*": ["../../.github/scripts/*"], + "@/package-tarball/*": ["../../scripts/package-tarball/*"], + "@/scripts/*": ["../../scripts/*"] }, "types": ["node", "bun"], "strictNullChecks": true diff --git a/packages/opencode-usage-limits/vitest.config.ts b/packages/opencode-usage-limits/vitest.config.ts index eecb65d..6d6e0cd 100644 --- a/packages/opencode-usage-limits/vitest.config.ts +++ b/packages/opencode-usage-limits/vitest.config.ts @@ -12,9 +12,22 @@ export default defineConfig({ }), ], resolve: { - alias: { - "@": fileURLToPath(new URL("src", import.meta.url)), - }, + alias: [ + { + find: "@/github", + replacement: fileURLToPath( + new URL("../../.github/scripts", import.meta.url) + ), + }, + { + find: "@/scripts", + replacement: fileURLToPath(new URL("../../scripts", import.meta.url)), + }, + { + find: "@", + replacement: fileURLToPath(new URL("src", import.meta.url)), + }, + ], conditions: ["bun"], dedupe: ["solid-js"], }, diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..2a5bf35 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,25 @@ +# Repository scripts + +This directory contains shared repository utilities. Bun's native workspace filters run package scripts across the workspace. GitHub configuration, workflows, and workflow-specific scripts are documented in [`.github/README.md`](../.github/README.md). + +## Shared scripts + +| Script | Purpose | Used by | +| --- | --- | --- | +| [`changeset-add.ts`](changeset-add.ts) | Creates a Changeset for one of the supported packages. | `bun run changeset-add "summary"` | +| [`check-coverage.ts`](check-coverage.ts) | Checks an LCOV report against a minimum coverage threshold. | Package `test` scripts | + +### Shared script helpers + +| Script | Purpose | Used by | +| --- | --- | --- | +| [`json-value.ts`](shared/json-value.ts) | Parses JSON values using Node's built-in assertions and typed projections. | GitHub API and npm response parsing | +| [`node-executable.ts`](shared/node-executable.ts) | Resolves the explicit Node executable used for child-process invocation. | npm and Changesets CLI helpers | +| [`npm-cli.ts`](shared/npm-cli.ts) | Resolves the npm CLI bundled with the active Node installation. | Package tarball validation and release staging | + +### Package tarball checks + +| Script | Purpose | Used by | +| --- | --- | --- | +| [`check.ts`](package-tarball/check.ts) | Verifies the contents of a packed package tarball. | Package smoke tests via `test-package.ts` | +| [`package-tarball-helpers.ts`](package-tarball/package-tarball-helpers.ts) | Shared path-validation helpers for package tarball checks. | Root unit tests and package smoke tests | diff --git a/scripts/changeset-add.ts b/scripts/changeset-add.ts index d6bbfa3..7b9b316 100644 --- a/scripts/changeset-add.ts +++ b/scripts/changeset-add.ts @@ -14,7 +14,7 @@ const summary = summaryParts.join(" ").trim(); if (!packageName || !type || !types.has(type) || !summary) { console.error( - 'Usage: bun run changeset-add -- "summary"' + 'Usage: bun run changeset-add "summary"' ); process.exit(1); } diff --git a/scripts/check-coverage.ts b/scripts/check-coverage.ts index 8993203..9dae1aa 100644 --- a/scripts/check-coverage.ts +++ b/scripts/check-coverage.ts @@ -1,3 +1,5 @@ +import { readFile } from "node:fs/promises"; + const [coveragePath, thresholdText] = process.argv.slice(2); const threshold = Number(thresholdText); @@ -7,7 +9,7 @@ if (!coveragePath || !Number.isFinite(threshold)) { ); } -const report = await Bun.file(coveragePath).text(); +const report = await readFile(coveragePath, "utf-8"); const hits = [...report.matchAll(/^DA:\d+,(?\d+)$/gmu)].map((match) => Number(match.groups?.hits) ); diff --git a/packages/opencode-usage-limits/__tests__/package-tarball.test.ts b/scripts/package-tarball/__tests__/package-tarball.test.ts similarity index 93% rename from packages/opencode-usage-limits/__tests__/package-tarball.test.ts rename to scripts/package-tarball/__tests__/package-tarball.test.ts index 513ebed..58f510d 100644 --- a/packages/opencode-usage-limits/__tests__/package-tarball.test.ts +++ b/scripts/package-tarball/__tests__/package-tarball.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { isUnexpectedPackagePath } from "../../../scripts/package-tarball-helpers.ts"; +import { isUnexpectedPackagePath } from "@/package-tarball/package-tarball-helpers.ts"; describe("package tarball paths", () => { it("rejects package source, test, and script directories", () => { diff --git a/scripts/check-package-tarball.ts b/scripts/package-tarball/check.ts similarity index 51% rename from scripts/check-package-tarball.ts rename to scripts/package-tarball/check.ts index 11a3e2f..bde5cb1 100644 --- a/scripts/check-package-tarball.ts +++ b/scripts/package-tarball/check.ts @@ -1,11 +1,20 @@ +import { execFileSync } from "node:child_process"; import { appendFileSync, readFileSync } from "node:fs"; import path from "node:path"; -import { isUnexpectedPackagePath } from "./package-tarball-helpers.ts"; +import { isUnexpectedPackagePath } from "@/package-tarball/package-tarball-helpers.ts"; +import type { JsonValue } from "@/scripts/shared/json-value.ts"; +import { + parseJsonArray, + parseJsonNumber, + parseJsonObject, + parseJsonString, +} from "@/scripts/shared/json-value.ts"; +import { getNodeExecutablePath } from "@/scripts/shared/node-executable.ts"; +import { getNpmCliPath } from "@/scripts/shared/npm-cli.ts"; interface PackageManifest { name: string; - files?: string[]; } interface PackedFile { @@ -19,45 +28,70 @@ interface PackResult { unpackedSize: number; } -const npmPath = Bun.which("npm"); -if (!npmPath) { - throw new Error("npm is required to validate package tarballs."); -} +const parsePackageManifest = (value: JsonValue): PackageManifest => { + const manifest = parseJsonObject(value, "Package manifest"); + return { name: parseJsonString(manifest.get("name"), "Package name") }; +}; + +const parsePackResult = (value: JsonValue): PackResult => { + const result = parseJsonObject(value, "npm pack result"); + const files = parseJsonArray(result.get("files"), "npm pack files").map( + (file, index) => { + const packedFile = parseJsonObject(file, `npm pack file ${index}`); + return { + path: parseJsonString( + packedFile.get("path"), + `npm pack file ${index} path` + ), + size: parseJsonNumber( + packedFile.get("size"), + `npm pack file ${index} size` + ), + }; + } + ); + return { + files, + size: parseJsonNumber(result.get("size"), "npm pack size"), + unpackedSize: parseJsonNumber( + result.get("unpackedSize"), + "npm pack unpacked size" + ), + }; +}; -export const checkPackageTarball = async ( - packageDirectory: string -): Promise => { +export const checkPackageTarball = (packageDirectory: string): void => { const absolutePackageDirectory = path.resolve( - import.meta.dir, + import.meta.dirname, + "..", "..", packageDirectory ); const manifestPath = path.join(absolutePackageDirectory, "package.json"); - // SAFETY: Package-specific manifest and required-file checks verify this structure. - const manifest = JSON.parse( + const manifestValue: JsonValue = JSON.parse( readFileSync(manifestPath, "utf-8") - ) as PackageManifest; - const childProcess = Bun.spawn( - [npmPath, "pack", "--dry-run", "--json", "--ignore-scripts"], - { cwd: absolutePackageDirectory, stderr: "inherit", stdout: "pipe" } ); - const output = await new Response(childProcess.stdout).text(); - if ((await childProcess.exited) !== 0) { - throw new Error(`npm pack failed for ${manifest.name}.`); - } - // SAFETY: npm pack --json returns either a result array or a workspace map with the fields declared here. - const parsed = JSON.parse(output) as - | PackResult[] - | Record; - let pack: PackResult | undefined; + const manifest = parsePackageManifest(manifestValue); + const output = execFileSync( + getNodeExecutablePath(), + [getNpmCliPath(), "pack", "--dry-run", "--json", "--ignore-scripts"], + { + cwd: absolutePackageDirectory, + encoding: "utf-8", + stdio: ["ignore", "pipe", "inherit"], + } + ); + const parsed: JsonValue = JSON.parse(output); + let packValue: JsonValue; if (Array.isArray(parsed)) { - [pack] = parsed; + [packValue] = parsed; } else { - pack = parsed[manifest.name]; + packValue = parseJsonObject(parsed, "npm pack response").get(manifest.name); } - if (!pack || !Array.isArray(pack.files)) { + if (packValue === undefined) { throw new Error(`npm pack returned no result for ${manifest.name}`); } + const pack = parsePackResult(packValue); const packedPaths = new Set(pack.files.map(({ path: filePath }) => filePath)); const requiredFiles = manifest.name === "@mynameistito/opencode-force-input" diff --git a/scripts/package-tarball-helpers.ts b/scripts/package-tarball/package-tarball-helpers.ts similarity index 100% rename from scripts/package-tarball-helpers.ts rename to scripts/package-tarball/package-tarball-helpers.ts diff --git a/scripts/run-workspaces.ts b/scripts/run-workspaces.ts deleted file mode 100644 index df3750b..0000000 --- a/scripts/run-workspaces.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { existsSync, readdirSync } from "node:fs"; -import path from "node:path"; - -const [task] = process.argv.slice(2); -const supportedTasks = [ - "build", - "check", - "fix", - "knip", - "test", - "test:package", - "typecheck", -] as const; - -const isSupportedTask = ( - value: string | undefined -): value is (typeof supportedTasks)[number] => - supportedTasks.some((supportedTask) => supportedTask === value); - -if (!isSupportedTask(task)) { - console.error( - `Usage: bun run scripts/run-workspaces.ts <${supportedTasks.join("|")}>` - ); - process.exit(1); -} - -const packageManifestFile = "package.json"; -const manifest: { workspaces?: string[] } = JSON.parse( - await Bun.file(packageManifestFile).text() -); -const workspacePatterns = manifest.workspaces ?? []; -const packages = workspacePatterns - .filter((pattern) => pattern.endsWith("/*")) - .flatMap((pattern) => { - const parentDirectory = pattern.slice(0, -2); - return readdirSync(parentDirectory, { withFileTypes: true }) - .filter( - (entry) => - entry.isDirectory() && - existsSync(path.join(parentDirectory, entry.name, "package.json")) - ) - .map((entry) => path.join(parentDirectory, entry.name)); - }) - .toSorted(); - -const packageManifests = await Promise.all( - packages.map(async (packageDirectory) => { - const packageManifest: { scripts?: Record } = JSON.parse( - await Bun.file(path.join(packageDirectory, packageManifestFile)).text() - ); - return { directory: packageDirectory, manifest: packageManifest }; - }) -); - -for (const { - directory: packageDirectory, - manifest: packageManifest, -} of packageManifests) { - if (packageManifest.scripts?.[task] === undefined) { - continue; - } - - console.log(`\n==> ${packageDirectory} ${task}`); - const result = Bun.spawnSync(["bun", "run", task], { - cwd: path.resolve(packageDirectory), - stderr: "inherit", - stdout: "inherit", - }); - - if (result.exitCode !== 0) { - process.exit(result.exitCode); - } -} diff --git a/scripts/shared/json-value.ts b/scripts/shared/json-value.ts new file mode 100644 index 0000000..ca720bb --- /dev/null +++ b/scripts/shared/json-value.ts @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; + +/** A JSON value projected into a recursive TypeScript representation. */ +export type JsonValue = + | undefined + | null + | boolean + | number + | string + | JsonValue[] + | JsonObject; + +/** A JSON object whose members retain their recursive JSON value types. */ +export type JsonObject = ReadonlyMap; + +/** Parse a JSON value as a plain object. */ +export const parseJsonObject = ( + value: JsonValue, + context: string +): JsonObject => { + assert.notEqual(value, null, `${context} must be an object`); + assert.notEqual(value, undefined, `${context} must be an object`); + const object = new Object(value); + assert.equal( + Object.getPrototypeOf(object), + Object.prototype, + `${context} must be an object` + ); + + const result = new Map(); + for (const [key, entry] of Object.entries(object)) { + result.set(key, entry); + } + return result; +}; + +/** Parse a JSON value as a string. */ +export const parseJsonString = (value: JsonValue, context: string): string => { + assert.equal( + Object.prototype.toString.call(value), + "[object String]", + `${context} must be a string` + ); + return String(value); +}; + +/** Parse a JSON value as a finite number. */ +export const parseJsonNumber = (value: JsonValue, context: string): number => { + assert.equal( + Object.prototype.toString.call(value), + "[object Number]", + `${context} must be a number` + ); + const number = Number(value); + assert.ok(Number.isFinite(number), `${context} must be finite`); + return number; +}; + +/** Parse a JSON value as an array. */ +export const parseJsonArray = ( + value: JsonValue, + context: string +): JsonValue[] => { + assert.ok(Array.isArray(value), `${context} must be an array`); + return value; +}; diff --git a/scripts/shared/node-executable.ts b/scripts/shared/node-executable.ts new file mode 100644 index 0000000..3ec612e --- /dev/null +++ b/scripts/shared/node-executable.ts @@ -0,0 +1,31 @@ +import { existsSync, statSync } from "node:fs"; +import path from "node:path"; + +/** Resolve Node from the explicit workflow setting or the local PATH. */ +export const getNodeExecutablePath = (): string => { + const configuredPath = process.env.NODE_EXECUTABLE; + if (configuredPath) { + if (!path.isAbsolute(configuredPath) || !existsSync(configuredPath)) { + throw new Error("NODE_EXECUTABLE must be an existing absolute path."); + } + return configuredPath; + } + + if (/^node(?:\.exe)?$/iu.test(path.basename(process.execPath))) { + return process.execPath; + } + + const pathDirectories = process.env.PATH?.split(path.delimiter) ?? []; + const executableNames = + process.platform === "win32" ? ["node.exe", "node"] : ["node"]; + for (const directory of pathDirectories) { + for (const executableName of executableNames) { + const candidate = path.resolve(directory, executableName); + if (existsSync(candidate) && statSync(candidate).isFile()) { + return candidate; + } + } + } + + throw new Error("Node executable was not found. Set NODE_EXECUTABLE."); +}; diff --git a/scripts/shared/npm-cli.ts b/scripts/shared/npm-cli.ts new file mode 100644 index 0000000..8f63c73 --- /dev/null +++ b/scripts/shared/npm-cli.ts @@ -0,0 +1,28 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; + +import { getNodeExecutablePath } from "@/scripts/shared/node-executable.ts"; + +/** Resolve the npm CLI shipped alongside the active Node executable. */ +export const getNpmCliPath = (): string => { + const nodeDirectory = path.dirname(getNodeExecutablePath()); + const candidates = [ + path.join(nodeDirectory, "node_modules", "npm", "bin", "npm-cli.js"), + path.resolve( + nodeDirectory, + "..", + "lib", + "node_modules", + "npm", + "bin", + "npm-cli.js" + ), + ]; + const npmCliPath = candidates.find(existsSync); + + if (!npmCliPath) { + throw new Error("The active Node installation does not include npm."); + } + + return npmCliPath; +}; diff --git a/tsconfig.json b/tsconfig.json index fcb574a..02e890f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,12 @@ "extends": "./tsconfig.base.json", "compilerOptions": { "types": ["node", "bun"], + "paths": { + "@/github/*": ["./.github/scripts/*"], + "@/package-tarball/*": ["./scripts/package-tarball/*"], + "@/scripts/*": ["./scripts/*"] + }, "strictNullChecks": true }, - "include": ["scripts/**/*.ts"] + "include": ["scripts/**/*.ts", ".github/scripts/**/*.ts"] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..3ba4241 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,15 @@ +import { fileURLToPath } from "node:url"; + +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { + "@/github": fileURLToPath(new URL(".github/scripts", import.meta.url)), + "@/package-tarball": fileURLToPath( + new URL("scripts/package-tarball", import.meta.url) + ), + "@/scripts": fileURLToPath(new URL("scripts", import.meta.url)), + }, + }, +}); From 8c79d3b2c9f0459838558e95b826226d9b03d5ac Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Tue, 6 Oct 2026 23:55:08 +1300 Subject: [PATCH 02/17] chore: sync lockfile with dependency overrides --- bun.lock | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index 519146e..82930fb 100644 --- a/bun.lock +++ b/bun.lock @@ -139,7 +139,6 @@ "lodash": "4.18.1", "lodash-es": "4.18.1", }, - "effect": "4.0.0", }, "catalog": { "@effect/platform-bun": "4.0.0", @@ -695,6 +694,18 @@ "@mrleebo/prisma-ast": ["@mrleebo/prisma-ast@0.13.1", "", { "dependencies": { "chevrotain": "^10.5.0", "lilconfig": "^2.1.0" } }, "sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw=="], + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], + + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="], + + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="], + + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], + "@mynameistito/opencode-force-input": ["@mynameistito/opencode-force-input@workspace:packages/opencode-force-input"], "@mynameistito/opencode-plugins-docs": ["@mynameistito/opencode-plugins-docs@workspace:apps/web"], @@ -2015,6 +2026,8 @@ "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], + "fast-check": ["fast-check@4.10.2", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="], @@ -2637,6 +2650,10 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "msgpackr": ["msgpackr@2.1.0", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ=="], + + "msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="], + "muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="], "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], @@ -2665,6 +2682,8 @@ "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], + "node-html-parser": ["node-html-parser@9.0.4", "", { "dependencies": { "css-select": "^5.1.0", "entities": "^8.0.0" } }, "sha512-reUSrZkF1rO7hEYe5mJwtbVcQ6mB5QrViCZH6Wv+iz3er/PA9Q50gol15dMLQRDfdli4VzqPMPR24HNRFgVKFw=="], "node-mock-http": ["node-mock-http@1.0.5", "", {}, "sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw=="], @@ -2867,6 +2886,8 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="], + "qified": ["qified@0.10.1", "", { "dependencies": { "hookified": "^2.1.1" } }, "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA=="], "qs": ["qs@6.16.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA=="], @@ -3467,8 +3488,18 @@ "@octokit/request/content-type": ["content-type@3.1.1", "", {}, "sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA=="], + "@opencode/ai/effect": ["effect@4.0.0-rc.112", "", { "dependencies": { "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A=="], + + "@opencode/plugin/effect": ["effect@4.0.0-rc.112", "", { "dependencies": { "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A=="], + "@opencode/plugin/zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + "@opencode/protocol/effect": ["effect@4.0.0-rc.112", "", { "dependencies": { "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A=="], + + "@opencode/schema/effect": ["effect@4.0.0-rc.112", "", { "dependencies": { "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A=="], + + "@opencode/util/effect": ["effect@4.0.0-rc.112", "", { "dependencies": { "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A=="], + "@opencode/util/glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], "@opencode/util/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], From 77490b612a6211112903ff2e3a1f984724d173df Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Wed, 7 Oct 2026 00:04:03 +1300 Subject: [PATCH 03/17] chore: update security audit suppression line --- .github/zizmor.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/zizmor.yml b/.github/zizmor.yml index 08ed5a3..361c48e 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -13,4 +13,4 @@ rules: - deploy-preview.yml:21 adhoc-packages: ignore: - - release.yml:35 + - release.yml:37 From 3fe3aa0efbd71867a7b8aab9ccfc8b364b9fce90 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Wed, 7 Oct 2026 00:07:28 +1300 Subject: [PATCH 04/17] fix: keep release workflow security audit clean --- .github/workflows/release.yml | 2 -- .github/zizmor.yml | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6f3db4a..7cb62bf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,8 +31,6 @@ jobs: node-version-file: .node-version registry-url: https://registry.npmjs.org package-manager-cache: false - - name: Export Node executable path - run: echo "NODE_EXECUTABLE=$(node -p 'process.execPath')" >> "$GITHUB_ENV" - name: Upgrade npm run: npm install --global npm@11.15.0 - name: Verify npm staged publishing support diff --git a/.github/zizmor.yml b/.github/zizmor.yml index 361c48e..08ed5a3 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -13,4 +13,4 @@ rules: - deploy-preview.yml:21 adhoc-packages: ignore: - - release.yml:37 + - release.yml:35 From e766ec1c9576f20e0213bc448d0ed236376abf84 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Wed, 7 Oct 2026 00:50:30 +1300 Subject: [PATCH 05/17] fix: use scoped GitHub App token for releases --- .github/workflows/release.yml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7cb62bf..f239975 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,6 +62,21 @@ jobs: echo "title=$title" >> "$GITHUB_OUTPUT" - name: Stage packages on npm run: bun .github/scripts/stage-packages.ts + - name: Load GitHub App credentials + id: onepassword + uses: 1Password/load-secrets-action@70062d7a876d3eb6334754fa26efd2fbd90c32f2 # v5 + env: + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} + GH_APP_ID: op://uhub4xx3iwwsaxj3robdosm2hu/spbjca2lethbsiz6menimq7afm/ID + GH_APP_PRIVATE_KEY: op://uhub4xx3iwwsaxj3robdosm2hu/spbjca2lethbsiz6menimq7afm/credential + - name: Create repository-scoped App token + id: app-token + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 + with: + app-id: ${{ steps.onepassword.outputs.GH_APP_ID }} + private-key: ${{ steps.onepassword.outputs.GH_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} - name: Create release pull request or publish id: changesets uses: changesets/action@ae32849d5ba541f9ae29e40e22a623bc13562f51 # v2.1.2 @@ -71,5 +86,5 @@ jobs: pr-title: ${{ steps.version-title.outputs.title }} commit-message: "chore: release packages" env: - GITHUB_TOKEN: ${{ secrets.CHANGESETS_GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} GIT_EXECUTABLE: /usr/bin/git From c39c0f0d21b451cbf2425ac5f03d2b8f53a2ad3a Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Wed, 7 Oct 2026 00:54:07 +1300 Subject: [PATCH 06/17] fix: pin GitHub App token permissions --- .github/workflows/release.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f239975..78c0963 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -65,6 +65,8 @@ jobs: - name: Load GitHub App credentials id: onepassword uses: 1Password/load-secrets-action@70062d7a876d3eb6334754fa26efd2fbd90c32f2 # v5 + with: + version: 2.39.0 env: OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} GH_APP_ID: op://uhub4xx3iwwsaxj3robdosm2hu/spbjca2lethbsiz6menimq7afm/ID @@ -77,6 +79,8 @@ jobs: private-key: ${{ steps.onepassword.outputs.GH_APP_PRIVATE_KEY }} owner: ${{ github.repository_owner }} repositories: ${{ github.event.repository.name }} + permission-contents: write + permission-pull-requests: write - name: Create release pull request or publish id: changesets uses: changesets/action@ae32849d5ba541f9ae29e40e22a623bc13562f51 # v2.1.2 From 7e259889ad758b8c5cd1a8567fca1cccc218c930 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Wed, 7 Oct 2026 01:08:45 +1300 Subject: [PATCH 07/17] docs: escape pipes in changeset command table --- scripts/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/README.md b/scripts/README.md index 2a5bf35..611877b 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -6,7 +6,7 @@ This directory contains shared repository utilities. Bun's native workspace filt | Script | Purpose | Used by | | --- | --- | --- | -| [`changeset-add.ts`](changeset-add.ts) | Creates a Changeset for one of the supported packages. | `bun run changeset-add "summary"` | +| [`changeset-add.ts`](changeset-add.ts) | Creates a Changeset for one of the supported packages. | `bun run changeset-add "summary"` | | [`check-coverage.ts`](check-coverage.ts) | Checks an LCOV report against a minimum coverage threshold. | Package `test` scripts | ### Shared script helpers From 2a070f478a4fec0c938f426f1680cbb72a6226d0 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Wed, 7 Oct 2026 01:16:27 +1300 Subject: [PATCH 08/17] fix: pass app token to changesets action --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 78c0963..25ac48b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -87,8 +87,8 @@ jobs: with: version-script: bunx changeset version publish-script: bun .github/scripts/create-git-tag.ts + github-token: ${{ steps.app-token.outputs.token }} pr-title: ${{ steps.version-title.outputs.title }} commit-message: "chore: release packages" env: - GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} GIT_EXECUTABLE: /usr/bin/git From 94581b39296be089392e530ddea692ae955b9d7e Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Wed, 7 Oct 2026 01:23:43 +1300 Subject: [PATCH 09/17] chore: add workflow schemas --- .github/workflows/ci.yml | 2 ++ .github/workflows/deploy-preview.yml | 2 ++ .github/workflows/deploy.yml | 2 ++ .github/workflows/link-check.yml | 2 ++ .github/workflows/pr-metadata.yml | 2 ++ .github/workflows/release.yml | 2 ++ .github/workflows/update-opencode-plugin.yml | 2 ++ .github/workflows/workflow-security.yml | 2 ++ 8 files changed, 16 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ffca32..a309b8f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,3 +1,5 @@ +# yaml-language-server: $schema=https://www.schemastore.org/github-workflow.json + name: CI on: diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml index 7d49a25..0a4d75b 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -1,3 +1,5 @@ +# yaml-language-server: $schema=https://www.schemastore.org/github-workflow.json + name: Deploy documentation preview on: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index cb26264..9d65e01 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,3 +1,5 @@ +# yaml-language-server: $schema=https://www.schemastore.org/github-workflow.json + name: Deploy documentation on: diff --git a/.github/workflows/link-check.yml b/.github/workflows/link-check.yml index 42508f0..39798aa 100644 --- a/.github/workflows/link-check.yml +++ b/.github/workflows/link-check.yml @@ -1,3 +1,5 @@ +# yaml-language-server: $schema=https://www.schemastore.org/github-workflow.json + name: Documentation links on: diff --git a/.github/workflows/pr-metadata.yml b/.github/workflows/pr-metadata.yml index 3e712db..9e15e53 100644 --- a/.github/workflows/pr-metadata.yml +++ b/.github/workflows/pr-metadata.yml @@ -1,3 +1,5 @@ +# yaml-language-server: $schema=https://www.schemastore.org/github-workflow.json + name: PR metadata on: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 25ac48b..4534536 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,3 +1,5 @@ +# yaml-language-server: $schema=https://www.schemastore.org/github-workflow.json + name: Release on: diff --git a/.github/workflows/update-opencode-plugin.yml b/.github/workflows/update-opencode-plugin.yml index 4f9df64..ba866f1 100644 --- a/.github/workflows/update-opencode-plugin.yml +++ b/.github/workflows/update-opencode-plugin.yml @@ -1,3 +1,5 @@ +# yaml-language-server: $schema=https://www.schemastore.org/github-workflow.json + name: Update @opencode/plugin on: diff --git a/.github/workflows/workflow-security.yml b/.github/workflows/workflow-security.yml index 48aa665..a126585 100644 --- a/.github/workflows/workflow-security.yml +++ b/.github/workflows/workflow-security.yml @@ -1,3 +1,5 @@ +# yaml-language-server: $schema=https://www.schemastore.org/github-workflow.json + name: Workflow security on: From d8478405cb57fbce1cb1ed4cf4cf3128de7e8187 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Wed, 7 Oct 2026 01:25:19 +1300 Subject: [PATCH 10/17] chore: add zizmor schema --- .github/zizmor.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/zizmor.yml b/.github/zizmor.yml index 08ed5a3..1349a2a 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -1,3 +1,5 @@ +# yaml-language-server: $schema=https://www.schemastore.org/zizmor.json + # These findings are limited to existing, intentionally reviewed workflow # patterns. Keep the ignores line-specific so new findings remain visible. rules: From 2328b7b2ecc30b284baff7e16922c1a05b8f2c0b Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Wed, 7 Oct 2026 01:28:34 +1300 Subject: [PATCH 11/17] chore: add github issue template schemas --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 ++ .github/ISSUE_TEMPLATE/config.yml | 2 ++ .github/ISSUE_TEMPLATE/feature_request.yml | 2 ++ 3 files changed, 6 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 7556cda..11b4958 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,3 +1,5 @@ +# yaml-language-server: $schema=https://www.schemastore.org/github-issue-forms.json + name: Bug report description: Report a reproducible problem with OpenCode V2 or one of these plugins. title: "bug: " diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index bc005de..6f5e0ab 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,3 +1,5 @@ +# yaml-language-server: $schema=https://www.schemastore.org/github-issue-config.json + blank_issues_enabled: true contact_links: - name: OpenCode V2 documentation diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index b64d916..a90f8c9 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,3 +1,5 @@ +# yaml-language-server: $schema=https://www.schemastore.org/github-issue-forms.json + name: Feature request description: Suggest an improvement to OpenCode V2 support or one of these plugins. title: "feature: " From cd3e4d2012e0b58abd1192f745659cf95b668219 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Wed, 7 Oct 2026 01:59:53 +1300 Subject: [PATCH 12/17] refactor: centralize automation scripts --- .github/scripts/announce-preview-approval.ts | 48 ++++ .github/scripts/create-git-tag.ts | 4 +- .../scripts/create-signed-github-commit.ts | 120 +++++--- .github/scripts/github-api.ts | 129 +++++++++ .github/scripts/pr-metadata-helpers.ts | 2 +- .../scripts/resolve-preview-pull-request.ts | 264 ++++++++++++++++++ .github/scripts/semver.ts | 4 +- .github/scripts/update-opencode-plugin.ts | 48 ++-- .github/scripts/update-pr-metadata.ts | 83 +++--- .../scripts/verify-npm-staged-publishing.ts | 15 + .../scripts/verify-preview-author-access.ts | 74 +++++ .github/workflows/deploy-preview.yml | 230 +++------------ .github/workflows/release.yml | 13 +- scripts/changeset-add.ts | 3 +- scripts/package-tarball/check.ts | 50 ++-- scripts/shared/json-value.ts | 6 +- 16 files changed, 747 insertions(+), 346 deletions(-) create mode 100644 .github/scripts/announce-preview-approval.ts create mode 100644 .github/scripts/github-api.ts create mode 100644 .github/scripts/resolve-preview-pull-request.ts create mode 100644 .github/scripts/verify-npm-staged-publishing.ts create mode 100644 .github/scripts/verify-preview-author-access.ts diff --git a/.github/scripts/announce-preview-approval.ts b/.github/scripts/announce-preview-approval.ts new file mode 100644 index 0000000..3331a6b --- /dev/null +++ b/.github/scripts/announce-preview-approval.ts @@ -0,0 +1,48 @@ +import { + asNumber, + asOptionalString, + asRecord, + githubPaginate, + githubRepository, + githubRequest, +} from "@/github/github-api.ts"; +import type { JsonValue } from "@/scripts/shared/json-value.ts"; +import { getJsonField } from "@/scripts/shared/json-value.ts"; + +interface Comment { + id: number; + body: string | null; +} + +const parseComment = (value: JsonValue): Comment => { + const comment = asRecord(value); + return { + body: asOptionalString(getJsonField(comment, "body")), + id: asNumber(getJsonField(comment, "id")), + }; +}; + +const pullRequestNumber = Number(process.env.PULL_REQUEST_NUMBER); +if (!Number.isInteger(pullRequestNumber)) { + throw new TypeError("The preview pull request number is missing."); +} + +const serverUrl = process.env.GITHUB_SERVER_URL; +const runId = process.env.GITHUB_RUN_ID; +if (!serverUrl || !runId) { + throw new Error("GITHUB_SERVER_URL and GITHUB_RUN_ID are required."); +} + +const marker = ""; +const runUrl = `${serverUrl}/${githubRepository.owner}/${githubRepository.repo}/actions/runs/${runId}`; +const body = `${marker}\nThe documentation preview is waiting for approval. [Open the workflow run and click **Review deployments**](${runUrl}).`; +const path = `/repos/${githubRepository.owner}/${githubRepository.repo}/issues/${pullRequestNumber}/comments`; +const comments = await githubPaginate(path, parseComment); +const existing = + comments.find((comment) => comment.body?.includes(marker)) ?? null; + +await githubRequest(existing ? `${path}/${existing.id}` : path, parseComment, { + body: JSON.stringify({ body }), + headers: { "Content-Type": "application/json" }, + method: existing ? "PATCH" : "POST", +}); diff --git a/.github/scripts/create-git-tag.ts b/.github/scripts/create-git-tag.ts index 6980649..bec8fcc 100644 --- a/.github/scripts/create-git-tag.ts +++ b/.github/scripts/create-git-tag.ts @@ -29,8 +29,8 @@ const existingTags = new Set( encoding: "utf-8", }) .split("\n") - .map((line) => line.match(/refs\/tags\/(?.+)$/u)?.groups?.tag) - .filter((tag): tag is string => tag !== undefined && !tag.endsWith("^{}")) + .map((line) => line.match(/refs\/tags\/(?.+)$/u)?.groups?.tag ?? "") + .filter((tag) => tag.length > 0 && !tag.endsWith("^{}")) ); execFileSync( diff --git a/.github/scripts/create-signed-github-commit.ts b/.github/scripts/create-signed-github-commit.ts index ddc8570..940452b 100644 --- a/.github/scripts/create-signed-github-commit.ts +++ b/.github/scripts/create-signed-github-commit.ts @@ -1,14 +1,20 @@ import { readFile, readdir } from "node:fs/promises"; +import type { JsonValue } from "@/scripts/shared/json-value.ts"; +import { + parseJsonObject, + parseJsonString, +} from "@/scripts/shared/json-value.ts"; + interface ApiResponse { - data?: unknown; - errors?: unknown; - message?: string; - node_id?: string; - sha?: string; - tree?: { sha: string }; - verification?: { verified: boolean; reason: string }; - object?: { sha: string }; + data: JsonValue | null; + errors: JsonValue | null; + message: string | null; + node_id: string | null; + sha: string | null; + tree: { sha: string } | null; + verification: { verified: boolean; reason: string } | null; + object: { sha: string } | null; } interface CreateTreeRequest { @@ -29,17 +35,54 @@ interface CreateCommitRequest { type ApiRequestBody = CreateTreeRequest | CreateCommitRequest; -const isApiResponse = (value: unknown): value is ApiResponse => - value instanceof Object && !Array.isArray(value); - -const hasUpdateRefs = (response: ApiResponse | undefined): boolean => { - const { data } = response ?? {}; +const parseNullableString = (value: JsonValue): string | null => + value === null ? null : parseJsonString(value, "GitHub response string"); + +const parseApiResponse = (value: JsonValue): ApiResponse => { + const response = parseJsonObject(value, "GitHub API response"); + const treeValue = response.get("tree") ?? null; + const verificationValue = response.get("verification") ?? null; + const objectValue = response.get("object") ?? null; + const tree = + treeValue === null ? null : parseJsonObject(treeValue, "Git tree"); + const verification = + verificationValue === null + ? null + : parseJsonObject(verificationValue, "Commit verification"); + const refObject = + objectValue === null + ? null + : parseJsonObject(objectValue, "Git ref object"); + return { + data: response.get("data") ?? null, + errors: response.get("errors") ?? null, + message: parseNullableString(response.get("message") ?? null), + node_id: parseNullableString(response.get("node_id") ?? null), + object: refObject + ? { sha: parseJsonString(refObject.get("sha") ?? null, "Git object SHA") } + : null, + sha: parseNullableString(response.get("sha") ?? null), + tree: tree + ? { sha: parseJsonString(tree.get("sha") ?? null, "Tree SHA") } + : null, + verification: verification + ? { + reason: parseJsonString( + verification.get("reason") ?? null, + "Verification reason" + ), + verified: verification.get("verified") === true, + } + : null, + }; +}; - if (!(data instanceof Object) || Array.isArray(data)) { +const hasUpdateRefs = (response: ApiResponse | null): boolean => { + if (response === null || response.data === null) { return false; } - - return "updateRefs" in data && Boolean(data.updateRefs); + const data = parseJsonObject(response.data, "GraphQL response data"); + return data.has("updateRefs") && data.get("updateRefs") !== null; }; const token = process.env.GH_TOKEN; @@ -56,7 +99,7 @@ if (!token || !repository || !version || !mainSha) { const request = async ( path: string, method: "GET" | "POST" | "PATCH", - body?: ApiRequestBody + body: ApiRequestBody | null ): Promise => { const headers = new Headers({ Accept: "application/vnd.github+json", @@ -65,7 +108,7 @@ const request = async ( }); const init: RequestInit = { headers, method }; - if (body) { + if (body !== null) { headers.set("Content-Type", "application/json"); init.body = JSON.stringify(body); } @@ -75,23 +118,15 @@ const request = async ( init ); - const result: unknown = await response.json(); + const result: JsonValue = JSON.parse(await response.text()); + const parsed = parseApiResponse(result); if (!response.ok) { - const message = - isApiResponse(result) && result.message - ? String(result.message) - : response.statusText; + const message = parsed.message || response.statusText; throw new Error(`GitHub API ${method} ${path} failed: ${message}`); } - if (!isApiResponse(result)) { - throw new Error( - `GitHub API ${method} ${path} returned an invalid response` - ); - } - - return result; + return parsed; }; const changesetDirectoryEntries = await readdir(".changeset", { @@ -104,7 +139,7 @@ const changedFiles = ["package.json", "bun.lock", ...changesetFiles]; const baseSha = mainSha; -const baseCommit = await request(`/git/commits/${baseSha}`, "GET"); +const baseCommit = await request(`/git/commits/${baseSha}`, "GET", null); if (!baseCommit.tree?.sha) { throw new Error("GitHub API did not return the base tree SHA"); @@ -136,22 +171,22 @@ const commit = await request("/git/commits", "POST", { if (!commit.sha || !commit.verification?.verified) { throw new Error( - `GitHub did not verify the update commit (reason: ${commit.verification?.reason ?? "unknown"})` + `GitHub did not verify the update commit (reason: ${commit.verification?.reason ?? "not provided"})` ); } const refPath = "/git/ref/heads/update-opencode-plugin"; -let existingRef: ApiResponse | undefined; +let existingRef: ApiResponse | null = null; try { - existingRef = await request(refPath, "GET"); + existingRef = await request(refPath, "GET", null); } catch (error) { if (!(error instanceof Error) || !error.message.includes("Not Found")) { throw error; } } -const repositoryInfo = await request("", "GET"); +const repositoryInfo = await request("", "GET", null); if (!repositoryInfo.node_id) { throw new Error("GitHub API did not return the repository node ID"); @@ -182,14 +217,13 @@ const refUpdateResponse = await fetch("https://api.github.com/graphql", { }, method: "POST", }); -const refUpdateResult: unknown = await refUpdateResponse.json(); - -const graphQLResult = isApiResponse(refUpdateResult) - ? refUpdateResult - : undefined; -const hasGraphQLErrors = Array.isArray(graphQLResult?.errors) - ? graphQLResult.errors.length > 0 - : false; +const graphQLResult = parseApiResponse( + JSON.parse(await refUpdateResponse.text()) +); +const hasGraphQLErrors = + graphQLResult.errors !== null && Array.isArray(graphQLResult.errors) + ? graphQLResult.errors.length > 0 + : false; if ( !refUpdateResponse.ok || diff --git a/.github/scripts/github-api.ts b/.github/scripts/github-api.ts new file mode 100644 index 0000000..9df91cd --- /dev/null +++ b/.github/scripts/github-api.ts @@ -0,0 +1,129 @@ +import { appendFile, readFile } from "node:fs/promises"; + +import type { JsonObject, JsonValue } from "@/scripts/shared/json-value.ts"; +import { + parseJsonArray, + parseJsonNumber, + parseJsonObject, + parseJsonString, +} from "@/scripts/shared/json-value.ts"; + +const apiUrl = process.env.GITHUB_API_URL ?? "https://api.github.com"; +const apiResponseContext = "GitHub API response"; +const token = process.env.GITHUB_TOKEN; +const repository = process.env.GITHUB_REPOSITORY; + +if (!token || !repository) { + throw new Error("GITHUB_TOKEN and GITHUB_REPOSITORY are required."); +} + +const [owner, repo] = repository.split("/"); + +if (!owner || !repo) { + throw new Error(`Invalid GITHUB_REPOSITORY: ${repository}`); +} + +export const githubRepository = { owner, repo }; + +export const githubRequest = async function githubRequest( + path: string, + decode: (value: JsonValue) => T, + init?: RequestInit +): Promise { + const response = await fetch(`${apiUrl}${path}`, { + ...init, + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + ...init?.headers, + }, + signal: AbortSignal.timeout(30_000), + }); + + if (!response.ok) { + throw new Error( + `GitHub API ${path} returned ${response.status}: ${await response.text()}` + ); + } + + const value: JsonValue = JSON.parse(await response.text()); + return decode(value); +}; + +export const githubPaginate = function githubPaginate( + path: string, + decode: (value: JsonValue) => T +): Promise { + const readPage = async (page: number, results: T[]): Promise => { + const separator = path.includes("?") ? "&" : "?"; + const pageResults = await githubRequest( + `${path}${separator}per_page=100&page=${page}`, + (value) => + parseJsonArray(value, "GitHub API pagination response").map(decode) + ); + if (pageResults.length < 100) { + return [...results, ...pageResults]; + } + return readPage(page + 1, [...results, ...pageResults]); + }; + + return readPage(1, []); +}; + +export const readEvent = async function readEvent(): Promise { + const eventPath = process.env.GITHUB_EVENT_PATH; + if (!eventPath) { + throw new Error("GITHUB_EVENT_PATH is required."); + } + const event: JsonValue = JSON.parse(await readFile(eventPath, "utf-8")); + return event; +}; + +export const asRecord = (value: JsonValue): JsonObject => + parseJsonObject(value, apiResponseContext); + +export const asString = (value: JsonValue): string => + parseJsonString(value, apiResponseContext); + +export const asNumber = (value: JsonValue): number => + parseJsonNumber(value, apiResponseContext); + +export const asOptionalRecord = ( + value: JsonValue | null +): JsonObject | null => { + if (value === null) { + return null; + } + return asRecord(value); +}; + +export const asOptionalString = (value: JsonValue | null): string | null => { + if (value === null) { + return null; + } + return asString(value); +}; + +export const asOptionalNumber = (value: JsonValue | null): number | null => { + if (value === null) { + return null; + } + return asNumber(value); +}; + +export const setOutput = async (name: string, value: string): Promise => { + const outputPath = process.env.GITHUB_OUTPUT; + if (!outputPath) { + throw new Error("GITHUB_OUTPUT is required."); + } + await appendFile(outputPath, `${name}=${value}\n`); +}; + +export const eventName = (): string => { + const name = process.env.GITHUB_EVENT_NAME; + if (!name) { + throw new Error("GITHUB_EVENT_NAME is required."); + } + return name; +}; diff --git a/.github/scripts/pr-metadata-helpers.ts b/.github/scripts/pr-metadata-helpers.ts index 3f354f2..167230b 100644 --- a/.github/scripts/pr-metadata-helpers.ts +++ b/.github/scripts/pr-metadata-helpers.ts @@ -97,7 +97,7 @@ export const getPullRequestFilePageCount = (changedFiles: number): number => { */ export const isChangesetReleasePR = ( headRef: string, - headRepository: string | null | undefined, + headRepository: string | null, baseRepository: string ): boolean => headRepository === baseRepository && headRef.startsWith("changeset-release/"); diff --git a/.github/scripts/resolve-preview-pull-request.ts b/.github/scripts/resolve-preview-pull-request.ts new file mode 100644 index 0000000..23667ef --- /dev/null +++ b/.github/scripts/resolve-preview-pull-request.ts @@ -0,0 +1,264 @@ +import { + asNumber, + asOptionalNumber, + asOptionalRecord, + asOptionalString, + asRecord, + asString, + eventName, + githubPaginate, + githubRepository, + githubRequest, + readEvent, + setOutput, +} from "@/github/github-api.ts"; +import type { JsonValue } from "@/scripts/shared/json-value.ts"; +import { getJsonField, parseJsonArray } from "@/scripts/shared/json-value.ts"; + +interface PullRequest { + number: number; + user: { login: string | null } | null; + base: { ref: string; repo: { id: number | null } | null }; + head: { + ref: string | null; + sha: string | null; + repo: { id: number | null } | null; + }; +} + +interface WorkflowRun { + head_repository: { + id: number | null; + owner: { login: string | null } | null; + } | null; + head_branch: string | null; + head_sha: string | null; + pull_requests: PullRequest[] | null; +} + +interface EventPayload { + repository: { id: number | null; default_branch: string | null } | null; + workflow_run: WorkflowRun | null; + pull_request: { number: number } | null; +} + +interface PermissionResponse { + permission: string | null; + role_name: string | null; +} + +const parsePullRequest = (value: JsonValue): PullRequest => { + const item = asRecord(value); + const base = asRecord(getJsonField(item, "base")); + const head = asRecord(getJsonField(item, "head")); + const user = asOptionalRecord(getJsonField(item, "user")); + const baseRepo = asOptionalRecord(getJsonField(base, "repo")); + const headRepo = asOptionalRecord(getJsonField(head, "repo")); + return { + base: { + ref: asString(getJsonField(base, "ref")), + repo: baseRepo + ? { id: asOptionalNumber(getJsonField(baseRepo, "id")) } + : null, + }, + head: { + ref: asOptionalString(getJsonField(head, "ref")), + repo: headRepo + ? { id: asOptionalNumber(getJsonField(headRepo, "id")) } + : null, + sha: asOptionalString(getJsonField(head, "sha")), + }, + number: asNumber(getJsonField(item, "number")), + user: user + ? { login: asOptionalString(getJsonField(user, "login")) } + : null, + }; +}; + +const parsePayload = (value: JsonValue): EventPayload => { + const item = asRecord(value); + const repository = asOptionalRecord(getJsonField(item, "repository")); + const workflowRun = asOptionalRecord(getJsonField(item, "workflow_run")); + const headRepository = workflowRun + ? asOptionalRecord(getJsonField(workflowRun, "head_repository")) + : null; + const headOwner = headRepository + ? asOptionalRecord(getJsonField(headRepository, "owner")) + : null; + const pullRequest = asOptionalRecord(getJsonField(item, "pull_request")); + const workflowPullRequests = workflowRun + ? getJsonField(workflowRun, "pull_requests") + : null; + return { + pull_request: pullRequest + ? { number: asNumber(getJsonField(pullRequest, "number")) } + : null, + repository: repository + ? { + default_branch: asOptionalString( + getJsonField(repository, "default_branch") + ), + id: asOptionalNumber(getJsonField(repository, "id")), + } + : null, + workflow_run: workflowRun + ? { + head_branch: asOptionalString( + getJsonField(workflowRun, "head_branch") + ), + head_repository: headRepository + ? { + id: asOptionalNumber(getJsonField(headRepository, "id")), + owner: headOwner + ? { + login: asOptionalString(getJsonField(headOwner, "login")), + } + : null, + } + : null, + head_sha: asOptionalString(getJsonField(workflowRun, "head_sha")), + pull_requests: + workflowPullRequests === null + ? null + : parseJsonArray( + workflowPullRequests, + "Workflow run pull_requests" + ).map(parsePullRequest), + } + : null, + }; +}; + +const parsePermissionResponse = (value: JsonValue): PermissionResponse => { + const item = asRecord(value); + return { + permission: asOptionalString(getJsonField(item, "permission")), + role_name: asOptionalString(getJsonField(item, "role_name")), + }; +}; + +const payload = parsePayload(await readEvent()); +const defaultBranch = payload.repository?.default_branch ?? null; +if (!defaultBranch) { + throw new Error( + "The repository default branch is missing from the event payload." + ); +} + +const workflowRun = payload.workflow_run; +const baseRepositoryId = String(payload.repository?.id ?? ""); +const headRepositoryId = String(workflowRun?.head_repository?.id ?? ""); +const headBranch = workflowRun?.head_branch ?? null; +const headSha = workflowRun?.head_sha ?? null; + +const matchesWorkflowRun = (candidate: PullRequest): boolean => + [ + String(candidate.head.repo?.id ?? "") === headRepositoryId, + candidate.head.ref === headBranch, + candidate.head.sha === headSha, + String(candidate.base.repo?.id ?? "") === baseRepositoryId, + candidate.base.ref === defaultBranch, + ].every(Boolean); + +let pullRequest: PullRequest | null = null; + +if (eventName() === "pull_request_target") { + const number = payload.pull_request?.number ?? null; + if (!number) { + throw new Error( + "The pull request number is missing from the event payload." + ); + } + pullRequest = await githubRequest( + `/repos/${githubRepository.owner}/${githubRepository.repo}/pulls/${number}`, + parsePullRequest + ); +} else { + if (!baseRepositoryId || !headRepositoryId || !headBranch || !headSha) { + throw new Error("The workflow run is missing pull request identity data."); + } + + const candidates = ( + Array.isArray(workflowRun?.pull_requests) ? workflowRun.pull_requests : [] + ).filter(matchesWorkflowRun); + if (candidates.length > 1) { + throw new Error( + `Expected one pull request for ${headBranch} at ${headSha}, found ${candidates.length}.` + ); + } + + if (candidates.length === 1) { + pullRequest = await githubRequest( + `/repos/${githubRepository.owner}/${githubRepository.repo}/pulls/${candidates[0]?.number}`, + parsePullRequest + ); + } else { + const headOwner = workflowRun?.head_repository?.owner?.login ?? null; + if (!headOwner) { + throw new Error( + "The workflow run is missing pull request head repository data." + ); + } + const query = new URLSearchParams({ + base: defaultBranch, + head: `${headOwner}:${headBranch}`, + state: "all", + }); + const pullRequests = await githubPaginate( + `/repos/${githubRepository.owner}/${githubRepository.repo}/pulls?${query}`, + parsePullRequest + ); + const matches = pullRequests.filter(matchesWorkflowRun); + + if (matches.length > 1) { + throw new Error( + `Expected one pull request for ${headOwner}:${headBranch} at ${headSha}, found ${matches.length}.` + ); + } + if (matches.length === 0) { + console.info( + `No pull request currently sits at head ${headSha} for ${headOwner}:${headBranch}; the branch moved on, so there is nothing to deploy for this run.` + ); + process.exit(0); + } + pullRequest = matches[0] ?? null; + } +} + +if (pullRequest === null || pullRequest.base.ref !== defaultBranch) { + throw new Error( + "The preview pull request does not target the default branch." + ); +} +if (eventName() === "workflow_run" && !matchesWorkflowRun(pullRequest)) { + console.info( + `The pull request head moved past ${headSha} while resolving; there is nothing to deploy for this run.` + ); + process.exit(0); +} + +const username = pullRequest.user?.login ?? null; +if (!username) { + throw new Error("The preview pull request author is missing."); +} + +let permission = "none"; +try { + const result = await githubRequest( + `/repos/${githubRepository.owner}/${githubRepository.repo}/collaborators/${encodeURIComponent(username)}/permission`, + parsePermissionResponse + ); + permission = result.permission ?? result.role_name ?? "none"; +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn( + `Could not verify ${username}'s repository access; treating the PR as untrusted: ${message}` + ); +} + +const trusted = ["admin", "write", "maintain", "push"].includes(permission); +console.info( + `Pull request #${pullRequest.number} author ${username} has ${permission} access.` +); +await setOutput("pull_request_number", String(pullRequest.number)); +await setOutput("trusted", String(trusted)); diff --git a/.github/scripts/semver.ts b/.github/scripts/semver.ts index 7af7b5b..2682f05 100644 --- a/.github/scripts/semver.ts +++ b/.github/scripts/semver.ts @@ -76,9 +76,9 @@ const comparePrereleaseIdentifier = (left: string, right: string): number => { const comparePrerelease = (left: string[], right: string[]): number => { for (const [index, leftIdentifier] of left.entries()) { - const rightIdentifier = right[index]; + const rightIdentifier = right[index] ?? null; - if (rightIdentifier === undefined) { + if (rightIdentifier === null) { return 1; } diff --git a/.github/scripts/update-opencode-plugin.ts b/.github/scripts/update-opencode-plugin.ts index f9a6a87..2ccfdc7 100644 --- a/.github/scripts/update-opencode-plugin.ts +++ b/.github/scripts/update-opencode-plugin.ts @@ -2,31 +2,25 @@ import { appendFile, readFile, writeFile } from "node:fs/promises"; import { isNewerSemVer, parseSerializedSemVer } from "@/github/semver.ts"; -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"); +interface PackageManifest { + catalog: Record; } -const { catalog } = packageJson; +interface PackageMetadata { + version: string; +} -if ( - !(catalog instanceof Object) || - Array.isArray(catalog) || - !("@opencode/plugin" in catalog) -) { - throw new TypeError("Root package.json must define a catalog object"); +const packageJsonPath = new URL("../../package.json", import.meta.url); +const packageJson: PackageManifest = JSON.parse( + await readFile(packageJsonPath, "utf-8") +); +const currentVersionText = packageJson.catalog["@opencode/plugin"]; +if (!currentVersionText) { + throw new TypeError("Root package catalog must define @opencode/plugin."); } const currentVersion = parseSerializedSemVer( - JSON.stringify(catalog["@opencode/plugin"]) ?? "", + currentVersionText, "Root package catalog" ); @@ -41,25 +35,21 @@ if (!response.ok) { ); } -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 metadata: PackageMetadata = JSON.parse(await response.text()); +const latestVersionText = metadata.version; +if (!latestVersionText) { + throw new TypeError("npm registry response must contain a version."); } const latestVersion = parseSerializedSemVer( - JSON.stringify(metadata.version) ?? "", + latestVersionText, "npm registry response" ); const changed = isNewerSemVer(latestVersion.value, currentVersion.value); if (changed) { - Reflect.set(catalog, "@opencode/plugin", latestVersion.value); + packageJson.catalog["@opencode/plugin"] = latestVersion.value; await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); } diff --git a/.github/scripts/update-pr-metadata.ts b/.github/scripts/update-pr-metadata.ts index 53252c9..03548d0 100644 --- a/.github/scripts/update-pr-metadata.ts +++ b/.github/scripts/update-pr-metadata.ts @@ -62,7 +62,7 @@ const api = async ( throw new GitHubApiError(response.status, await response.text()); } if (response.status === 204) { - return undefined; + return null; } const result: JsonValue = JSON.parse(await response.text()); return result; @@ -78,45 +78,52 @@ interface PullRequest { head: { ref: string; sha: string; - repo?: { full_name?: string } | null; + repo: { full_name: string | null } | null; }; labels: { name: string }[]; changed_files: number; } interface PullRequestContents { - content?: string; - encoding?: string; + content: string | null; + encoding: string | null; } const parsePullRequest = (value: JsonValue): PullRequest => { const pullRequest = parseJsonObject(value, "Pull request response"); - const head = parseJsonObject(pullRequest.get("head"), "Pull request head"); - const repositoryValue = head.get("repo"); + const head = parseJsonObject( + pullRequest.get("head") ?? null, + "Pull request head" + ); + const repositoryValue = head.get("repo") ?? null; let repo: PullRequest["head"]["repo"]; if (repositoryValue === null) { repo = null; - } else if (repositoryValue !== undefined) { + } else { const headRepositoryDetails = parseJsonObject( repositoryValue, "Pull request head repository" ); - const fullName = headRepositoryDetails.get("full_name"); - repo = - fullName === undefined - ? {} - : { full_name: parseJsonString(fullName, "Repository full name") }; + const fullName = headRepositoryDetails.get("full_name") ?? null; + repo = { + full_name: + fullName === null + ? null + : parseJsonString(fullName, "Repository full name"), + }; } const labels = parseJsonArray( - pullRequest.get("labels"), + pullRequest.get("labels") ?? null, "Pull request labels" ).map((label, index) => { const parsedLabel = parseJsonObject(label, `Pull request label ${index}`); - return { name: parseJsonString(parsedLabel.get("name"), "Label name") }; + return { + name: parseJsonString(parsedLabel.get("name") ?? null, "Label name"), + }; }); const changedFiles = parseJsonNumber( - pullRequest.get("changed_files"), + pullRequest.get("changed_files") ?? null, "Pull request changed file count" ); assert.ok( @@ -131,9 +138,9 @@ const parsePullRequest = (value: JsonValue): PullRequest => { return { changed_files: changedFiles, head: { - ref: parseJsonString(head.get("ref"), "Pull request head ref"), + ref: parseJsonString(head.get("ref") ?? null, "Pull request head ref"), repo, - sha: parseJsonString(head.get("sha"), "Pull request head SHA"), + sha: parseJsonString(head.get("sha") ?? null, "Pull request head SHA"), }, labels, }; @@ -143,27 +150,35 @@ const parsePullRequestFiles = (value: JsonValue): PullRequestFile[] => parseJsonArray(value, "Pull request file list").map((file, index) => { const parsedFile = parseJsonObject(file, `Pull request file ${index}`); return { - additions: parseJsonNumber(parsedFile.get("additions"), "File additions"), - deletions: parseJsonNumber(parsedFile.get("deletions"), "File deletions"), - filename: parseJsonString(parsedFile.get("filename"), "File name"), + additions: parseJsonNumber( + parsedFile.get("additions") ?? null, + "File additions" + ), + deletions: parseJsonNumber( + parsedFile.get("deletions") ?? null, + "File deletions" + ), + filename: parseJsonString( + parsedFile.get("filename") ?? null, + "File name" + ), }; }); const parseContentsResponse = (value: JsonValue): PullRequestContents => { const contents = parseJsonObject(value, "GitHub contents response"); - const parsedContents: PullRequestContents = {}; - const content = contents.get("content"); - if (content !== undefined) { - parsedContents.content = parseJsonString(content, "GitHub contents data"); - } - const encoding = contents.get("encoding"); - if (encoding !== undefined) { - parsedContents.encoding = parseJsonString( - encoding, - "GitHub contents encoding" - ); - } - return parsedContents; + const content = contents.get("content") ?? null; + const encoding = contents.get("encoding") ?? null; + return { + content: + content === null + ? null + : parseJsonString(content, "GitHub contents data"), + encoding: + encoding === null + ? null + : parseJsonString(encoding, "GitHub contents encoding"), + }; }; const pullRequest = parsePullRequest( @@ -329,7 +344,7 @@ const requiredPackages = skipChangeset || isChangesetReleasePR( pullRequest.head.ref, - pullRequest.head.repo?.full_name, + pullRequest.head.repo?.full_name ?? null, `${owner}/${repository}` ) ? new Set() diff --git a/.github/scripts/verify-npm-staged-publishing.ts b/.github/scripts/verify-npm-staged-publishing.ts new file mode 100644 index 0000000..e8ea602 --- /dev/null +++ b/.github/scripts/verify-npm-staged-publishing.ts @@ -0,0 +1,15 @@ +import { execFileSync } from "node:child_process"; + +import { getNpmCliPath } from "@/scripts/shared/npm-cli.ts"; + +const version = execFileSync(process.execPath, [getNpmCliPath(), "--version"], { + encoding: "utf-8", +}).trim(); +const [major = 0, minor = 0] = version.split(".").map(Number); +const supported = major > 11 || (major === 11 && minor >= 15); + +if (!supported) { + throw new Error( + `npm ${version} does not support staged publishing. Use npm >= 11.15.0.` + ); +} diff --git a/.github/scripts/verify-preview-author-access.ts b/.github/scripts/verify-preview-author-access.ts new file mode 100644 index 0000000..a8c7fcc --- /dev/null +++ b/.github/scripts/verify-preview-author-access.ts @@ -0,0 +1,74 @@ +import { + asNumber, + asOptionalRecord, + asOptionalString, + asRecord, + githubRepository, + githubRequest, + setOutput, +} from "@/github/github-api.ts"; +import type { JsonValue } from "@/scripts/shared/json-value.ts"; +import { getJsonField } from "@/scripts/shared/json-value.ts"; + +interface PullRequest { + number: number; + user: { login: string | null } | null; +} + +interface PermissionResponse { + permission: string | null; + role_name: string | null; +} + +const parsePullRequest = (value: JsonValue): PullRequest => { + const pullRequest = asRecord(value); + const user = asOptionalRecord(getJsonField(pullRequest, "user")); + return { + number: asNumber(getJsonField(pullRequest, "number")), + user: user + ? { login: asOptionalString(getJsonField(user, "login")) } + : null, + }; +}; + +const parsePermissionResponse = (value: JsonValue): PermissionResponse => { + const permission = asRecord(value); + return { + permission: asOptionalString(getJsonField(permission, "permission")), + role_name: asOptionalString(getJsonField(permission, "role_name")), + }; +}; + +const pullRequestNumber = Number(process.env.PULL_REQUEST_NUMBER); +if (!Number.isInteger(pullRequestNumber)) { + throw new TypeError("The preview pull request number is missing."); +} + +const pullRequest = await githubRequest( + `/repos/${githubRepository.owner}/${githubRepository.repo}/pulls/${pullRequestNumber}`, + parsePullRequest +); +const username = pullRequest.user?.login ?? null; +if (!username) { + throw new Error("The preview pull request author is missing."); +} + +let permission = "none"; +try { + const result = await githubRequest( + `/repos/${githubRepository.owner}/${githubRepository.repo}/collaborators/${encodeURIComponent(username)}/permission`, + parsePermissionResponse + ); + permission = result.permission ?? result.role_name ?? "none"; +} catch { + throw new Error(`Could not verify ${username}'s repository access.`); +} + +const trusted = ["admin", "write", "maintain", "push"].includes(permission); +console.info( + `Pull request #${pullRequest.number} author ${username} currently has ${permission} access.` +); +await setOutput("trusted", String(trusted)); +if (!trusted) { + throw new Error(`${username} no longer has write access to the repository.`); +} diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml index 0a4d75b..cd3a220 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -35,130 +35,20 @@ jobs: pull_request_number: ${{ steps.resolve.outputs.pull_request_number }} trusted: ${{ steps.resolve.outputs.trusted }} steps: + - name: Checkout trusted workflow scripts + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .node-version - name: Resolve pull request and author access id: resolve - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - const defaultBranch = context.payload.repository?.default_branch; - if (!defaultBranch) { - core.setFailed("The repository default branch is missing from the event payload."); - return; - } - - let pullRequest; - const workflowRun = context.payload.workflow_run; - const baseRepositoryId = String(context.payload.repository?.id ?? ""); - const headRepositoryId = String(workflowRun?.head_repository?.id ?? ""); - const headBranch = workflowRun?.head_branch; - const headSha = workflowRun?.head_sha; - const matchesWorkflowRun = (candidate) => - String(candidate.head?.repo?.id ?? "") === headRepositoryId && - candidate.head?.ref === headBranch && - candidate.head?.sha === headSha && - String(candidate.base?.repo?.id ?? "") === baseRepositoryId && - candidate.base?.ref === defaultBranch; - - if (context.eventName === "pull_request_target") { - const response = await github.rest.pulls.get({ - ...context.repo, - pull_number: context.payload.pull_request.number, - }); - pullRequest = response.data; - } else { - if (!baseRepositoryId || !headRepositoryId || !headBranch || !headSha) { - core.setFailed("The workflow run is missing pull request identity data."); - return; - } - - const candidates = Array.isArray(workflowRun.pull_requests) - ? workflowRun.pull_requests - : []; - const matchingCandidates = candidates.filter(matchesWorkflowRun); - - if (matchingCandidates.length > 1) { - core.setFailed( - `Expected one pull request for ${headBranch} at ${headSha}, found ${matchingCandidates.length}.`, - ); - return; - } - - if (matchingCandidates.length === 1) { - const [candidate] = matchingCandidates; - const response = await github.rest.pulls.get({ - ...context.repo, - pull_number: candidate.number, - }); - pullRequest = response.data; - } else { - const headOwner = workflowRun.head_repository?.owner?.login; - if (!headOwner) { - core.setFailed("The workflow run is missing pull request head repository data."); - return; - } - - const pullRequests = await github.paginate(github.rest.pulls.list, { - ...context.repo, - state: "all", - base: defaultBranch, - head: `${headOwner}:${headBranch}`, - per_page: 100, - }); - const matches = pullRequests.filter(matchesWorkflowRun); - - if (matches.length > 1) { - core.setFailed( - `Expected one pull request for ${headOwner}:${headBranch} at ${headSha}, found ${matches.length}.`, - ); - return; - } - - if (matches.length === 0) { - core.info( - `No pull request currently sits at head ${headSha} for ${headOwner}:${headBranch}; the branch moved on, so there is nothing to deploy for this run.`, - ); - return; - } - [pullRequest] = matches; - } - } - - if (!pullRequest || pullRequest.base.ref !== defaultBranch) { - core.setFailed("The preview pull request does not target the default branch."); - return; - } - if ( - context.eventName === "workflow_run" && - !matchesWorkflowRun(pullRequest) - ) { - core.info( - `The pull request head moved past ${headSha} while resolving; there is nothing to deploy for this run.`, - ); - return; - } - - const username = pullRequest.user?.login; - if (!username) { - core.setFailed("The preview pull request author is missing."); - return; - } - - let permission = "none"; - try { - const response = await github.rest.repos.getCollaboratorPermissionLevel({ - ...context.repo, - username, - }); - permission = response.data.permission ?? response.data.role_name ?? "none"; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - core.warning(`Could not verify ${username}'s repository access; treating the PR as untrusted: ${message}`); - } - - const trusted = ["admin", "write", "maintain", "push"].includes(permission); - core.info(`Pull request #${pullRequest.number} author ${username} has ${permission} access.`); - core.setOutput("pull_request_number", String(pullRequest.number)); - core.setOutput("trusted", String(trusted)); + run: bun .github/scripts/resolve-preview-pull-request.ts + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} announce: name: Announce preview approval @@ -169,45 +59,22 @@ jobs: permissions: issues: write pull-requests: write + contents: read steps: - - name: Comment approval link - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + - name: Checkout trusted workflow scripts + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - script: | - const pullRequestNumber = Number(process.env.PULL_REQUEST_NUMBER); - if (!Number.isInteger(pullRequestNumber)) { - core.setFailed("The preview pull request number is missing."); - return; - } - - const marker = ""; - const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; - const body = `${marker}\nThe documentation preview is waiting for approval. [Open the workflow run and click **Review deployments**](${runUrl}).`; - const comments = await github.paginate(github.rest.issues.listComments, { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pullRequestNumber, - per_page: 100, - }); - const existing = comments.find((comment) => comment.body?.includes(marker)); - - if (existing) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body, - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pullRequestNumber, - body, - }); - } + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .node-version + - name: Comment approval link + run: bun .github/scripts/announce-preview-approval.ts env: PULL_REQUEST_NUMBER: ${{ needs.resolve.outputs.pull_request_number }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} deploy: name: Deploy preview (review required) @@ -242,48 +109,21 @@ jobs: # A write-level PR author is trusted to run pull-request code with the # repository's Cloudflare credentials, so no reviewer gate is needed. steps: + - name: Checkout trusted workflow scripts + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .node-version - name: Recheck author access id: verify-access - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + run: bun .github/scripts/verify-preview-author-access.ts env: PULL_REQUEST_NUMBER: ${{ needs.resolve.outputs.pull_request_number }} - with: - script: | - const pullRequestNumber = Number(process.env.PULL_REQUEST_NUMBER); - if (!Number.isInteger(pullRequestNumber)) { - core.setFailed("The preview pull request number is missing."); - return; - } - - const { data: pullRequest } = await github.rest.pulls.get({ - ...context.repo, - pull_number: pullRequestNumber, - }); - const username = pullRequest.user?.login; - if (!username) { - core.setFailed("The preview pull request author is missing."); - return; - } - - let permission = "none"; - try { - const response = await github.rest.repos.getCollaboratorPermissionLevel({ - ...context.repo, - username, - }); - permission = response.data.permission ?? response.data.role_name ?? "none"; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - core.setFailed(`Could not verify ${username}'s repository access: ${message}`); - return; - } - - const trusted = ["admin", "write", "maintain", "push"].includes(permission); - core.info(`Pull request #${pullRequest.number} author ${username} currently has ${permission} access.`); - core.setOutput("trusted", String(trusted)); - if (!trusted) { - core.setFailed(`${username} no longer has write access to the repository.`); - } + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Run Alchemy deployment if: success() && steps.verify-access.outputs.trusted == 'true' uses: mynameistito/alchemy-deploy@c508ae6038e5936b9f19e3eb6284c3aa42312b43 # approved fork preview support diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4534536..41391b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,18 +36,7 @@ jobs: - name: Upgrade npm run: npm install --global npm@11.15.0 - name: Verify npm staged publishing support - run: | - node --input-type=module <<'EOF' - import { execFileSync } from "node:child_process"; - - const version = execFileSync("npm", ["--version"], { encoding: "utf8" }).trim(); - const [major = 0, minor = 0, patch = 0] = version.split(".").map(Number); - const supported = major > 11 || (major === 11 && (minor > 15 || (minor === 15 && patch >= 0))); - - if (!supported) { - throw new Error(`npm ${version} does not support staged publishing. Use npm >= 11.15.0.`); - } - EOF + run: bun .github/scripts/verify-npm-staged-publishing.ts - name: Install dependencies run: bun install --frozen-lockfile - name: Verify packages diff --git a/scripts/changeset-add.ts b/scripts/changeset-add.ts index 7b9b316..2f54fec 100644 --- a/scripts/changeset-add.ts +++ b/scripts/changeset-add.ts @@ -9,7 +9,8 @@ const packages = new Map([ ]); const types = new Set(["patch", "minor", "major"]); const [packageSelector, type, ...summaryParts] = process.argv.slice(2); -const packageName = packageSelector ? packages.get(packageSelector) : undefined; +const packageName = + (packageSelector ? packages.get(packageSelector) : null) ?? null; const summary = summaryParts.join(" ").trim(); if (!packageName || !type || !types.has(type) || !summary) { diff --git a/scripts/package-tarball/check.ts b/scripts/package-tarball/check.ts index bde5cb1..7b10a6a 100644 --- a/scripts/package-tarball/check.ts +++ b/scripts/package-tarball/check.ts @@ -30,31 +30,34 @@ interface PackResult { const parsePackageManifest = (value: JsonValue): PackageManifest => { const manifest = parseJsonObject(value, "Package manifest"); - return { name: parseJsonString(manifest.get("name"), "Package name") }; + return { + name: parseJsonString(manifest.get("name") ?? null, "Package name"), + }; }; const parsePackResult = (value: JsonValue): PackResult => { const result = parseJsonObject(value, "npm pack result"); - const files = parseJsonArray(result.get("files"), "npm pack files").map( - (file, index) => { - const packedFile = parseJsonObject(file, `npm pack file ${index}`); - return { - path: parseJsonString( - packedFile.get("path"), - `npm pack file ${index} path` - ), - size: parseJsonNumber( - packedFile.get("size"), - `npm pack file ${index} size` - ), - }; - } - ); + const files = parseJsonArray( + result.get("files") ?? null, + "npm pack files" + ).map((file, index) => { + const packedFile = parseJsonObject(file, `npm pack file ${index}`); + return { + path: parseJsonString( + packedFile.get("path") ?? null, + `npm pack file ${index} path` + ), + size: parseJsonNumber( + packedFile.get("size") ?? null, + `npm pack file ${index} size` + ), + }; + }); return { files, - size: parseJsonNumber(result.get("size"), "npm pack size"), + size: parseJsonNumber(result.get("size") ?? null, "npm pack size"), unpackedSize: parseJsonNumber( - result.get("unpackedSize"), + result.get("unpackedSize") ?? null, "npm pack unpacked size" ), }; @@ -82,13 +85,10 @@ export const checkPackageTarball = (packageDirectory: string): void => { } ); const parsed: JsonValue = JSON.parse(output); - let packValue: JsonValue; - if (Array.isArray(parsed)) { - [packValue] = parsed; - } else { - packValue = parseJsonObject(parsed, "npm pack response").get(manifest.name); - } - if (packValue === undefined) { + const packValue: JsonValue | null = Array.isArray(parsed) + ? (parsed[0] ?? null) + : (parseJsonObject(parsed, "npm pack response").get(manifest.name) ?? null); + if (packValue === null) { throw new Error(`npm pack returned no result for ${manifest.name}`); } const pack = parsePackResult(packValue); diff --git a/scripts/shared/json-value.ts b/scripts/shared/json-value.ts index ca720bb..f1572fd 100644 --- a/scripts/shared/json-value.ts +++ b/scripts/shared/json-value.ts @@ -2,7 +2,6 @@ import assert from "node:assert/strict"; /** A JSON value projected into a recursive TypeScript representation. */ export type JsonValue = - | undefined | null | boolean | number @@ -13,13 +12,16 @@ export type JsonValue = /** A JSON object whose members retain their recursive JSON value types. */ export type JsonObject = ReadonlyMap; +/** Read an optional JSON object member, normalizing absent members to null. */ +export const getJsonField = (object: JsonObject, key: string): JsonValue => + object.get(key) ?? null; + /** Parse a JSON value as a plain object. */ export const parseJsonObject = ( value: JsonValue, context: string ): JsonObject => { assert.notEqual(value, null, `${context} must be an object`); - assert.notEqual(value, undefined, `${context} must be an object`); const object = new Object(value); assert.equal( Object.getPrototypeOf(object), From 979a0e793a51d9f1fdce0d6a23d2375323b3316e Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Wed, 7 Oct 2026 02:07:47 +1300 Subject: [PATCH 13/17] fix: align preview workflow security suppressions --- .github/workflows/deploy-preview.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml index cd3a220..6012a43 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -1,7 +1,5 @@ # yaml-language-server: $schema=https://www.schemastore.org/github-workflow.json -name: Deploy documentation preview - on: workflow_run: workflows: @@ -11,7 +9,7 @@ on: pull_request_target: types: - closed - +name: Deploy documentation preview concurrency: group: deploy-documentation-${{ (github.event_name == 'pull_request' || github.event_name == 'pull_request_target') && format('pr-{0}', github.event.pull_request.number) || format('run-{0}', github.event.workflow_run.id) }} cancel-in-progress: false From 458475bf2f1adb3a404a81095a578111d888d414 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Wed, 7 Oct 2026 02:10:17 +1300 Subject: [PATCH 14/17] fix: align workflow security trigger suppressions --- .github/workflows/deploy.yml | 4 +--- .github/workflows/pr-metadata.yml | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9d65e01..eeaca43 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,14 +1,12 @@ # yaml-language-server: $schema=https://www.schemastore.org/github-workflow.json -name: Deploy documentation - on: workflow_run: workflows: - Release types: - completed - +name: Deploy documentation concurrency: deploy-documentation-prod permissions: diff --git a/.github/workflows/pr-metadata.yml b/.github/workflows/pr-metadata.yml index 9e15e53..b46b9a4 100644 --- a/.github/workflows/pr-metadata.yml +++ b/.github/workflows/pr-metadata.yml @@ -1,11 +1,9 @@ # yaml-language-server: $schema=https://www.schemastore.org/github-workflow.json -name: PR metadata - on: pull_request_target: types: [opened, reopened, synchronize, ready_for_review, labeled, unlabeled] - +name: PR metadata concurrency: group: pr-metadata-${{ github.event.pull_request.number }}-${{ github.event.action }}-${{ github.event.label.name || 'none' }} cancel-in-progress: ${{ github.event.action == 'synchronize' }} From befdf93727e4b90706c8fd7bd7d79a4701e221ef Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Wed, 7 Oct 2026 02:16:48 +1300 Subject: [PATCH 15/17] fix: align release workflow security suppression --- .github/workflows/release.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 41391b6..2b5cda4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,13 +1,11 @@ # yaml-language-server: $schema=https://www.schemastore.org/github-workflow.json -name: Release - on: push: branches: - main workflow_dispatch: - +name: Release concurrency: ${{ github.workflow }}-${{ github.ref }} permissions: From 68aaa6d4d5d9bda56a192f4d25e399d9d61cb262 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Wed, 7 Oct 2026 02:36:09 +1300 Subject: [PATCH 16/17] chore: migrate over to github-app.pem --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2b5cda4..8c3a54d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,7 +59,7 @@ jobs: env: OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} GH_APP_ID: op://uhub4xx3iwwsaxj3robdosm2hu/spbjca2lethbsiz6menimq7afm/ID - GH_APP_PRIVATE_KEY: op://uhub4xx3iwwsaxj3robdosm2hu/spbjca2lethbsiz6menimq7afm/credential + GH_APP_PRIVATE_KEY: op://uhub4xx3iwwsaxj3robdosm2hu/spbjca2lethbsiz6menimq7afm/github-app.pem - name: Create repository-scoped App token id: app-token uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 From e9428e76248b2da2402a8e8c2590cdc2cd9e1e9b Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Wed, 7 Oct 2026 02:45:20 +1300 Subject: [PATCH 17/17] fix: address PR automation review findings --- .../scripts/create-signed-github-commit.ts | 24 +++++++++++++------ .github/workflows/deploy-preview.yml | 12 ++++++++++ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/.github/scripts/create-signed-github-commit.ts b/.github/scripts/create-signed-github-commit.ts index 940452b..96d79e3 100644 --- a/.github/scripts/create-signed-github-commit.ts +++ b/.github/scripts/create-signed-github-commit.ts @@ -44,7 +44,9 @@ const parseApiResponse = (value: JsonValue): ApiResponse => { const verificationValue = response.get("verification") ?? null; const objectValue = response.get("object") ?? null; const tree = - treeValue === null ? null : parseJsonObject(treeValue, "Git tree"); + treeValue === null || Array.isArray(treeValue) + ? null + : parseJsonObject(treeValue, "Git tree"); const verification = verificationValue === null ? null @@ -118,15 +120,23 @@ const request = async ( init ); - const result: JsonValue = JSON.parse(await response.text()); - const parsed = parseApiResponse(result); - if (!response.ok) { - const message = parsed.message || response.statusText; - throw new Error(`GitHub API ${method} ${path} failed: ${message}`); + const responseText = await response.text(); + let message = response.statusText; + + try { + const errorResponse = parseApiResponse(JSON.parse(responseText)); + message = errorResponse.message || message; + } catch { + // Keep the HTTP error useful when GitHub returns a non-JSON error body. + } + + throw new Error( + `GitHub API ${method} ${path} failed (${response.status}): ${message}` + ); } - return parsed; + return parseApiResponse(JSON.parse(await response.text())); }; const changesetDirectoryEntries = await readdir(".changeset", { diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml index 6012a43..35ed68b 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -42,6 +42,10 @@ jobs: uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: .node-version + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.2 - name: Resolve pull request and author access id: resolve run: bun .github/scripts/resolve-preview-pull-request.ts @@ -68,6 +72,10 @@ jobs: uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: .node-version + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.2 - name: Comment approval link run: bun .github/scripts/announce-preview-approval.ts env: @@ -116,6 +124,10 @@ jobs: uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: .node-version + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.2 - name: Recheck author access id: verify-access run: bun .github/scripts/verify-preview-author-access.ts