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
109 changes: 72 additions & 37 deletions .github/workflows/specgit-accept.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,27 @@ name: SpecGit Acceptance

on:
pull_request:
# Delivery PRs target dev (fast-integration layer); the acceptance
# verdict runs only on the dev→main promotion PR, where protect-main's
# checks apply. Keep the trigger main-only (d6ce53a83): running it on
# dev PRs duplicated the verdict against the lighter dev gate.
branches: [main]
# Local specialization (repo-only deviation from the specgit template):
# no workflow_dispatch trigger. Dispatch is the privileged context that
# made CodeQL's cache-poisoning taint rule fire on the head_ref checkout;
# it also evaluated the wrong tree here (head_ref is empty on dispatch,
# so the verdict would run against the default branch). This repo's
# delivery flow always goes through a PR, so dispatch has no use.
# Re-apply this deletion after every `specgit init --force`.

permissions:
contents: read

jobs:
specgit-acceptance:
name: SpecGit Acceptance
# Portable gate for any adopting repository: the published CLI is
# installed at the exact version `specgit init` pinned. The adopting
# project's own toolchain (package manager, lockfile, build, layout)
# is never assumed and never invoked.
runs-on: ubuntu-latest
# Must exceed the slowest required sibling (Unit Tests (linux) runs
# ~28min on PRs): the verdict waits for every policy check to reach a
# terminal state before evaluating.
timeout-minutes: 45
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand All @@ -33,67 +37,98 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
node-version: '20.19'

# This repo is a bun workspace and does not vendor the SpecGit CLI;
# install the published CLI instead of building from source. Pinned
# with a caret floor (#366): the CLI releases multiple times a day and
# an unpinned install would let an unnoticed upstream change flip CI
# acceptance verdicts repo-wide.
- name: Install specgit CLI
run: npm install -g specgit@^0.5.0
- name: Install pinned SpecGit CLI
# Exact version on purpose (no ^): the gate must evaluate with the
# same CLI generation that wrote the binding; upgrades are a
# deliberate re-init. --no-save keeps the adopting tree clean.
run: npm install --no-save --no-audit --no-fund specgit@1.0.1

- name: Wait for sibling checks
# The verdict must see the OTHER required checks in a terminal
# state. Sibling jobs start in parallel AND may not have registered
# their check-runs yet, so an empty poll is not "done": wait until
# every name in spec_git/policy.yaml is present with a terminal
# conclusion. This job is not in the policy, so no self-deadlock.
# All GitHub access goes through the authenticated gh CLI.
env:
GH_TOKEN: ${{ github.token }}
WAIT_REPO: ${{ github.repository }}
WAIT_SHA: ${{ github.event.pull_request.head.sha }}
WAIT_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
run: |
node --input-type=module <<'EOF'
import { readFileSync } from 'node:fs';
// Minimal parse of policy.yaml's required_checks block list —
// avoids a yaml dependency in this bun-based repo.
const policy = readFileSync('spec_git/policy.yaml', 'utf8');
const section = policy.slice(policy.indexOf('required_checks:'));
const required = [...section.matchAll(/^\s*-\s*(.+)$/gm)].map((m) => m[1].trim());
const headers = {
authorization: 'Bearer ' + process.env.GH_TOKEN,
accept: 'application/vnd.github+json',
};
const url = 'https://api.github.com/repos/' + process.env.WAIT_REPO
+ '/commits/' + process.env.WAIT_SHA + '/check-runs?per_page=100';
import { execFileSync } from 'node:child_process';
import { parse } from 'yaml';
const policy = parse(readFileSync('spec_git/policy.yaml', 'utf8'));
const required = policy.required_checks ?? [];
const listChecks = () =>
JSON.parse(
execFileSync(
'gh',
[
'api',
'repos/' + process.env.WAIT_REPO + '/commits/' + process.env.WAIT_SHA + '/check-runs?per_page=100',
],
// gh.cmd needs a shell on Windows; POSIX execs the binary
// directly (shell stays off where it is not needed).
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32' }
)
);
const terminal = new Set(['completed']);
const terminalHas = (byName, name) => {
if (byName.has(name)) return terminal.has(byName.get(name));
const retried = [...byName.keys()].find((k) => k.startsWith(name + ' ('));
return retried !== undefined && terminal.has(byName.get(retried));
};
// Must outlast the slowest required sibling (Unit Tests (linux)
// runs ~28min on PRs); the job timeout above bounds this too.
const deadline = Date.now() + 40 * 60 * 1000;
// Transient API failures (5xx, 429, network) retry with bounded
// exponential backoff — a platform blip must not fail the gate.
const MAX_ATTEMPTS = 5;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const listChecksWithRetry = async () => {
for (let attempt = 1; ; attempt += 1) {
try {
return listChecks();
} catch (error) {
const text = String(error) + ' ' + String(error && error.stderr ? error.stderr : '');
const transient = /HTTP 5\d\d|HTTP 429|ETIMEDOUT|ECONNRESET|ENOTFOUND|timed out/i.test(text);
if (attempt >= MAX_ATTEMPTS || !transient) throw error;
const backoff = Math.min(30000, 2000 * 2 ** (attempt - 1));
console.log('Transient failure; retry ' + attempt + '/' + MAX_ATTEMPTS + ' in ' + backoff + 'ms');
await sleep(backoff);
}
}
};
const deadline = Date.now() + 15 * 60 * 1000;
while (Date.now() < deadline) {
const res = await fetch(url, { headers });
if (!res.ok) throw new Error('check-runs API ' + res.status);
const payload = await res.json();
const byName = new Map(payload.check_runs.map((r) => [r.name, r.status]));
const payload = await listChecksWithRetry();
// #119: re-runs keep every same-name run; terminality is
// decided on the truth run — latest started_at, ties broken
// by the higher check-run id (docs/reference.md) — never on
// response position.
const truth = new Map();
for (const r of payload.check_runs) {
const cur = truth.get(r.name);
const later = cur === undefined
|| (r.started_at || '') > (cur.started_at || '')
|| ((r.started_at || '') === (cur.started_at || '') && (r.id || 0) > (cur.id || 0));
if (later) truth.set(r.name, r);
}
const byName = new Map([...truth].map(([name, r]) => [name, r.status]));
const missing = required.filter((n) => !terminalHas(byName, n));
if (missing.length === 0) {
console.log('All required checks are in a terminal state.');
process.exit(0);
}
console.log('Waiting for: ' + missing.join(', '));
await new Promise((r) => setTimeout(r, 10000));
await sleep(10000);
}
console.error('Timed out waiting for sibling checks.');
process.exit(1);
EOF

- name: specgit finish
run: specgit finish --json
run: npx --no-install specgit finish --json
env:
GH_TOKEN: ${{ github.token }}
156 changes: 148 additions & 8 deletions .opencode/hooks/specgit-merge-guard.sh
Original file line number Diff line number Diff line change
@@ -1,16 +1,156 @@
#!/bin/sh
# SpecGit merge guard (managed by specgit init). Exit 2 = block with reason.
command=$(printf '%s' "$1" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{const j=JSON.parse(s);process.stdout.write((j.tool_input&&j.tool_input.command)||'')}catch{process.stdout.write('')}})")
GUARD_DIR=$(cd "$(dirname "$0")" && pwd)
export GUARD_DIR
# Hook payloads arrive as the first argument or on stdin; accept both.
if [ -n "$1" ]; then
payload=$1
else
payload=$(cat)
fi
command=$(printf '%s' "$payload" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{const j=JSON.parse(s);process.stdout.write((j.tool_input&&j.tool_input.command)||'')}catch{process.stdout.write('')}})")

case "$command" in
gh\ pr\ merge*)
# Real-time verdict: re-evaluate the delivery before letting a merge
# through. Verdicts are never persisted, so compute one now.
if specgit finish >/dev/null 2>&1; then
exit 0
fi
echo "specgit: merge blocked - 'specgit finish' does not exit 0 right now. Fix what the failures name; never weaken spec_git/policy.yaml to pass." >&2
exit 2
exec node -e '
const { spawn } = require("child_process");
const fs = require("fs");
const path = require("path");
const ghMsRaw = parseInt(process.env.SPECGIT_GH_TIMEOUT_MS || "", 10);
const ghMs = Number.isFinite(ghMsRaw) && ghMsRaw > 0 ? ghMsRaw : 15000;
const ghS = Math.max(1, Math.floor(ghMs / 1000));
let budgetS = Math.max(60, ghS * 8);
const overrideRaw = parseInt(process.env.SPECGIT_GUARD_BUDGET_S || "", 10);
if (Number.isFinite(overrideRaw) && overrideRaw > 0) {
budgetS = Math.max(overrideRaw, ghS);
}
// The hook runner kills long hooks; surface the mismatch instead of
// being cut off mid-verdict.
try {
const hooks = JSON.parse(
fs.readFileSync(path.join(process.env.GUARD_DIR || ".", "..", "hooks.json"), "utf8")
);
const runner = (hooks.PreToolUse || [])
.flatMap((entry) => entry.hooks || [])
.map((hook) => hook.timeout)
.find((timeout) => typeof timeout === "number");
if (runner !== undefined && runner - 10 < budgetS) {
console.error(
"specgit: guard budget " + budgetS + "s exceeds the hook runner timeout " +
runner + "s in .opencode/hooks.json - raise the runner timeout or lower SPECGIT_GUARD_BUDGET_S."
);
}
} catch {}
const cp = require("child_process");
const isWin = process.platform === "win32";
// Windows: cmd.exe cannot exec an extensionless sh shim, so prefer
// git-bash sh when present; only then fall back to shell mode.
let child;
if (isWin) {
const probe = cp.spawnSync("sh", ["-c", "exit 0"]);
if (probe.status === 0) {
child = spawn("sh", ["-c", "specgit finish --json"], {
stdio: ["ignore", "pipe", "pipe"],
});
}
}
if (!child) {
child = spawn("specgit", ["finish", "--json"], {
shell: isWin,
stdio: ["ignore", "pipe", "pipe"],
});
}
let out = "";
let err = "";
let expired = false;
child.stdout.on("data", (chunk) => (out += chunk));
child.stderr.on("data", (chunk) => (err += chunk));
const timer = setTimeout(() => {
expired = true;
// Bound the wait strictly: descendants may inherit the pipes, so
// destroy them and exit now — never lag behind orphaned children.
child.stdout.destroy();
child.stderr.destroy();
child.kill("SIGKILL");
console.error(
"specgit: merge blocked - guard budget " + budgetS + "s exhausted before a verdict. This says nothing about the delivery; run specgit finish directly for the full verdict."
);
process.exit(2);
}, budgetS * 1000);
child.on("error", (error) => {
clearTimeout(timer);
console.error(
"specgit: merge blocked - the verdict could not run (" + error.message + "). Install specgit on PATH, then retry the merge."
);
process.exit(2);
});
child.on("close", (code) => {
clearTimeout(timer);
if (expired) {
process.exit(2);
}
if (code === 0) {
process.exit(0);
}
let envelope = null;
try {
envelope = JSON.parse(out);
} catch {}
const verdict = envelope && envelope.verdict;
const gates = (envelope && (envelope.gates || (verdict && verdict.gates))) || [];
const failures = [];
for (const gate of gates) {
for (const failure of (gate && gate.failures) || []) failures.push(failure);
}
const label = (failure, suffix) => {
const detail = failure.detail || {};
const name = detail.name || failure.code;
const state = suffix || detail.status || detail.conclusion || "";
return name + (state ? " [" + state + "]" : "");
};
const pending = failures.filter((f) => f.code === "checks_pending");
const failed = failures.filter((f) => f.code === "checks_failed");
const other = failures.filter(
(f) => f.code !== "checks_pending" && f.code !== "checks_failed"
);
const lines = [];
if (code === 1) {
lines.push(
"specgit: merge blocked - verdict rejected (exit 1). Fix what the failures name; never weaken spec_git/policy.yaml to pass."
);
} else {
lines.push(
"specgit: merge blocked - no verdict possible (evidence incomplete, exit " + code + "). This is not a rejection: fix evidence gathering (network, gh auth), then retry."
);
}
if (pending.length > 0) {
lines.push(
" pending (transient - wait, then re-run): " + pending.map((f) => label(f)).join(", ")
);
}
if (failed.length > 0) {
lines.push(
" failed (repair required): " +
failed
.map((f) =>
label(
f,
f.detail && f.detail.conclusion === "action_required"
? "action_required - run awaits maintainer approval"
: undefined
)
)
.join(", ")
);
}
if (other.length > 0) {
lines.push(" other failures: " + other.map((f) => label(f)).join(", "));
}
lines.push("Full verdict: specgit finish");
console.error(lines.join("\n"));
process.exit(2);
});
'
;;
git\ push\ origin\ main*|git\ push\ origin\ +main*|git\ push\ origin\ HEAD:main*)
echo "specgit: direct push to main is not the delivery path. Deliveries go: specgit issue -> PR -> CI -> specgit finish (exit 0) -> merge." >&2
Expand Down
Loading
Loading