From e73842355ec7287b59c9c3b5ac1355bb23439879 Mon Sep 17 00:00:00 2001 From: Matt Jackson Date: Fri, 13 Nov 2026 04:42:01 +0000 Subject: [PATCH 1/2] ci: monitor OpenCode releases and notify Discord on failures --- .github/opencode-reviewed-version | 1 + .github/workflows/beads-contract.yml | 95 ++++++++++++++ .github/workflows/notify-ci-failure.yml | 70 ++++++++++ docs/opencode-sdk-contract.md | 43 ++++++ tests/ci-failure-notification.test.ts | 92 +++++++++++++ tests/opencode-release-monitor.test.ts | 165 ++++++++++++++++++++++++ 6 files changed, 466 insertions(+) create mode 100644 .github/opencode-reviewed-version create mode 100644 .github/workflows/notify-ci-failure.yml create mode 100644 tests/ci-failure-notification.test.ts create mode 100644 tests/opencode-release-monitor.test.ts diff --git a/.github/opencode-reviewed-version b/.github/opencode-reviewed-version new file mode 100644 index 0000000..5bc1cc4 --- /dev/null +++ b/.github/opencode-reviewed-version @@ -0,0 +1 @@ +2.2.7 diff --git a/.github/workflows/beads-contract.yml b/.github/workflows/beads-contract.yml index 22008e1..0c8c658 100644 --- a/.github/workflows/beads-contract.yml +++ b/.github/workflows/beads-contract.yml @@ -11,6 +11,9 @@ on: version: description: Beads release to check, e.g. 1.3.1 (default latest stable) required: false + opencode_version: + description: OpenCode release tag to check, e.g. v2.2.7 (default newest stable V2+) + required: false permissions: contents: read @@ -66,6 +69,98 @@ jobs: exit 1 fi + opencode-release: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.14 + + - name: Resolve stable OpenCode release + id: opencode-release + env: + GH_TOKEN: ${{ github.token }} + REQUESTED: ${{ inputs.opencode_version }} + run: | + if [[ -z "$REQUESTED" ]]; then + # GraphQL, not the shell, expands $endCursor. + # shellcheck disable=SC2016 + gh api graphql --paginate --slurp -f query=' + query($endCursor: String) { + repository(owner: "anomalyco", name: "opencode") { + releases(first: 100, after: $endCursor) { + nodes { tagName isDraft isPrerelease } + pageInfo { hasNextPage endCursor } + } + } + }' > "$RUNNER_TEMP/opencode-releases.json" + REQUESTED="$(jq -er '[.[].data.repository.releases.nodes[] + | select(.isDraft == false and .isPrerelease == false) + | select(.tagName | test("^v?[0-9]+\\.[0-9]+\\.[0-9]+$")) + | select((.tagName | ltrimstr("v") | split(".")[0] | tonumber) >= 2)] + | max_by(.tagName | ltrimstr("v") | split(".") | map(tonumber)) + | .tagName // empty' "$RUNNER_TEMP/opencode-releases.json")" + fi + gh release view "$REQUESTED" --repo anomalyco/opencode --json tagName,isDraft,isPrerelease,url > "$RUNNER_TEMP/opencode-release.json" + jq -e '.isDraft == false and .isPrerelease == false' "$RUNNER_TEMP/opencode-release.json" > /dev/null + tag="$(jq -er .tagName "$RUNNER_TEMP/opencode-release.json")" + [[ "$tag" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]] + version="${tag#v}" + [[ "${version%%.*}" -ge 2 ]] + reviewed="$(cat .github/opencode-reviewed-version)" + [[ "$reviewed" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] + printf 'version=%s\nreviewed=%s\n' "$version" "$reviewed" >> "$GITHUB_OUTPUT" + { + printf '## OpenCode stable-release monitor\n\n' + jq -r '"Release: [\(.tagName)](\(.url))"' "$RUNNER_TEMP/opencode-release.json" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Check exact published OpenCode SDK in a disposable snapshot + id: sdk-check + env: + VERSION: ${{ steps.opencode-release.outputs.version }} + run: | + probe="$(mktemp -d "$RUNNER_TEMP/opencode-sdk.XXXXXX")" + trap 'rm -rf "$probe"' EXIT + mkdir "$probe/source" "$probe/cache" + git archive HEAD | tar -x -C "$probe/source" + jq --arg version "$VERSION" '.devDependencies["@opencode/plugin"] = $version' package.json > "$probe/source/package.json" + ( + cd "$probe/source" + export BUN_INSTALL_CACHE_DIR="$probe/cache" + bun install --ignore-scripts + cp "$GITHUB_WORKSPACE/package.json" package.json + cp "$GITHUB_WORKSPACE/bun.lock" bun.lock + [[ "$(jq -r .version node_modules/@opencode/plugin/package.json)" == "$VERSION" ]] + bun test + bun run typecheck + bun run test:package + ) + + - name: Report SDK outcome and require review of new OpenCode releases + if: ${{ always() && steps.opencode-release.outcome == 'success' }} + env: + VERSION: ${{ steps.opencode-release.outputs.version }} + REVIEWED: ${{ steps.opencode-release.outputs.reviewed }} + SDK_OUTCOME: ${{ steps.sdk-check.outcome }} + run: | + { + printf '\nReviewed release: %s\n\nSDK checks: %s\n\n' "$REVIEWED" "$SDK_OUTCOME" + printf 'These are source/SDK checks, not live-host/model qualification. No consumer upgrade or automatic baseline change occurs.\n' + } >> "$GITHUB_STEP_SUMMARY" + newest="$(printf '%s\n%s\n' "$REVIEWED" "$VERSION" | sort -V | tail -n 1)" + if [[ "$VERSION" != "$REVIEWED" && "$newest" == "$VERSION" ]]; then + printf '\nOpenCode %s requires human review. See docs/opencode-sdk-contract.md#release-monitoring.\n' "$VERSION" >> "$GITHUB_STEP_SUMMARY" + echo "::error::OpenCode $VERSION is newer than reviewed $REVIEWED. SDK success is not release review; inspect the release before deliberately updating .github/opencode-reviewed-version." + exit 1 + fi + source-comparison: runs-on: ubuntu-latest timeout-minutes: 5 diff --git a/.github/workflows/notify-ci-failure.yml b/.github/workflows/notify-ci-failure.yml new file mode 100644 index 0000000..541f4e4 --- /dev/null +++ b/.github/workflows/notify-ci-failure.yml @@ -0,0 +1,70 @@ +name: Notify Discord when CI fails + +# Trusted metadata only: no checkout, artifacts or GitHub permissions. +on: # zizmor: ignore[dangerous-triggers] + workflow_run: + workflows: [ci, beads-contract] + types: [completed] + +permissions: {} + +jobs: + notify: + if: ${{ github.event.workflow_run.conclusion == 'failure' }} + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + # Metadata only: never check out code or download artifacts from the failed run. + - name: Post failed run to Discord + env: + DISCORD_CI_WEBHOOK_URL: ${{ secrets.DISCORD_CI_WEBHOOK_URL }} + run: | + python3 - <<'PY' + import json + import os + import urllib.error + import urllib.parse + import urllib.request + + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + + def main(): + with open(os.environ["GITHUB_EVENT_PATH"], encoding="utf-8") as source: + event = json.load(source) + run = event["workflow_run"] + if run["conclusion"] != "failure": + return 0 + repository = "snarkipus/opencode-beads" + if event["repository"]["full_name"] != repository or run["name"] not in ("ci", "beads-contract"): + raise SystemExit("Unexpected repository or workflow; notification refused.") + webhook = os.environ.get("DISCORD_CI_WEBHOOK_URL", "") + parsed = urllib.parse.urlsplit(webhook) + if (parsed.scheme != "https" or parsed.netloc != "discord.com" + or not parsed.path.startswith("/api/webhooks/") or parsed.query or parsed.fragment): + raise SystemExit("Set DISCORD_CI_WEBHOOK_URL to this channel's standard Discord webhook URL.") + branch = " ".join(str(run.get("head_branch", "unknown")).split())[:180] + link = f"https://github.com/{repository}/actions/runs/{int(run['id'])}" + payload = {"content": f"CI failed: {repository} / {run['name']}\nBranch: {branch}\n{link}", + "allowed_mentions": {"parse": []}} + request = urllib.request.Request(webhook + "?wait=true", + data=json.dumps(payload).encode("utf-8"), method="POST", + headers={"Content-Type": "application/json", + "User-Agent": "OpenCode-Beads-CI-Notifier/1.0"}) + try: + with urllib.request.build_opener(NoRedirect()).open(request, timeout=10) as response: + result = json.load(response) + except urllib.error.HTTPError as error: + raise SystemExit(f"Discord notification failed: HTTP {error.code}.") from None + except Exception: + raise SystemExit("Discord delivery failed or timed out; delivery may be uncertain. No retry was made.") from None + message_id = result.get("id", "") + if not isinstance(message_id, str) or not message_id.isdigit(): + raise SystemExit("Discord response did not confirm a message ID.") + print(f"Discord accepted message ID {message_id}.") + return 0 + + if __name__ == "__main__": + raise SystemExit(main()) + PY diff --git a/docs/opencode-sdk-contract.md b/docs/opencode-sdk-contract.md index e0dc493..3985710 100644 --- a/docs/opencode-sdk-contract.md +++ b/docs/opencode-sdk-contract.md @@ -34,6 +34,49 @@ to live at this path is preserved as frozen; the published 2.0.0 package carries the same runtime files and packaged guidance with updated version metadata. +## Release monitoring + +The daily/manual [`beads-contract` workflow](../.github/workflows/beads-contract.yml) +has an independent OpenCode stable-release job alongside the existing Beads checks. +Its human-reviewed release baseline is [`.github/opencode-reviewed-version`](../.github/opencode-reviewed-version), +not the development SDK pin or a claim of native-host qualification. +The initial 2.2.7 review covers tagged source and SDK compatibility only; it +claims no additional native-host/model qualification. Assessment evidence and +check results belong in Beads, not this standing reference. + +The job selects the highest stable OpenCode version from 2.0.0 onward by default, +using the complete paginated release list with only tag/draft/prerelease metadata. +It ignores legacy 1.x and separately named component releases: GitHub's +repository-wide `latest` can designate the legacy line instead of V2. Future +major releases still trigger review; their detection does not expand host +admission. Manual runs can select an exact stable V2-or-later release tag with +`opencode_version`. The job installs that exact published SDK in a disposable +Git snapshot, restores the snapshot's source manifest/lockfile, and runs the +existing tests, typecheck and package smoke. Its Actions summary +records the release link, reviewed baseline and SDK outcome. Newer unreviewed +releases fail the independent monitor even if SDK checks pass. Retrieval/install +or compatibility failures remain failures, not successful no-change results. + +Review the release notes and relevant tagged source before deliberately advancing +the reviewed-version file. Passing SDK checks does not automatically admit a new +host version, change the pinned SDK, update consumer installations or establish +live-host/model qualification. The monitor creates no issues, PRs or releases; +selected follow-up work belongs in Beads. Its failures do not become dependencies +of ordinary product CI or the release workflow. + +## CI failure notifications + +The metadata-only [failure notifier](../.github/workflows/notify-ci-failure.yml) +posts a failed `ci` or `beads-contract` run's link to Discord. Create an incoming +webhook in the intended Discord channel, then save its standard URL directly as +the repository Actions secret `DISCORD_CI_WEBHOOK_URL`; never put it in source or +chat. Without that secret, delivery is not configured and the notifier reports +an error. Successful runs do not post, mentions are disabled, and the notifier +never checks out failed-run code or downloads its artifacts. Each failed-run +completion gets one send attempt; manually rerunning the notifier can duplicate +a message. Activation and observed delivery are separate: first delivery remains +unverified until a real failure is posted to the intended channel. + ## Loading and host admission The package default-exports `{ id: "opencode-beads", setup }` (the shape diff --git a/tests/ci-failure-notification.test.ts b/tests/ci-failure-notification.test.ts new file mode 100644 index 0000000..5456c3a --- /dev/null +++ b/tests/ci-failure-notification.test.ts @@ -0,0 +1,92 @@ +import { expect, test } from "bun:test"; +import * as fs from "node:fs/promises"; +import { resolve } from "node:path"; + +const workflow = resolve(import.meta.dir, "../.github/workflows/notify-ci-failure.yml"); + +test("CI failure notifications use trusted metadata and a bounded, secret-safe Discord sender", async () => { + expect(await Bun.file(workflow).exists(), "failure notifier must be wired into CI").toBeTrue(); + const text = (await fs.readFile(workflow, "utf8")).replace(/\r\n/g, "\n"); + expect(text).toContain("workflows: [ci, beads-contract]"); + expect(text).toContain("github.event.workflow_run.conclusion == 'failure'"); + expect(text).toContain("permissions: {}"); + expect(text).not.toMatch(/uses:|download-artifact|actions\/checkout/); + const source = text.match(/ python3 - <<'PY'\n([\s\S]+?) PY\n/)?.[1]?.replace(/^ /gm, ""); + expect(source).toBeDefined(); + const program = ` +import io, json, os, tempfile, urllib.error, urllib.request +scope = {"__name__": "fixture"} +exec(${JSON.stringify(source)}, scope) +webhook = "https://discord.com/api/webhooks/123/fixture-not-real" +calls = [] +mode = "success" +class Opener: + def open(self, request, timeout): + calls.append(request) + assert timeout == 10 + assert request.full_url == webhook + "?wait=true" + assert request.get_header("User-agent") == "OpenCode-Beads-CI-Notifier/1.0" + payload = json.loads(request.data) + assert payload["allowed_mentions"] == {"parse": []} + assert "https://github.com/snarkipus/opencode-beads/actions/runs/42" in payload["content"] + assert len(payload["content"]) < 2000 + if mode == "http": + raise urllib.error.HTTPError(webhook, 403, "Forbidden", {}, None) + if mode == "network": + raise OSError(webhook) + return io.BytesIO(b'{"id":"456"}') +def opener(*handlers): + assert len(handlers) == 1 + assert handlers[0].redirect_request(None, None, None, None, None, None) is None + return Opener() +urllib.request.build_opener = opener +with tempfile.TemporaryDirectory(prefix="ci-notify-test-") as directory: + event_path = os.path.join(directory, "event.json") + os.environ["GITHUB_EVENT_PATH"] = event_path + os.environ["DISCORD_CI_WEBHOOK_URL"] = webhook + event = {"repository": {"full_name": "snarkipus/opencode-beads"}, + "workflow_run": {"name": "ci", "conclusion": "failure", "id": 42, + "head_branch": "@everyone " + "x" * 300}} + def event_file(): + with open(event_path, "w") as target: json.dump(event, target) + event_file() + assert scope["main"]() == 0 and len(calls) == 1 + calls.clear() + event["workflow_run"]["conclusion"] = "success" + event_file() + assert scope["main"]() == 0 and not calls + event["workflow_run"]["conclusion"] = "failure" + for field, invalid in [("repository", "other/repo"), ("workflow", "release")]: + original_repo = event["repository"]["full_name"] + original_name = event["workflow_run"]["name"] + if field == "repository": event["repository"]["full_name"] = invalid + else: event["workflow_run"]["name"] = invalid + event_file() + try: scope["main"](); raise AssertionError("Untrusted metadata accepted") + except SystemExit as error: assert "Unexpected" in str(error) + assert not calls + event["repository"]["full_name"] = original_repo + event["workflow_run"]["name"] = original_name + event_file() + for invalid in ["", "https://other.example/api/webhooks/123/token"]: + os.environ["DISCORD_CI_WEBHOOK_URL"] = invalid + try: scope["main"](); raise AssertionError("Missing/incorrect destination accepted") + except SystemExit as error: assert "Set DISCORD_CI_WEBHOOK_URL" in str(error) + assert not calls + os.environ["DISCORD_CI_WEBHOOK_URL"] = webhook + for failure in ["http", "network"]: + mode = failure + calls.clear() + try: scope["main"](); raise AssertionError("Delivery failure hidden") + except SystemExit as error: + assert webhook not in str(error) and "fixture-not-real" not in str(error) + assert "HTTP 403" in str(error) if failure == "http" else "uncertain" in str(error) + assert len(calls) == 1 +print("NOTIFIER_OFFLINE_PASS") +`; + const child = Bun.spawn([process.platform === "win32" ? "python" : "python3", "-c", program], { stdout: "pipe", stderr: "pipe" }); + const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + expect(code, stderr).toBe(0); + expect(stdout).toContain("NOTIFIER_OFFLINE_PASS"); + expect(stdout + stderr).not.toContain("fixture-not-real"); +}); diff --git a/tests/opencode-release-monitor.test.ts b/tests/opencode-release-monitor.test.ts new file mode 100644 index 0000000..1e68305 --- /dev/null +++ b/tests/opencode-release-monitor.test.ts @@ -0,0 +1,165 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import * as fs from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const root = resolve(import.meta.dir, ".."); +const reviewedVersion = (await fs.readFile(join(root, ".github/opencode-reviewed-version"), "utf8")).trim(); +const [major, minor, patch] = reviewedVersion.split("."); +const nextVersion = `${major}.${minor}.${Number(patch) + 1}`; +const directories: string[] = []; +afterEach(async () => { + await Promise.all(directories.splice(0).map(dir => fs.rm(dir, { recursive: true, force: true }))); +}); + +/** Exercise the actual workflow shell, mocking only GitHub retrieval and SDK commands. */ +async function monitor(version: string, options: { sdkFailure?: boolean; unavailable?: boolean; prerelease?: boolean; noMatch?: boolean; crlf?: boolean } = {}) { + let workflow = await fs.readFile(join(root, ".github/workflows/beads-contract.yml"), "utf8"); + if (options.crlf) workflow = workflow.replace(/\r?\n/g, "\r\n"); + workflow = workflow.replace(/\r\n/g, "\n"); + const job = workflow.split(" opencode-release:\n")[1]?.split("\n source-comparison:\n")[0]; + expect(job, "the existing daily workflow must include an independent OpenCode release job").toBeDefined(); + const blocks = [...job!.matchAll(/ run: \|\n((?: .*\n|\n)+)/g)] + .map(match => match[1]!.replace(/^ /gm, "")); + expect(blocks).toHaveLength(3); + expect(job).toContain("always() && steps.opencode-release.outcome == 'success'"); + expect(job).not.toContain("needs:"); + + const directory = await fs.mkdtemp(join(tmpdir(), "oc-release-monitor-")); + directories.push(directory); + const repo = join(directory, "repo"); + const bin = join(directory, "bin"); + const scratch = join(directory, "scratch"); + await Promise.all([fs.mkdir(join(repo, ".github"), { recursive: true }), fs.mkdir(bin), fs.mkdir(scratch)]); + const manifest = JSON.stringify({ devDependencies: { "@opencode/plugin": "2.2.6" }, marker: "unchanged" }); + await fs.writeFile(join(repo, "package.json"), manifest); + await fs.writeFile(join(repo, "bun.lock"), "original-lock\n"); + await fs.copyFile(join(root, ".github/opencode-reviewed-version"), join(repo, ".github/opencode-reviewed-version")); + const git = async (...args: string[]) => { + const p = Bun.spawn(["git", ...args], { cwd: repo, stdout: "pipe", stderr: "pipe" }); + expect(await p.exited, await new Response(p.stderr).text()).toBe(0); + }; + await git("init", "--quiet"); + await git("add", "."); + await git("-c", "user.name=Monitor fixture", "-c", "user.email=fixture@example.invalid", "commit", "--quiet", "--no-verify", "-m", "Fixture baseline"); + await fs.writeFile(join(bin, "gh"), '#!/usr/bin/env bash\nif [[ "$GH_FAILURE" == 1 ]]; then exit 22; fi\nif [[ "$1" == api ]]; then printf "%s\\n" "$RELEASE_PAGES"; elif [[ "$3" == "$EXPECTED_TAG" ]]; then printf "%s\\n" "$RELEASE_JSON"; else printf "%s\\n" "$LEGACY_RELEASE_JSON"; fi\n', { mode: 0o755 }); + await fs.writeFile(join(bin, "bun"), '#!/usr/bin/env bash\nexec "$BUN_EXECUTABLE" "$SDK_STUB" "$@"\n', { mode: 0o755 }); + const stub = join(directory, "sdk-stub.ts"); + await fs.writeFile(stub, ` +import * as fs from "node:fs/promises"; +import { relative } from "node:path"; +const args = process.argv.slice(2); +const manifest = JSON.parse(await fs.readFile("package.json", "utf8")); +if (args[0] === "install") { + if (!args.includes("--ignore-scripts")) throw new Error("SDK installation must not run lifecycle scripts"); + if (!relative(process.cwd(), process.env.BUN_INSTALL_CACHE_DIR!).startsWith("..")) throw new Error("Cache must be outside source"); + await fs.mkdir("node_modules/@opencode/plugin", { recursive: true }); + await fs.writeFile("node_modules/@opencode/plugin/package.json", JSON.stringify({ version: manifest.devDependencies["@opencode/plugin"] })); + await fs.writeFile("bun.lock", "candidate-lock"); +} +const installed = JSON.parse(await fs.readFile("node_modules/@opencode/plugin/package.json", "utf8")).version; +await fs.appendFile(process.env.CALL_LOG!, JSON.stringify({ args, installed, declared: manifest.devDependencies["@opencode/plugin"], lock: await fs.readFile("bun.lock", "utf8") }) + "\\n"); +if (process.env.SDK_FAILURE === "1" && args[0] === "test") process.exit(33); +`); + const output = join(directory, "output"); + const summary = join(directory, "summary.md"); + const callLog = join(directory, "calls.jsonl"); + await fs.writeFile(output, ""); + const env = { ...process.env, PATH: `${bin}${process.platform === "win32" ? ";" : ":"}${process.env.PATH}`, + GITHUB_WORKSPACE: repo, RUNNER_TEMP: scratch, GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: summary, + BUN_EXECUTABLE: process.execPath, SDK_STUB: stub, CALL_LOG: callLog, + GH_FAILURE: options.unavailable ? "1" : "0", SDK_FAILURE: options.sdkFailure ? "1" : "0", + REQUESTED: options.prerelease ? `v${version}` : "", EXPECTED_TAG: `v${version}`, + LEGACY_RELEASE_JSON: JSON.stringify({ tagName: "v1.19.5", isDraft: false, isPrerelease: false, url: "https://github.com/anomalyco/opencode/releases/tag/v1.19.5" }), + RELEASE_PAGES: JSON.stringify([ + { data: { repository: { releases: { nodes: [ + { tagName: "process-daemon-v0.5.2", isDraft: false, isPrerelease: false }, + { tagName: "v1.19.5", isDraft: false, isPrerelease: false }, + { tagName: "v9.0.0", isDraft: false, isPrerelease: true }, + { tagName: "v8.0.0", isDraft: true, isPrerelease: false }, + ...(options.noMatch ? [] : [{ tagName: `v${reviewedVersion}`, isDraft: false, isPrerelease: false }]), + ] } } } }, + { data: { repository: { releases: { nodes: options.noMatch ? [] : [{ tagName: `v${version}`, isDraft: false, isPrerelease: false }] } } } }, + ]), + RELEASE_JSON: JSON.stringify({ tagName: `v${version}`, isDraft: false, isPrerelease: options.prerelease ?? false, + url: `https://github.com/anomalyco/opencode/releases/tag/v${version}` }) }; + async function shell(block: string, extra: Record = {}) { + const p = Bun.spawn(["bash", "-e", "-o", "pipefail", "-c", block], { cwd: repo, env: { ...env, ...extra }, stdout: "pipe", stderr: "pipe" }); + const [exitCode, stdout, stderr] = await Promise.all([p.exited, new Response(p.stdout).text(), new Response(p.stderr).text()]); + return { exitCode, stdout, stderr }; + } + const release = await shell(blocks[0]!); + if (release.exitCode !== 0) { + expect(await fs.readFile(output, "utf8")).toBe(""); + return { release, sdk: undefined, review: undefined, calls: [], summary: "" }; + } + const outputs = Object.fromEntries((await fs.readFile(output, "utf8")).trim().split("\n").map(line => line.split("="))); + expect(outputs.version).toBe(version); + expect(outputs.reviewed).toBe(reviewedVersion); + const sdk = await shell(blocks[1]!, { VERSION: outputs.version! }); + // Actions runs this always-step even if the SDK step failed. + const review = await shell(blocks[2]!, { VERSION: outputs.version!, REVIEWED: outputs.reviewed!, SDK_OUTCOME: sdk.exitCode === 0 ? "success" : "failure" }); + const calls = (await fs.readFile(callLog, "utf8")).trim().split("\n").map(line => JSON.parse(line)); + expect(await fs.readFile(join(repo, "package.json"), "utf8")).toBe(manifest); + expect(await fs.readFile(join(repo, "bun.lock"), "utf8")).toBe("original-lock\n"); + expect((await fs.readFile(join(repo, ".github/opencode-reviewed-version"), "utf8")).trim()).toBe(reviewedVersion); + expect((await fs.readdir(scratch)).sort()).toEqual(["opencode-release.json", "opencode-releases.json"]); + return { release, sdk, review, calls, summary: await fs.readFile(summary, "utf8") }; +} + +describe("OpenCode stable-release monitoring", () => { + test("already reviewed release runs exact SDK checks without advancing the pinned baseline", async () => { + const result = await monitor(reviewedVersion, { crlf: true }); + expect(result.release.exitCode).toBe(0); + expect(result.sdk?.exitCode).toBe(0); + expect(result.review?.exitCode).toBe(0); + expect(result.calls.map(call => call.args)).toEqual([["install", "--ignore-scripts"], ["test"], ["run", "typecheck"], ["run", "test:package"]]); + expect(result.calls.every(call => call.installed === reviewedVersion)).toBeTrue(); + expect(result.calls.slice(1).every(call => call.declared === "2.2.6" && call.lock === "original-lock\n")).toBeTrue(); + expect(result.summary).toContain("SDK checks: success"); + expect(result.summary).toContain(`Release: [v${reviewedVersion}](https://github.com/anomalyco/opencode/releases/tag/v${reviewedVersion})`); + expect(result.summary).toContain("not live-host/model qualification"); + expect(result.review?.stdout).not.toContain("::error::"); + }); + + test("new stable release requires human review even when SDK checks pass", async () => { + const result = await monitor(nextVersion); + expect(result.sdk?.exitCode).toBe(0); + expect(result.review?.exitCode).toBe(1); + expect(result.review?.stdout).toContain(`::error::OpenCode ${nextVersion}`); + expect(result.calls.every(call => call.installed === nextVersion)).toBeTrue(); + expect(result.summary).toContain("requires human review"); + }); + + test("failed SDK checks remain failed and still produce a review summary", async () => { + const result = await monitor(reviewedVersion, { sdkFailure: true }); + expect(result.sdk?.exitCode).toBe(33); + expect(result.review?.exitCode).toBe(0); + expect(result.calls.map(call => call.args)).toEqual([["install", "--ignore-scripts"], ["test"]]); + expect(result.summary).toContain("SDK checks: failure"); + }); + + test("unavailable or prerelease input cannot be reported as a completed stable comparison", async () => { + for (const options of [{ unavailable: true }, { prerelease: true }]) { + const result = await monitor(nextVersion, options); + expect(result.release.exitCode).not.toBe(0); + expect(result.sdk).toBeUndefined(); + expect(result.review).toBeUndefined(); + } + }); + + test("no matching stable release is unavailable rather than successful no-change", async () => { + const result = await monitor(nextVersion, { noMatch: true }); + expect(result.release.exitCode).not.toBe(0); + expect(result.sdk).toBeUndefined(); + expect(result.review).toBeUndefined(); + }); + + test("new release still requires review when its SDK checks fail", async () => { + const result = await monitor(nextVersion, { sdkFailure: true }); + expect(result.sdk?.exitCode).toBe(33); + expect(result.review?.exitCode).toBe(1); + expect(result.summary).toContain("SDK checks: failure"); + expect(result.summary).toContain("requires human review"); + }); +}); From 059ccbf8023b0226affec5f506587650c5972c18 Mon Sep 17 00:00:00 2001 From: Matt Jackson Date: Fri, 13 Nov 2026 04:44:39 +0000 Subject: [PATCH 2/2] test: scope Ubuntu workflow shell fixtures to their runner --- tests/opencode-release-monitor.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/opencode-release-monitor.test.ts b/tests/opencode-release-monitor.test.ts index 1e68305..cf093a1 100644 --- a/tests/opencode-release-monitor.test.ts +++ b/tests/opencode-release-monitor.test.ts @@ -107,7 +107,9 @@ if (process.env.SDK_FAILURE === "1" && args[0] === "test") process.exit(33); return { release, sdk, review, calls, summary: await fs.readFile(summary, "utf8") }; } -describe("OpenCode stable-release monitoring", () => { +// These literal Bash/Git/jq blocks target the Ubuntu-only maintenance job. +// Product Windows coverage and the platform-independent notifier test remain enabled. +describe.skipIf(process.platform === "win32")("OpenCode stable-release monitoring", () => { test("already reviewed release runs exact SDK checks without advancing the pinned baseline", async () => { const result = await monitor(reviewedVersion, { crlf: true }); expect(result.release.exitCode).toBe(0);