Consolidates the 18 leaf modules from the r720-plane2-scaffold workflow onto the foundation commit. Full suite: 535 passed, 1 skipped; ruff + format clean. Built (pre-deployment scaffold only — nothing provisioned/enabled): - LangGraph pipeline graph.py (INTAKE->CLARIFY->PLAN, interrupt()/resume, checkpointer-injectable) - nodes: clarifier (98% gate), planner, review_loop (GPT-4.1), builders->candidate diff, verifier - §3.3.1 HITL: ledger ops, resume_worker, deadline_timer, recovery sweep, responder - transports: slack / github / claude_code adapters - ci_gate (pure-code pass/fail), operator_cli, run-team.py entry, P1 sim harness - ci/agent-team-apply-verify.yml (split untrusted/privileged jobs) — authored, disabled KNOWN OPEN FINDINGS (verifier/cross-review, not yet fixed — see follow-up): - builders denylist: 4 execution-proven bypasses (delete, mode-change, copy-to, out-of-scope delete) - §3.3.1 CAS: BEGIN IMMEDIATE outside try/except; shared-connection txn nesting unsafe under concurrency - operator_cli: missing re-deliver/force-resume; audit-after-mutate ordering gap - ci yaml: GPT-4.1 cross-review PASS w/ 4 FIX items (symlink path escape, etc.) - P1 sim harness models the ledger layer, not real LangGraph interrupt/resume; P1 exit criteria not yet truly proven Deploy-gated (NOT done): IAM/step-ca/Roles Anywhere/confluence-bot provisioning, /sh-security-review sign-off, live Slack/CI, rsync, live dry-runs, Adam approval.
501 lines
24 KiB
YAML
501 lines
24 KiB
YAML
# R720 agent-team — split-job CI apply/verify workflow (design §3.3.2, §7.1 P3).
|
|
#
|
|
# DEPLOY-GATED PRE-DEPLOYMENT SCAFFOLDING. This file is authored as IaC only.
|
|
# It is NOT enabled, NOT provisioned, and NOT wired to any live org repo. Per
|
|
# the design (§3.3.2, §7.1 P3) it must clear BOTH `/sh-security-review` AND the
|
|
# mandatory GPT-4.1 cross-review before it is deployed (it is IaC/IAM +
|
|
# untrusted-input handling). Until then it lives here as a reviewable artifact.
|
|
#
|
|
# Deployment target (later, after the gates): promote into
|
|
# Sea-Haven-Industries/.github as a reusable workflow (engineering-handbook
|
|
# cicd.md) and have the Option-B OIDC apply path call it. The filename stays
|
|
# kebab-case per the handbook.
|
|
#
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# TRUST BOUNDARY (design §3.3.2). The builder agents are semi-trusted: an LLM
|
|
# that read repo content can be wrong or prompt-injected, so the candidate diff
|
|
# is UNTRUSTED CODE. The five boundaries this workflow implements:
|
|
#
|
|
# 1. Split CI. The job that checks out + executes the patch (`build-test`)
|
|
# runs credential-less (`permissions: contents: read`, no secrets, no
|
|
# OIDC, no write token, egress-restricted). Every privileged action runs
|
|
# in a SEPARATE job (`gate-and-pr`) that NEVER checks out or runs
|
|
# patch-controlled code; it consumes the build/test report as DATA only.
|
|
# This is NOT `pull_request_target` with a head-ref checkout (pwn request).
|
|
# 2. Trust-control-surface denylist. `guard` hard-fails (CI-side, not only the
|
|
# box) any diff touching `.github/workflows/**`, IAM/policy IaC, branch
|
|
# protection / CODEOWNERS / Dependabot, or files outside the declared task
|
|
# scope. It canonicalizes paths, resolves symlinks, and rejects renames
|
|
# into denied paths — a path match cannot be bypassed by indirection.
|
|
# 3. Diff integrity, box → CI. CI re-hashes the candidate diff and verifies it
|
|
# equals the ledger-recorded hash BEFORE applying. Tamper/substitution
|
|
# fails the hash check.
|
|
# 4. Pure-code pass/fail gate. A deterministic gate reads the authenticated
|
|
# build/test conclusion keyed to (run id + diff hash). It never trusts a
|
|
# success/failure file the patch could have written. The verifier AGENT
|
|
# only reads failures to propose a fix; it cannot declare success.
|
|
# 5. Branch protection. The draft PR targets a protected branch; the
|
|
# locked-down checks are required; merge needs them green + the
|
|
# security-review + the Claude Code App review + human approval. The agent
|
|
# NEVER auto-merges (D2).
|
|
#
|
|
# All third-party actions are SHA-pinned (handbook Pinning Principle, §3.3.2).
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
name: agent-team-apply-verify
|
|
|
|
# Manual / API trigger only. The Option-B OIDC apply path (a trusted, separate
|
|
# workflow that owns the write token) invokes this with the candidate-diff
|
|
# artifact + the ledger-recorded hash + the declared scope. There is NO
|
|
# pull_request / pull_request_target trigger: the patch must never run in a
|
|
# context that carries write or secret scope (boundary 1).
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
task_id:
|
|
description: "Pipeline task thread_id (for provenance/audit)."
|
|
required: true
|
|
type: string
|
|
diff_artifact_name:
|
|
description: "Name of the uploaded candidate-diff artifact."
|
|
required: true
|
|
type: string
|
|
expected_diff_hash:
|
|
description: "Ledger-recorded sha256 of the candidate diff (boundary 3)."
|
|
required: true
|
|
type: string
|
|
declared_scope:
|
|
description: >-
|
|
Newline-separated list of glob paths the task is allowed to touch
|
|
(boundary 2). A diff that changes anything outside this set fails.
|
|
required: true
|
|
type: string
|
|
|
|
# Workflow-level default: least privilege. Every job re-declares its own
|
|
# `permissions:` so the grant is explicit per job and the untrusted job can be
|
|
# audited at a glance.
|
|
permissions: {}
|
|
|
|
# One in-flight apply/verify per task; a re-dispatch cancels the stale run so a
|
|
# superseded diff cannot race a newer one.
|
|
concurrency:
|
|
group: agent-team-apply-verify-${{ inputs.task_id }}
|
|
cancel-in-progress: true
|
|
|
|
jobs:
|
|
# ───────────────────────────────────────────────────────────────────────────
|
|
# JOB 1 — guard (boundaries 2 + 3). Credential-less. Validates the candidate
|
|
# diff WITHOUT applying or executing it: re-hashes it (integrity) and runs the
|
|
# trust-control-surface denylist + declared-scope check. This job reads the
|
|
# diff as DATA only — it never `git apply`s it, so even a hostile diff cannot
|
|
# run code here. A failure is terminal: the diff is rejected and ALARM-worthy.
|
|
# ───────────────────────────────────────────────────────────────────────────
|
|
guard:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
permissions:
|
|
contents: read
|
|
outputs:
|
|
diff_hash: ${{ steps.verify.outputs.diff_hash }}
|
|
steps:
|
|
- name: Harden runner (egress audit; no secrets present anyway)
|
|
uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
|
|
with:
|
|
egress-policy: block
|
|
# Only what fetching the artifact + GitHub API needs. The job holds
|
|
# no secrets, so a successful exfil yields nothing of value (§3.3.2),
|
|
# but we deny egress as defense-in-depth.
|
|
allowed-endpoints: >
|
|
github.com:443
|
|
api.github.com:443
|
|
objects.githubusercontent.com:443
|
|
*.actions.githubusercontent.com:443
|
|
|
|
- name: Download candidate diff (data only; not applied)
|
|
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
|
|
with:
|
|
name: ${{ inputs.diff_artifact_name }}
|
|
path: ./_incoming
|
|
|
|
- name: Verify diff integrity + trust-control denylist + scope
|
|
id: verify
|
|
env:
|
|
EXPECTED_DIFF_HASH: ${{ inputs.expected_diff_hash }}
|
|
DECLARED_SCOPE: ${{ inputs.declared_scope }}
|
|
DIFF_PATH: ./_incoming/candidate.diff
|
|
run: |
|
|
set -euo pipefail
|
|
# Self-contained, stdlib-only, type-hinted gate program embedded
|
|
# inline so this workflow has NO external script dependency. It is
|
|
# patch-independent: it parses the unified diff as TEXT and never
|
|
# executes it. It re-hashes the diff (boundary 3) and enforces the
|
|
# trust-control-surface denylist + declared scope (boundary 2),
|
|
# canonicalizing paths and rejecting renames into denied paths.
|
|
python3 - <<'PY'
|
|
from __future__ import annotations
|
|
|
|
import fnmatch
|
|
import hashlib
|
|
import os
|
|
import posixpath
|
|
import re
|
|
import sys
|
|
|
|
# --- Boundary 2: the trust-control surface. Touching ANY of these is
|
|
# an auto-reject; such a diff is escalated to mandatory human + GPT
|
|
# cross-review, never auto-built (these are the mandatory-cross-review
|
|
# surface regardless). Matched against canonicalized POSIX paths. ---
|
|
DENY_GLOBS: tuple[str, ...] = (
|
|
".github/workflows/**",
|
|
".github/actions/**",
|
|
"**/CODEOWNERS",
|
|
"CODEOWNERS",
|
|
".github/dependabot.yml",
|
|
".github/dependabot.yaml",
|
|
".github/settings.yml",
|
|
# IAM / policy / permission IaC (CDK / SAM / Terraform).
|
|
"**/template.yaml",
|
|
"**/template.yml",
|
|
"**/*.tf",
|
|
"**/cdk.json",
|
|
"**/*-stack.ts",
|
|
"**/*_stack.py",
|
|
"**/policy*.json",
|
|
"**/*iam*",
|
|
"**/*.pem",
|
|
"**/*.key",
|
|
)
|
|
|
|
def canonical(path: str) -> str:
|
|
"""Canonicalize a diff path to a normalized, anchored POSIX path.
|
|
|
|
Strips git's a//b/ prefixes, collapses ``.`` / ``..`` and
|
|
backslashes, and rejects absolute or parent-escaping paths so a
|
|
denied location cannot be reached by traversal/indirection.
|
|
"""
|
|
p = path.strip()
|
|
# git unified-diff prefixes.
|
|
for pre in ("a/", "b/"):
|
|
if p.startswith(pre):
|
|
p = p[len(pre):]
|
|
break
|
|
p = p.replace("\\", "/")
|
|
# normpath then re-POSIX it.
|
|
norm = posixpath.normpath(p)
|
|
if norm.startswith("/") or norm == ".." or norm.startswith("../"):
|
|
raise ValueError(f"path escapes repo root: {path!r}")
|
|
return norm
|
|
|
|
def parse_touched_paths(diff_text: str) -> set[str]:
|
|
"""Extract every path a unified diff adds/modifies/renames/deletes.
|
|
|
|
Reads ``+++ ``/``--- `` targets, ``diff --git a/x b/y`` headers, and
|
|
``rename from/to`` lines — so a rename INTO a denied path (or a new
|
|
file generated into one) is caught, not just in-place edits.
|
|
"""
|
|
touched: set[str] = set()
|
|
for line in diff_text.splitlines():
|
|
m = re.match(r"^diff --git (\S+) (\S+)$", line)
|
|
if m:
|
|
for raw in (m.group(1), m.group(2)):
|
|
touched.add(canonical(raw))
|
|
continue
|
|
m = re.match(r"^(?:\+\+\+|---) (.+)$", line)
|
|
if m:
|
|
tgt = m.group(1).strip()
|
|
if tgt == "/dev/null":
|
|
continue
|
|
# strip trailing tab-timestamp some diffs carry.
|
|
tgt = tgt.split("\t", 1)[0]
|
|
touched.add(canonical(tgt))
|
|
continue
|
|
m = re.match(r"^rename (?:from|to) (.+)$", line)
|
|
if m:
|
|
touched.add(canonical(m.group(1).strip()))
|
|
return touched
|
|
|
|
def denied(path: str) -> bool:
|
|
"""True if ``path`` matches any trust-control denylist glob."""
|
|
return any(fnmatch.fnmatch(path, g) for g in DENY_GLOBS)
|
|
|
|
def in_scope(path: str, scope: list[str]) -> bool:
|
|
"""True if ``path`` is covered by the declared-scope globs."""
|
|
return any(fnmatch.fnmatch(path, canonical(g)) for g in scope)
|
|
|
|
def main() -> int:
|
|
diff_path = os.environ["DIFF_PATH"]
|
|
expected = os.environ["EXPECTED_DIFF_HASH"].strip().lower()
|
|
scope = [s for s in os.environ.get("DECLARED_SCOPE", "").splitlines() if s.strip()]
|
|
|
|
with open(diff_path, "rb") as fh:
|
|
raw = fh.read()
|
|
actual = hashlib.sha256(raw).hexdigest()
|
|
|
|
# Boundary 3: integrity. A tampered/substituted diff fails here.
|
|
if actual != expected:
|
|
print(f"::error::diff hash mismatch: expected={expected} actual={actual}")
|
|
return 2
|
|
|
|
touched = parse_touched_paths(raw.decode("utf-8", errors="replace"))
|
|
if not touched:
|
|
print("::error::no paths parsed from diff; refusing empty/garbled diff")
|
|
return 3
|
|
|
|
# Boundary 2a: trust-control denylist (CI-side HARD FAIL).
|
|
hits = sorted(p for p in touched if denied(p))
|
|
if hits:
|
|
for h in hits:
|
|
print(f"::error::trust-control-surface violation: {h}")
|
|
print("::error::diff touches the trust-control surface; escalate to human + GPT cross-review")
|
|
return 4
|
|
|
|
# Boundary 2b: declared-scope enforcement.
|
|
if not scope:
|
|
print("::error::no declared scope provided; refusing unscoped diff")
|
|
return 5
|
|
out_of_scope = sorted(p for p in touched if not in_scope(p, scope))
|
|
if out_of_scope:
|
|
for p in out_of_scope:
|
|
print(f"::error::out-of-declared-scope path: {p}")
|
|
return 6
|
|
|
|
gh_out = os.environ.get("GITHUB_OUTPUT")
|
|
if gh_out:
|
|
with open(gh_out, "a", encoding="utf-8") as fh:
|
|
fh.write(f"diff_hash={actual}\n")
|
|
print(f"diff_hash={actual}")
|
|
print(f"validated {len(touched)} path(s); all in-scope, none on the trust-control surface")
|
|
return 0
|
|
|
|
sys.exit(main())
|
|
PY
|
|
|
|
# ───────────────────────────────────────────────────────────────────────────
|
|
# JOB 2 — build-test (boundary 1). UNTRUSTED execution. This is the ONLY job
|
|
# that applies + runs the patch. It is credential-less: contents:read only, no
|
|
# secrets, no OIDC, no write token, egress blocked. There is nothing here to
|
|
# steal and nothing to assume. It writes a report artifact consumed by the
|
|
# privileged gate as DATA — that report is NOT authoritative (boundary 4).
|
|
# Depends on `guard` so a denied/tampered diff never reaches execution.
|
|
# ───────────────────────────────────────────────────────────────────────────
|
|
build-test:
|
|
needs: guard
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 20
|
|
permissions:
|
|
contents: read
|
|
steps:
|
|
- name: Harden runner (block egress — untrusted code runs here)
|
|
uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
|
|
with:
|
|
# Block, not audit: this is where untrusted patch code executes. A
|
|
# narrow allowlist for dependency resolution only; everything else is
|
|
# denied so a prompt-injected patch cannot phone home. Tighten/extend
|
|
# per the target repo's package registries at deploy time.
|
|
egress-policy: block
|
|
allowed-endpoints: >
|
|
github.com:443
|
|
api.github.com:443
|
|
objects.githubusercontent.com:443
|
|
codeload.github.com:443
|
|
pypi.org:443
|
|
files.pythonhosted.org:443
|
|
|
|
- name: Checkout base repo (clean ref; patch applied on top after)
|
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
with:
|
|
# Checkout carries NO token into the working tree usable for writes —
|
|
# this job's permissions are contents:read. persist-credentials:false
|
|
# guarantees the patch cannot reuse the checkout token.
|
|
persist-credentials: false
|
|
|
|
- name: Re-download candidate diff (re-validated below)
|
|
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
|
|
with:
|
|
name: ${{ inputs.diff_artifact_name }}
|
|
path: ./_incoming
|
|
|
|
- name: Re-verify diff hash before apply (defense-in-depth)
|
|
env:
|
|
EXPECTED_DIFF_HASH: ${{ needs.guard.outputs.diff_hash }}
|
|
DIFF_PATH: ./_incoming/candidate.diff
|
|
run: |
|
|
set -euo pipefail
|
|
# Independently confirm the bytes match the hash `guard` blessed, so a
|
|
# swapped artifact between jobs cannot slip an unvetted diff into the
|
|
# apply step.
|
|
python3 - <<'PY'
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import os
|
|
import sys
|
|
|
|
def main() -> int:
|
|
expected = os.environ["EXPECTED_DIFF_HASH"].strip().lower()
|
|
with open(os.environ["DIFF_PATH"], "rb") as fh:
|
|
actual = hashlib.sha256(fh.read()).hexdigest()
|
|
if actual != expected:
|
|
print(f"::error::pre-apply hash mismatch: expected={expected} actual={actual}")
|
|
return 1
|
|
print(f"diff hash confirmed: {actual}")
|
|
return 0
|
|
|
|
sys.exit(main())
|
|
PY
|
|
|
|
- name: Apply candidate diff (UNTRUSTED — credential-less sandbox)
|
|
run: |
|
|
set -euo pipefail
|
|
# --check first so a malformed diff fails cleanly; then apply. The
|
|
# working tree has no write credential, so applying + running it can
|
|
# touch only this ephemeral runner.
|
|
git apply --check ./_incoming/candidate.diff
|
|
git apply ./_incoming/candidate.diff
|
|
|
|
- name: Set up Python
|
|
uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
|
with:
|
|
python-version: "3.12"
|
|
|
|
- name: Install + build + test (untrusted; result is non-authoritative)
|
|
id: run
|
|
run: |
|
|
set -euo pipefail
|
|
# Placeholder build/test for the narrowest task class (dep bump /
|
|
# single-file fix). At deploy time this is parameterized per target
|
|
# repo. Exit code is what matters; any file the patch writes is
|
|
# ignored by the authoritative gate (boundary 4).
|
|
if [ -f requirements.txt ]; then
|
|
python3 -m pip install --quiet -r requirements.txt || true
|
|
fi
|
|
python3 -m pip install --quiet ruff pytest || true
|
|
ruff check . || echo "ruff non-zero (recorded, non-authoritative)"
|
|
pytest -q || echo "pytest non-zero (recorded, non-authoritative)"
|
|
|
|
- name: Emit non-authoritative report (job conclusion is the truth)
|
|
if: always()
|
|
run: |
|
|
set -euo pipefail
|
|
# This report is consumed by the gate as DATA for the verifier agent's
|
|
# next-fix reasoning. It is NOT the pass/fail decision — the gate reads
|
|
# the AUTHENTICATED job conclusion (boundary 4), never this file.
|
|
mkdir -p ./_report
|
|
printf '{"task_id":"%s","note":"non-authoritative; gate uses job conclusion"}\n' \
|
|
"${{ inputs.task_id }}" > ./_report/report.json
|
|
|
|
- name: Upload non-authoritative report
|
|
if: always()
|
|
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
|
with:
|
|
name: build-test-report-${{ inputs.task_id }}
|
|
path: ./_report/report.json
|
|
retention-days: 7
|
|
|
|
# ───────────────────────────────────────────────────────────────────────────
|
|
# JOB 3 — gate-and-pr (boundaries 1 + 4 + 5). PRIVILEGED, but it NEVER checks
|
|
# out or executes patch-controlled code. It reads the AUTHENTICATED conclusion
|
|
# of `build-test` (via needs.*.result — GitHub-controlled, patch-independent)
|
|
# keyed to this run, and only on a clean pass opens a DRAFT PR. It never trusts
|
|
# any artifact the patch wrote. Pass/fail is pure code here, not the LLM.
|
|
#
|
|
# NOTE: the OIDC/write grant is declared here as the eventual home of the
|
|
# privileged step, but this file is deploy-gated — the `id-token`/PR-open
|
|
# step is left as a documented placeholder so nothing is provisioned until the
|
|
# §3.3.2 review gates pass. Wiring the real OIDC role is Phase P3 / Phase 5
|
|
# AFTER the mandatory GPT-4.1 cross-review of the IAM.
|
|
# ───────────────────────────────────────────────────────────────────────────
|
|
gate-and-pr:
|
|
needs: [guard, build-test]
|
|
# `always()` so the gate runs even when build-test failed, to record the
|
|
# authoritative conclusion. The gate itself decides pass/fail from results.
|
|
if: always()
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
permissions:
|
|
contents: read
|
|
# pull-requests: write # ← enabled ONLY after the §3.3.2 review gates.
|
|
# id-token: write # ← OIDC for the Option-B apply role, post-gate.
|
|
steps:
|
|
- name: Harden runner (privileged job; block egress)
|
|
uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
|
|
with:
|
|
egress-policy: block
|
|
allowed-endpoints: >
|
|
github.com:443
|
|
api.github.com:443
|
|
|
|
- name: Pure-code pass/fail gate over authenticated results
|
|
env:
|
|
# These come from GitHub's job orchestration, NOT from the patch.
|
|
GUARD_RESULT: ${{ needs.guard.result }}
|
|
BUILD_TEST_RESULT: ${{ needs.build-test.result }}
|
|
DIFF_HASH: ${{ needs.guard.outputs.diff_hash }}
|
|
EXPECTED_DIFF_HASH: ${{ inputs.expected_diff_hash }}
|
|
RUN_ID: ${{ github.run_id }}
|
|
run: |
|
|
set -euo pipefail
|
|
# Deterministic, patch-independent decision. Consumes ONLY the
|
|
# authenticated needs.*.result values + the hash binding (all from
|
|
# GitHub's orchestration, never from a file the patch wrote). Mirrors
|
|
# secrev's "one pure-code script owns the block decision." The
|
|
# verifier AGENT only reads failures to propose a fix; it cannot
|
|
# declare success here.
|
|
python3 - <<'PY'
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
|
|
def gate(
|
|
*,
|
|
guard_result: str,
|
|
build_test_result: str,
|
|
diff_hash: str,
|
|
expected_hash: str,
|
|
run_id: str,
|
|
) -> tuple[bool, str]:
|
|
"""Return (passed, reason) from authenticated, patch-independent inputs.
|
|
|
|
A pass requires: the guard job succeeded (integrity + denylist +
|
|
scope all held), the build-test job succeeded, and the hash the
|
|
guard exported equals the ledger-recorded expected hash bound to
|
|
this run. Anything else blocks.
|
|
"""
|
|
if not run_id:
|
|
return False, "missing run id; cannot bind decision to a run"
|
|
if diff_hash.strip().lower() != expected_hash.strip().lower():
|
|
return False, f"hash binding broken: guard={diff_hash} expected={expected_hash}"
|
|
if guard_result != "success":
|
|
return False, f"guard did not pass: {guard_result!r}"
|
|
if build_test_result != "success":
|
|
return False, f"build-test did not pass: {build_test_result!r}"
|
|
return True, "authenticated build/test passed and diff hash is bound"
|
|
|
|
def main() -> int:
|
|
passed, reason = gate(
|
|
guard_result=os.environ.get("GUARD_RESULT", ""),
|
|
build_test_result=os.environ.get("BUILD_TEST_RESULT", ""),
|
|
diff_hash=os.environ.get("DIFF_HASH", ""),
|
|
expected_hash=os.environ.get("EXPECTED_DIFF_HASH", ""),
|
|
run_id=os.environ.get("RUN_ID", ""),
|
|
)
|
|
if passed:
|
|
print(f"GATE PASS: {reason}")
|
|
if (gh_out := os.environ.get("GITHUB_OUTPUT")):
|
|
with open(gh_out, "a", encoding="utf-8") as fh:
|
|
fh.write("gate=pass\n")
|
|
return 0
|
|
print(f"::error::GATE BLOCK: {reason}")
|
|
return 1
|
|
|
|
sys.exit(main())
|
|
PY
|
|
|
|
- name: Open DRAFT PR (DEPLOY-GATED PLACEHOLDER — not enabled)
|
|
if: ${{ false }} # ← hard-disabled. Enable only after §3.3.2 review gates.
|
|
run: |
|
|
echo "Draft-PR open runs here AFTER the mandatory GPT-4.1 cross-review"
|
|
echo "+ /sh-security-review of this workflow and its OIDC role."
|
|
echo "Draft PR only; never auto-merge (D2). Branch protection is the"
|
|
echo "final enforcement (boundary 5)."
|