Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/opencode-reviewed-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
2.2.7
95 changes: 95 additions & 0 deletions .github/workflows/beads-contract.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
70 changes: 70 additions & 0 deletions .github/workflows/notify-ci-failure.yml
Original file line number Diff line number Diff line change
@@ -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
43 changes: 43 additions & 0 deletions docs/opencode-sdk-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 92 additions & 0 deletions tests/ci-failure-notification.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
Loading
Loading