diff --git a/.github/workflows/update-opencode-plugin.yml b/.github/workflows/update-opencode-plugin.yml index 3718b19..024a6a9 100644 --- a/.github/workflows/update-opencode-plugin.yml +++ b/.github/workflows/update-opencode-plugin.yml @@ -21,27 +21,15 @@ jobs: 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: 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 +46,13 @@ 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 }} + MAIN_SHA: ${{ steps.main.outputs.sha }} 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..ddc8570 --- /dev/null +++ b/scripts/create-signed-github-commit.ts @@ -0,0 +1,204 @@ +import { readFile, readdir } from "node:fs/promises"; + +interface ApiResponse { + data?: unknown; + errors?: unknown; + message?: string; + node_id?: 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; +} + +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 ?? {}; + + if (!(data instanceof Object) || Array.isArray(data)) { + return false; + } + + return "updateRefs" in data && Boolean(data.updateRefs); +}; + +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 || !mainSha) { + throw new Error( + "GH_TOKEN, GITHUB_REPOSITORY, MAIN_SHA, 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 = mainSha; + +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"; +let existingRef: ApiResponse | undefined; + +try { + existingRef = await request(refPath, "GET"); +} catch (error) { + if (!(error instanceof Error) || !error.message.includes("Not Found")) { + throw error; + } +} + +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 = isApiResponse(refUpdateResult) + ? refUpdateResult + : undefined; +const hasGraphQLErrors = Array.isArray(graphQLResult?.errors) + ? graphQLResult.errors.length > 0 + : false; + +if ( + !refUpdateResponse.ok || + hasGraphQLErrors || + !hasUpdateRefs(graphQLResult) +) { + 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}`);