fix(ci): enforce exact-SHA release-green evidence and verdicts

This commit is contained in:
diegosouzapw
2026-09-21 17:56:58 -03:00
parent 06f1df9d77
commit 116ab84c53
4 changed files with 312 additions and 19 deletions

View File

@@ -1,6 +1,6 @@
name: Release-Green (continuous)
# Solution D — continuous, NON-BLOCKING drift signal for the active release branch.
# Continuous validation: artifacts, issue state and job exit must agree.
#
# WHY: the full gate (ci.yml) only runs on the release PR (PR → main), so reds
# accrue silently on release/** and explode — in layers — at release time. This
@@ -31,6 +31,8 @@ on:
- "scripts/**"
- "tests/**"
- "config/**"
- ".github/workflows/**"
- ".github/actions/**"
- "package.json"
- "package-lock.json"
- "tsconfig*.json"
@@ -53,7 +55,7 @@ concurrency:
# push storms during merge campaigns collapse to the newest commit per branch;
# scheduled full sweeps keep their own single lane.
group: release-green-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true
cancel-in-progress: ${{ github.event_name == 'push' }}
env:
OMNIROUTE_SKIP_SYSTEM_TRUST: "1"
@@ -111,11 +113,21 @@ jobs:
- name: Checkout the release branch
env:
TARGET: ${{ steps.branch.outputs.target }}
EVENT_NAME: ${{ github.event_name }}
PUSH_SHA: ${{ github.sha }}
run: |
set -euo pipefail
git checkout "$TARGET"
if [ "$EVENT_NAME" = "push" ]; then
git checkout --detach "$PUSH_SHA"
else
git checkout --detach "origin/$TARGET"
fi
git log -1 --oneline
- name: Record validated revision
id: revision
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- uses: actions/setup-node@v7
with:
node-version: "24"
@@ -127,6 +139,7 @@ jobs:
id: validate
env:
EVENT_NAME: ${{ github.event_name }}
VALIDATED_SHA: ${{ steps.revision.outputs.sha }}
run: |
set +e
# --hermetic: scrub live-test trigger vars (self-hosted runner may carry
@@ -147,7 +160,8 @@ jobs:
# shellcheck disable=SC2086
node scripts/quality/validate-release-green.mjs --json --hermetic $MODE \
1> release-green.json 2> release-green.log
echo "exit=$?" >> "$GITHUB_OUTPUT"
validation_exit=$?
node scripts/ci/release-green-result.mjs release-green.json "$validation_exit" "$VALIDATED_SHA" "$EVENT_NAME" >> release-green.log
echo "------- report -------"
cat release-green.log
@@ -165,23 +179,26 @@ jobs:
TITLE="🔴 Release branch not green: ${TARGET}"
{
echo "The **release-green** validation found HARD failures on \`${TARGET}\`."
echo "These are real defects that would block the release PR — fix them in the"
echo "originating PR branch (via co-authorship), not by demanding it from contributors."
echo "The required validation failed or its evidence was incomplete. Compare the"
echo "signature on the exact base before attributing a defect to a contributor."
echo ""
echo "**Run:** ${RUN_URL} (mode: ${EVENT_NAME})"
# WS5.1 attribution: on push events the offending change IS this push's range
# (one merge per push in the normal queue), so name it — no bisect needed.
# This is an observed range, not proof of causality. Compare with the base.
if [ "$EVENT_NAME" = "push" ] && [ -n "${BEFORE_SHA:-}" ] && \
git cat-file -e "$BEFORE_SHA" 2>/dev/null; then
echo ""
echo "**Offending push range** (\`${BEFORE_SHA:0:9}..${AFTER_SHA:0:9}\`):"
echo "**Observed push range** (\`${BEFORE_SHA:0:9}..${AFTER_SHA:0:9}\`):"
echo '```'
git log --no-decorate --oneline "${BEFORE_SHA}..${AFTER_SHA}" | head -20
echo '```'
fi
echo ""
echo '```'
sed -n '/──────── verdict ────────/,$p' release-green.log || tail -40 release-green.log
if grep -q '──────── verdict ────────' release-green.log; then
sed -n '/──────── verdict ────────/,$p' release-green.log
else
tail -40 release-green.log
fi
echo '```'
echo ""
echo "_Ratchet drift (eslint warnings / cognitive-complexity / file-size) listed above is expected mid-cycle and is rebaselined at release — it is NOT a contributor concern and did not, on its own, open this issue._"
@@ -197,13 +214,19 @@ jobs:
fi
- name: Close tracking issue when the branch is green again
if: steps.validate.outputs.exit == '0'
if: steps.validate.outputs.exit == '0' && github.event_name != 'push'
env:
GH_TOKEN: ${{ github.token }}
TARGET: ${{ steps.branch.outputs.target }}
VALIDATED_SHA: ${{ steps.revision.outputs.sha }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
CURRENT_SHA=$(git ls-remote origin "refs/heads/$TARGET" | cut -f1)
if [ "$CURRENT_SHA" != "$VALIDATED_SHA" ]; then
echo "Branch advanced; retaining the tracking issue."
exit 0
fi
# The open/update step above is the UPWARD half of the loop; without this
# step a stale "not green" issue outlives the fix and every base-green check
# (`AGENTS.md` → "Base-green check") keeps stamping new PRs as base-red inherited.
@@ -212,7 +235,7 @@ jobs:
--search "in:title $TITLE" --json number --jq '.[0].number' 2>/dev/null || echo "")
if [ -n "$EXISTING" ]; then
gh issue close "$EXISTING" --repo "$GITHUB_REPOSITORY" --reason completed \
--comment "✅ \`${TARGET}\` is release-green again at \`${GITHUB_SHA:0:9}\` — ${RUN_URL}. Auto-closed by Release-Green (continuous)."
--comment "✅ \`${TARGET}\` is release-green again at \`${VALIDATED_SHA}\` — ${RUN_URL}. Auto-closed after full validation of the current branch."
echo "Closed issue #$EXISTING"
fi
@@ -224,7 +247,15 @@ jobs:
path: |
release-green.json
release-green.log
if-no-files-found: ignore
if-no-files-found: error
- name: Enforce validation verdict
if: always()
env:
VALIDATION_EXIT: ${{ steps.validate.outputs.exit }}
VALIDATED_SHA: ${{ steps.revision.outputs.sha }}
EVENT_NAME: ${{ github.event_name }}
run: node scripts/ci/release-green-result.mjs release-green.json "$VALIDATION_EXIT" "$VALIDATED_SHA" "$EVENT_NAME"
# Companion arm for `main`. Under the parallel-cycle model, main only receives merged
# work at the release squash — so a gate/infra fix that lands only on release leaves
@@ -245,10 +276,14 @@ jobs:
steps:
- uses: actions/checkout@v7
with:
ref: main # literal — no injection surface; scheduled runs default to the repo default branch (a release/v*), so pin main explicitly
ref: ${{ github.event_name == 'push' && github.sha || 'main' }}
fetch-depth: 0
persist-credentials: false
- name: Record validated revision
id: revision
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- uses: actions/setup-node@v7
with:
node-version: "24"
@@ -260,6 +295,7 @@ jobs:
id: validate
env:
EVENT_NAME: ${{ github.event_name }}
VALIDATED_SHA: ${{ steps.revision.outputs.sha }}
run: |
set +e
# push (a merge into main) → --quick fast HARD gates; schedule/dispatch → full sweep.
@@ -273,7 +309,8 @@ jobs:
# shellcheck disable=SC2086
node scripts/quality/validate-release-green.mjs --json --hermetic $MODE \
1> main-green.json 2> main-green.log
echo "exit=$?" >> "$GITHUB_OUTPUT"
validation_exit=$?
node scripts/ci/release-green-result.mjs main-green.json "$validation_exit" "$VALIDATED_SHA" "$EVENT_NAME" >> main-green.log
echo "------- report -------"
cat main-green.log
@@ -299,7 +336,11 @@ jobs:
echo "**Run:** ${RUN_URL} (mode: ${EVENT_NAME})"
echo ""
echo '```'
sed -n '/──────── verdict ────────/,$p' main-green.log || tail -40 main-green.log
if grep -q '──────── verdict ────────' main-green.log; then
sed -n '/──────── verdict ────────/,$p' main-green.log
else
tail -40 main-green.log
fi
echo '```'
echo ""
echo "_Ratchet drift (eslint warnings / cognitive-complexity / file-size) is expected mid-cycle and did NOT, on its own, open this issue._"
@@ -315,12 +356,18 @@ jobs:
fi
- name: Close tracking issue when the branch is green again
if: steps.validate.outputs.exit == '0'
if: steps.validate.outputs.exit == '0' && github.event_name != 'push'
env:
GH_TOKEN: ${{ github.token }}
VALIDATED_SHA: ${{ steps.revision.outputs.sha }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
CURRENT_SHA=$(git ls-remote origin refs/heads/main | cut -f1)
if [ "$CURRENT_SHA" != "$VALIDATED_SHA" ]; then
echo "Branch advanced; retaining the tracking issue."
exit 0
fi
# The open/update step above is the UPWARD half of the loop; without this
# step a stale "not green" issue outlives the fix and every base-green check
# (`AGENTS.md` → "Base-green check") keeps stamping new PRs as base-red inherited.
@@ -329,7 +376,7 @@ jobs:
--search "in:title $TITLE" --json number --jq '.[0].number' 2>/dev/null || echo "")
if [ -n "$EXISTING" ]; then
gh issue close "$EXISTING" --repo "$GITHUB_REPOSITORY" --reason completed \
--comment "✅ \`main\` is main-green again at \`${GITHUB_SHA:0:9}\` — ${RUN_URL}. Auto-closed by Release-Green (continuous)."
--comment "✅ \`main\` is main-green again at \`${VALIDATED_SHA}\` — ${RUN_URL}. Auto-closed after full validation of the current branch."
echo "Closed issue #$EXISTING"
fi
@@ -341,7 +388,15 @@ jobs:
path: |
main-green.json
main-green.log
if-no-files-found: ignore
if-no-files-found: error
- name: Enforce validation verdict
if: always()
env:
VALIDATION_EXIT: ${{ steps.validate.outputs.exit }}
VALIDATED_SHA: ${{ steps.revision.outputs.sha }}
EVENT_NAME: ${{ github.event_name }}
run: node scripts/ci/release-green-result.mjs main-green.json "$VALIDATION_EXIT" "$VALIDATED_SHA" "$EVENT_NAME"
# ── Banking lane (#8584) ──────────────────────────────────────────────────
# The ratchet is asymmetric: RAISING a cap is a ten-second manual JSON edit made

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env node
// No npm dependencies: this verifier must still run after installation/validator failure.
import { appendFileSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
export function verifyReleaseGreenReport(report, exitCode, candidateSha, eventName) {
const expectedProfile =
eventName === "push"
? "quick"
: ["schedule", "workflow_dispatch"].includes(eventName)
? "full"
: undefined;
const valid =
report?.schemaVersion === 1 &&
/^[0-9a-f]{40}$/.test(candidateSha ?? "") &&
report.candidateSha === candidateSha &&
expectedProfile !== undefined &&
report.profile === expectedProfile &&
/^\d+$/.test(exitCode ?? "") &&
Number(exitCode) <= 255 &&
typeof report.releaseGreen === "boolean" &&
Array.isArray(report.hardFailures) &&
Array.isArray(report.checks) &&
report.checks.length > 0 &&
report.checks.every(
(check) =>
typeof check?.id === "string" &&
check.id.length > 0 &&
["hard", "drift"].includes(check.kind) &&
typeof check.ok === "boolean"
) &&
report.checks.some((check) => check.kind === "hard") &&
new Set(report.checks.map((check) => check.id)).size === report.checks.length;
if (!valid)
return {
exit: 1,
verdict: "UNKNOWN",
evidenceState: "INCOMPLETE",
reason: "Invalid or missing report/provenance",
};
const failed = report.checks.filter((check) => check.kind === "hard" && !check.ok);
const consistent =
report.releaseGreen === (failed.length === 0) &&
report.hardFailures.length === failed.length &&
failed.every((check) => report.hardFailures.some((failure) => failure?.id === check.id));
if (!consistent || (exitCode === "0") !== report.releaseGreen) {
return {
exit: 1,
verdict: "UNKNOWN",
evidenceState: "INCOMPLETE",
reason: "Exit and report disagree",
};
}
return {
exit: report.releaseGreen ? 0 : 1,
verdict: report.releaseGreen ? "PASS" : "FAIL",
evidenceState: "COMPLETE",
reason: report.releaseGreen ? "Validated report" : "Required validation failed",
};
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const [path, exitCode, candidateSha, eventName] = process.argv.slice(2);
let report;
try {
report = JSON.parse(readFileSync(path, "utf8"));
} catch {
/* verifier fails closed below */
}
const result = verifyReleaseGreenReport(report, exitCode, candidateSha, eventName);
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `exit=${result.exit}\n`);
console.log(JSON.stringify(result));
process.exitCode = result.exit;
}

View File

@@ -869,6 +869,13 @@ async function main() {
process.stdout.write(
JSON.stringify(
{
schemaVersion: 1,
candidateSha: execFileSync("git", ["rev-parse", "HEAD"], {
cwd: ROOT,
encoding: "utf8",
}).trim(),
profile: QUICK ? "quick" : WITH_BUILD && FULL_CI ? "full" : "standard",
completedAt: new Date().toISOString(),
releaseGreen,
hardFailures: hardFailures.map((r) => ({ id: r.id, label: r.label, detail: r.detail })),
drift: drift.map((r) => ({ id: r.id, label: r.label, detail: r.detail })),

View File

@@ -0,0 +1,157 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import {
cpSync,
existsSync,
mkdtempSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { parse } from "yaml";
const root = new URL("../../../", import.meta.url);
type Step = { name?: string; id?: string; run?: string; if?: string; env?: Record<string, string> };
const workflow = parse(
readFileSync(new URL(".github/workflows/nightly-release-green.yml", root), "utf8")
) as {
jobs: Record<string, { steps: Step[] }>;
};
const sha = "1".repeat(40);
function execute(job: string, validatorExit: number, report: string, event = "schedule") {
const dir = mkdtempSync(join(tmpdir(), "omni-green-workflow-"));
try {
mkdirSync(join(dir, "scripts/quality"), { recursive: true });
mkdirSync(join(dir, "scripts/ci"), { recursive: true });
writeFileSync(
join(dir, "scripts/quality/validate-release-green.mjs"),
"process.stdout.write(process.env.FIXTURE_REPORT); process.exit(Number(process.env.FIXTURE_EXIT));"
);
const verifier = new URL("scripts/ci/release-green-result.mjs", root);
if (existsSync(verifier)) cpSync(verifier, join(dir, "scripts/ci/release-green-result.mjs"));
const outputs = join(dir, "outputs");
writeFileSync(outputs, "");
const env = {
...process.env,
EVENT_NAME: event,
GITHUB_OUTPUT: outputs,
FIXTURE_EXIT: String(validatorExit),
FIXTURE_REPORT: report,
VALIDATED_SHA: sha,
};
const validate = workflow.jobs[job].steps.find((s) => s.id === "validate")!;
const result = spawnSync("bash", ["-euo", "pipefail", "-c", validate.run!], {
cwd: dir,
env,
encoding: "utf8",
});
assert.equal(result.error, undefined);
const values = Object.fromEntries(
readFileSync(outputs, "utf8")
.trim()
.split("\n")
.map((l) => l.split("="))
);
const enforce = workflow.jobs[job].steps.find((s) => s.name === "Enforce validation verdict");
if (!enforce) return result.status;
assert.equal(enforce.if, "always()");
const final = spawnSync("bash", ["-euo", "pipefail", "-c", enforce.run!], {
cwd: dir,
encoding: "utf8",
env: { ...env, VALIDATION_EXIT: values.exit ?? "", REPORT_PREFIX: job },
});
assert.equal(final.error, undefined);
return final.status;
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
const pass = JSON.stringify({
schemaVersion: 1,
candidateSha: sha,
profile: "full",
releaseGreen: true,
hardFailures: [],
checks: [{ id: "fixture", kind: "hard", ok: true }],
});
for (const job of ["release-green", "main-green"]) {
test(`${job}: a failed validator cannot produce a successful workflow`, () => {
assert.notEqual(execute(job, 42, ""), 0);
});
test(`${job}: exit zero with absent or inconsistent evidence fails closed`, () => {
for (const report of [
"",
"{}",
"not-json",
pass.replace('"releaseGreen":true', '"releaseGreen":false'),
pass.replace('"ok":true', '"ok":false'),
pass.replace(sha, "2".repeat(40)),
pass.replace('"profile":"full"', '"profile":"quick"'),
pass.replace('"id":"fixture"', '"id":""'),
pass.replace('"checks":[', '"checks":[{"id":"fixture","kind":"hard","ok":true},'),
]) {
assert.notEqual(execute(job, 0, report), 0, report);
}
});
test(`${job}: a complete consistent PASS retains success`, () => {
assert.equal(execute(job, 0, pass), 0);
assert.equal(execute(job, 0, pass.replace('"profile":"full"', '"profile":"quick"'), "push"), 0);
});
test(`${job}: quick runs cannot close a full-gate incident`, () => {
const close = workflow.jobs[job].steps.find((s) => s.name?.startsWith("Close tracking issue"))!;
assert.match(close.if ?? "", /github\.event_name != 'push'/);
assert.match(close.run ?? "", /refs\/heads\//);
assert.match(close.run ?? "", /VALIDATED_SHA/);
});
test(`${job}: only the still-current validated SHA can close an incident`, () => {
const close = workflow.jobs[job].steps.find((s) => s.name?.startsWith("Close tracking issue"))!;
const dir = mkdtempSync(join(tmpdir(), "omni-green-close-"));
try {
const calls = join(dir, "gh-calls");
for (const current of ["2".repeat(40), sha]) {
writeFileSync(calls, "");
const result = spawnSync(
"bash",
[
"-euo",
"pipefail",
"-c",
`
git() { printf '%s\\trefs/heads/main\\n' "$FIXTURE_CURRENT_SHA"; }
gh() {
printf '%s\\n' "$*" >> "$FIXTURE_GH_CALLS"
if [ "$2" = "list" ]; then printf '123\\n'; fi
}
${close.run}
`,
],
{
encoding: "utf8",
env: {
...process.env,
VALIDATED_SHA: sha,
FIXTURE_CURRENT_SHA: current,
FIXTURE_GH_CALLS: calls,
TARGET: "release/v3.8.51",
RUN_URL: "https://example.invalid/runs/1",
GITHUB_REPOSITORY: "fixture/repo",
},
}
);
assert.equal(result.status, 0, result.stderr);
const recorded = readFileSync(calls, "utf8");
if (current === sha) assert.match(recorded, /issue close 123/);
else assert.equal(recorded, "", "stale validation must not mutate issues");
}
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
}