From 79d44b5ec77ca84703ef3bbb47b63eac4a2bb53e Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Fri, 2 Oct 2026 20:05:09 +1300 Subject: [PATCH 1/3] ci: sign plugin update commits via GitHub API --- .github/workflows/update-opencode-plugin.yml | 31 +--- scripts/create-signed-github-commit.ts | 165 +++++++++++++++++++ 2 files changed, 168 insertions(+), 28 deletions(-) create mode 100644 scripts/create-signed-github-commit.ts diff --git a/.github/workflows/update-opencode-plugin.yml b/.github/workflows/update-opencode-plugin.yml index 3718b19..9aca5f3 100644 --- a/.github/workflows/update-opencode-plugin.yml +++ b/.github/workflows/update-opencode-plugin.yml @@ -18,7 +18,7 @@ jobs: - name: Checkout main uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: main + ref: ${{ github.sha }} persist-credentials: false - name: Setup Bun @@ -26,22 +26,6 @@ jobs: with: bun-version: 1.4.2 - - name: Prepare update branch - id: branch - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - gh auth setup-git - branch_sha=$(git ls-remote --heads origin refs/heads/update-opencode-plugin | cut -f1) - if [ -n "$branch_sha" ]; then - git fetch origin update-opencode-plugin - fi - git checkout -B update-opencode-plugin main - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - echo "branch_sha=$branch_sha" >> "$GITHUB_OUTPUT" - - name: Check npm for the latest version id: latest run: bun scripts/update-opencode-plugin.ts @@ -58,21 +42,12 @@ jobs: bun run changeset-add -- usage-limits patch "Update @opencode/plugin to $VERSION" bun run changeset-add -- force-input patch "Update @opencode/plugin to $VERSION" - - name: Commit and push update + - name: Create verified update commit if: steps.latest.outputs.changed == 'true' env: - BRANCH_SHA: ${{ steps.branch.outputs.branch_sha }} GH_TOKEN: ${{ github.token }} VERSION: ${{ steps.latest.outputs.version }} - run: | - gh auth setup-git - git add package.json bun.lock .changeset - git commit -m "chore(deps): update @opencode/plugin to $VERSION" - if [ -n "$BRANCH_SHA" ]; then - git push --force-with-lease="refs/heads/update-opencode-plugin:$BRANCH_SHA" origin update-opencode-plugin - else - git push --set-upstream origin update-opencode-plugin - fi + run: bun scripts/create-signed-github-commit.ts - name: Open or update pull request if: steps.latest.outputs.changed == 'true' diff --git a/scripts/create-signed-github-commit.ts b/scripts/create-signed-github-commit.ts new file mode 100644 index 0000000..5a67dbd --- /dev/null +++ b/scripts/create-signed-github-commit.ts @@ -0,0 +1,165 @@ +import { readFile, readdir } from "node:fs/promises"; + +interface ApiResponse { + message?: string; + sha?: string; + tree?: { sha: string }; + verification?: { verified: boolean; reason: string }; + object?: { sha: string }; +} + +interface CreateTreeRequest { + base_tree: string; + tree: { + content: string; + mode: "100644"; + path: string; + type: "blob"; + }[]; +} + +interface CreateCommitRequest { + message: string; + parents: string[]; + tree: string; +} + +interface UpdateRefRequest { + force: true; + sha: string; +} + +interface CreateRefRequest { + ref: string; + sha: string; +} + +type ApiRequestBody = + | CreateTreeRequest + | CreateCommitRequest + | UpdateRefRequest + | CreateRefRequest; + +const isApiResponse = (value: unknown): value is ApiResponse => + value instanceof Object && !Array.isArray(value); + +const token = process.env.GH_TOKEN; +const repository = process.env.GITHUB_REPOSITORY; +const version = process.env.VERSION; + +if (!token || !repository || !version) { + throw new Error("GH_TOKEN, GITHUB_REPOSITORY, and VERSION are required"); +} + +const request = async ( + path: string, + method: "GET" | "POST" | "PATCH", + body?: ApiRequestBody +): Promise => { + const headers = new Headers({ + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + }); + const init: RequestInit = { headers, method }; + + if (body) { + headers.set("Content-Type", "application/json"); + init.body = JSON.stringify(body); + } + + const response = await fetch( + `https://api.github.com/repos/${repository}${path}`, + init + ); + + const result: unknown = await response.json(); + + if (!response.ok) { + const message = + isApiResponse(result) && result.message + ? String(result.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; +}; + +const changesetDirectoryEntries = await readdir(".changeset", { + withFileTypes: true, +}); +const changesetFiles = changesetDirectoryEntries + .filter((entry) => entry.isFile() && entry.name.endsWith(".md")) + .map((entry) => `.changeset/${entry.name}`); +const changedFiles = ["package.json", "bun.lock", ...changesetFiles]; + +const baseSha = process.env.GITHUB_SHA; + +if (!baseSha) { + throw new Error("GITHUB_SHA is required"); +} + +const baseCommit = await request(`/git/commits/${baseSha}`, "GET"); + +if (!baseCommit.tree?.sha) { + throw new Error("GitHub API did not return the base tree SHA"); +} + +const treeEntries = await Promise.all( + changedFiles.map(async (path) => ({ + content: await readFile(path, "utf-8"), + mode: "100644" as const, + path, + type: "blob" as const, + })) +); + +const tree = await request("/git/trees", "POST", { + base_tree: baseCommit.tree.sha, + tree: treeEntries, +}); + +if (!tree.sha) { + throw new Error("GitHub API did not return a tree SHA"); +} + +const commit = await request("/git/commits", "POST", { + message: `chore(deps): update @opencode/plugin to ${version}`, + parents: [baseSha], + tree: tree.sha, +}); + +if (!commit.sha || !commit.verification?.verified) { + throw new Error( + `GitHub did not verify the update commit (reason: ${commit.verification?.reason ?? "unknown"})` + ); +} + +const refPath = "/git/ref/heads/update-opencode-plugin"; +const updateRefPath = "/git/refs/heads/update-opencode-plugin"; +let existingRef: ApiResponse | undefined; + +try { + existingRef = await request(refPath, "GET"); +} catch (error) { + if (!(error instanceof Error) || !error.message.includes("Not Found")) { + throw error; + } +} + +const updateRef = existingRef?.object?.sha + ? request(updateRefPath, "PATCH", { force: true, sha: commit.sha }) + : request("/git/refs", "POST", { + ref: "refs/heads/update-opencode-plugin", + sha: commit.sha, + }); +await updateRef; + +console.log(`Pushed verified update commit ${commit.sha}`); From 4a652ce84895e025f72e10987b3e048bf52ec0d7 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Fri, 2 Oct 2026 20:23:43 +1300 Subject: [PATCH 2/3] fix(ci): guard plugin update branch updates --- .github/workflows/update-opencode-plugin.yml | 7 +- scripts/create-signed-github-commit.ts | 108 ++++++++++++++----- 2 files changed, 86 insertions(+), 29 deletions(-) diff --git a/.github/workflows/update-opencode-plugin.yml b/.github/workflows/update-opencode-plugin.yml index 9aca5f3..024a6a9 100644 --- a/.github/workflows/update-opencode-plugin.yml +++ b/.github/workflows/update-opencode-plugin.yml @@ -18,9 +18,13 @@ jobs: - name: Checkout main uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.sha }} + ref: main persist-credentials: false + - name: Record main commit + id: main + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: @@ -46,6 +50,7 @@ jobs: if: steps.latest.outputs.changed == 'true' env: GH_TOKEN: ${{ github.token }} + MAIN_SHA: ${{ steps.main.outputs.sha }} VERSION: ${{ steps.latest.outputs.version }} run: bun scripts/create-signed-github-commit.ts diff --git a/scripts/create-signed-github-commit.ts b/scripts/create-signed-github-commit.ts index 5a67dbd..11ced42 100644 --- a/scripts/create-signed-github-commit.ts +++ b/scripts/create-signed-github-commit.ts @@ -2,6 +2,7 @@ import { readFile, readdir } from "node:fs/promises"; interface ApiResponse { message?: string; + node_id?: string; sha?: string; tree?: { sha: string }; verification?: { verified: boolean; reason: string }; @@ -24,31 +25,45 @@ interface CreateCommitRequest { tree: string; } -interface UpdateRefRequest { - force: true; - sha: string; -} +type ApiRequestBody = CreateTreeRequest | CreateCommitRequest; -interface CreateRefRequest { - ref: string; - sha: string; +interface GraphQLResponse { + data?: { updateRefs?: { clientMutationId: string | null } }; + errors?: unknown; } -type ApiRequestBody = - | CreateTreeRequest - | CreateCommitRequest - | UpdateRefRequest - | CreateRefRequest; - const isApiResponse = (value: unknown): value is ApiResponse => value instanceof Object && !Array.isArray(value); +const isGraphQLResponse = (value: unknown): value is GraphQLResponse => { + if (!(value instanceof Object) || Array.isArray(value)) { + return false; + } + + if ( + !("data" in value) || + !(value.data instanceof Object) || + Array.isArray(value.data) || + !("updateRefs" in value.data) + ) { + return false; + } + + return !( + "errors" in value && + (!Array.isArray(value.errors) || value.errors.length > 0) + ); +}; + const token = process.env.GH_TOKEN; const repository = process.env.GITHUB_REPOSITORY; const version = process.env.VERSION; +const mainSha = process.env.MAIN_SHA; -if (!token || !repository || !version) { - throw new Error("GH_TOKEN, GITHUB_REPOSITORY, and VERSION are required"); +if (!token || !repository || !version || !mainSha) { + throw new Error( + "GH_TOKEN, GITHUB_REPOSITORY, MAIN_SHA, and VERSION are required" + ); } const request = async ( @@ -100,11 +115,7 @@ const changesetFiles = changesetDirectoryEntries .map((entry) => `.changeset/${entry.name}`); const changedFiles = ["package.json", "bun.lock", ...changesetFiles]; -const baseSha = process.env.GITHUB_SHA; - -if (!baseSha) { - throw new Error("GITHUB_SHA is required"); -} +const baseSha = mainSha; const baseCommit = await request(`/git/commits/${baseSha}`, "GET"); @@ -143,7 +154,6 @@ if (!commit.sha || !commit.verification?.verified) { } const refPath = "/git/ref/heads/update-opencode-plugin"; -const updateRefPath = "/git/refs/heads/update-opencode-plugin"; let existingRef: ApiResponse | undefined; try { @@ -154,12 +164,54 @@ try { } } -const updateRef = existingRef?.object?.sha - ? request(updateRefPath, "PATCH", { force: true, sha: commit.sha }) - : request("/git/refs", "POST", { - ref: "refs/heads/update-opencode-plugin", - sha: commit.sha, - }); -await updateRef; +const repositoryInfo = await request("", "GET"); + +if (!repositoryInfo.node_id) { + throw new Error("GitHub API did not return the repository node ID"); +} + +const refUpdateResponse = await fetch("https://api.github.com/graphql", { + body: JSON.stringify({ + query: + "mutation($repositoryId: ID!, $refUpdates: [RefUpdate!]!) { updateRefs(input: { repositoryId: $repositoryId, refUpdates: $refUpdates }) { clientMutationId } }", + variables: { + refUpdates: [ + { + afterOid: commit.sha, + beforeOid: + existingRef?.object?.sha ?? + "0000000000000000000000000000000000000000", + force: true, + name: "refs/heads/update-opencode-plugin", + }, + ], + repositoryId: repositoryInfo.node_id, + }, + }), + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + method: "POST", +}); +const refUpdateResult: unknown = await refUpdateResponse.json(); + +const graphQLResult = isGraphQLResponse(refUpdateResult) + ? refUpdateResult + : undefined; +const hasGraphQLErrors = Array.isArray(graphQLResult?.errors) + ? graphQLResult.errors.length > 0 + : false; + +if ( + !refUpdateResponse.ok || + hasGraphQLErrors || + !graphQLResult?.data?.updateRefs +) { + throw new Error( + `GitHub API failed to update the update branch: ${JSON.stringify(graphQLResult?.errors ?? refUpdateResponse.statusText)}` + ); +} console.log(`Pushed verified update commit ${commit.sha}`); From c50291a91961c7d1b2fe9a5ca4b158f7a41550f2 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Fri, 2 Oct 2026 20:38:33 +1300 Subject: [PATCH 3/3] fix(ci): preserve GraphQL update errors --- scripts/create-signed-github-commit.ts | 29 +++++++------------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/scripts/create-signed-github-commit.ts b/scripts/create-signed-github-commit.ts index 11ced42..ddc8570 100644 --- a/scripts/create-signed-github-commit.ts +++ b/scripts/create-signed-github-commit.ts @@ -1,6 +1,8 @@ import { readFile, readdir } from "node:fs/promises"; interface ApiResponse { + data?: unknown; + errors?: unknown; message?: string; node_id?: string; sha?: string; @@ -27,32 +29,17 @@ interface CreateCommitRequest { type ApiRequestBody = CreateTreeRequest | CreateCommitRequest; -interface GraphQLResponse { - data?: { updateRefs?: { clientMutationId: string | null } }; - errors?: unknown; -} - const isApiResponse = (value: unknown): value is ApiResponse => value instanceof Object && !Array.isArray(value); -const isGraphQLResponse = (value: unknown): value is GraphQLResponse => { - if (!(value instanceof Object) || Array.isArray(value)) { - return false; - } +const hasUpdateRefs = (response: ApiResponse | undefined): boolean => { + const { data } = response ?? {}; - if ( - !("data" in value) || - !(value.data instanceof Object) || - Array.isArray(value.data) || - !("updateRefs" in value.data) - ) { + if (!(data instanceof Object) || Array.isArray(data)) { return false; } - return !( - "errors" in value && - (!Array.isArray(value.errors) || value.errors.length > 0) - ); + return "updateRefs" in data && Boolean(data.updateRefs); }; const token = process.env.GH_TOKEN; @@ -197,7 +184,7 @@ const refUpdateResponse = await fetch("https://api.github.com/graphql", { }); const refUpdateResult: unknown = await refUpdateResponse.json(); -const graphQLResult = isGraphQLResponse(refUpdateResult) +const graphQLResult = isApiResponse(refUpdateResult) ? refUpdateResult : undefined; const hasGraphQLErrors = Array.isArray(graphQLResult?.errors) @@ -207,7 +194,7 @@ const hasGraphQLErrors = Array.isArray(graphQLResult?.errors) if ( !refUpdateResponse.ok || hasGraphQLErrors || - !graphQLResult?.data?.updateRefs + !hasUpdateRefs(graphQLResult) ) { throw new Error( `GitHub API failed to update the update branch: ${JSON.stringify(graphQLResult?.errors ?? refUpdateResponse.statusText)}`