Skip to content

Commit 8d0d3da

Browse files
committed
fix: make release A/B gate lifecycle-aware
1 parent 07c9caa commit 8d0d3da

9 files changed

Lines changed: 592 additions & 28 deletions

.github/workflows/pytest.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ jobs:
8383
runs-on: ubuntu-latest
8484
steps:
8585
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
86+
with:
87+
fetch-depth: 0
8688
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
8789
with:
8890
python-version: '3.13'
@@ -94,7 +96,7 @@ jobs:
9496
run: PYTHON=python bash scripts/build_release_artifacts.sh --outdir dist
9597
- name: Prove deterministic source artifacts
9698
run: PYTHON=python bash scripts/reproducible_build_gate.sh
97-
- name: Exercise synthetic record-only A/B gate
99+
- name: Exercise lifecycle-aware record-only A/B gate
98100
run: PYTHON=python bash scripts/release_ab_repro_gate.sh
99101
- name: Exercise wheel and sdist package smoke
100102
run: PYTHON=python bash scripts/package_smoke.sh

PUBLISHING.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -176,9 +176,17 @@ fields.
176176
The publish workflow rebuilt A and B, required artifact equality before OIDC,
177177
and never creates or fills authentic records itself. The seeded form is not
178178
approval, identity proof or publication authority and contains no confidential
179-
report content. `scripts/release_ab_repro_gate.sh` modifies the seeded form and
180-
adds only a temporary synthetic window to prove the mechanism; neither is rc2
181-
evidence.
179+
report content. On pending source A, `scripts/release_ab_repro_gate.sh` modifies
180+
the seeded form and adds only a temporary synthetic window to prove the
181+
mechanism; neither is rc2 evidence. Once authentic record child B exists, the
182+
same gate requires full history, resolves exactly one valid record child in the
183+
checked commit's ancestry, validates the current window/review binding, rebuilds
184+
source A and B, and requires byte-identical artifacts. Post-release descendants
185+
therefore retain the authentic A/B proof without being misclassified as source
186+
A. For a prepared record-only PR aggregate before B exists, the gate constructs
187+
a disposable exact-squash candidate with A as its sole parent, requires exactly
188+
the two record changes, and applies the same authentic binding and artifact
189+
checks; this fallback is forbidden after the window becomes active.
182190

183191
## Tag and publish
184192

docs/VALIDATION.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ Separate jobs:
1818
- clean and build wheel/sdist;
1919
- use the canonical deterministic release helper, including `twine check` and
2020
exact distribution metadata validation;
21-
- run reproducibility and synthetic record-only A/B gates;
21+
- run reproducibility and lifecycle-aware record-only A/B gates: synthetic for
22+
pending source A, authentic for record child B and its descendants;
2223
- install wheel and sdist in explicit disposable `/tmp` smoke environments.
2324

2425
The scheduled security workflow runs dependency audit and CodeQL. All GitHub

scripts/release_ab_repro_gate.sh

Lines changed: 54 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,30 @@ set -euo pipefail
44
cd "$(dirname "$0")/.."
55
repo_root="$(pwd)"
66
python_bin="${PYTHON:-python3}"
7+
if [[ "$python_bin" == */* && "$python_bin" != /* ]]; then
8+
python_bin="$repo_root/$python_bin"
9+
fi
710
work="$(mktemp -d)"
811
cleanup() { rm -rf "$work"; }
912
trap cleanup EXIT
1013
export SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-1704067200}"
1114

1215
git clone --quiet --no-local "$repo_root" "$work/repo"
1316
cd "$work/repo"
14-
source_commit="$(git rev-parse HEAD)"
15-
PYTHON="$python_bin" bash scripts/build_release_artifacts.sh --outdir "$work/reviewed"
16-
mkdir -p docs/security-review docs/rc-window
17-
wheel_sha="$(sha256sum "$work"/reviewed/*.whl | cut -d' ' -f1)"
18-
sdist_sha="$(sha256sum "$work"/reviewed/*.tar.gz | cut -d' ' -f1)"
19-
pyproject_sha="$(sha256sum pyproject.toml | cut -d' ' -f1)"
20-
compat_sha="$(sha256sum govengine/v1_compatibility_manifest.json | cut -d' ' -f1)"
21-
corpus_sha="$(sha256sum govengine/conformance/v1/manifest.json | cut -d' ' -f1)"
22-
reason_registry_sha="$(sha256sum govengine/policy/reasons.py | cut -d' ' -f1)"
23-
"$python_bin" - "$source_commit" "$wheel_sha" "$sdist_sha" "$pyproject_sha" "$compat_sha" "$corpus_sha" "$reason_registry_sha" <<'PY'
17+
IFS=$'\t' read -r gate_mode source_commit record_commit < <(
18+
"$python_bin" scripts/validate_release_record_commit.py --resolve-ab-state
19+
)
20+
21+
if [ "$gate_mode" = "synthetic" ]; then
22+
PYTHON="$python_bin" bash scripts/build_release_artifacts.sh --outdir "$work/reviewed"
23+
mkdir -p docs/security-review docs/rc-window
24+
wheel_sha="$(sha256sum "$work"/reviewed/*.whl | cut -d' ' -f1)"
25+
sdist_sha="$(sha256sum "$work"/reviewed/*.tar.gz | cut -d' ' -f1)"
26+
pyproject_sha="$(sha256sum pyproject.toml | cut -d' ' -f1)"
27+
compat_sha="$(sha256sum govengine/v1_compatibility_manifest.json | cut -d' ' -f1)"
28+
corpus_sha="$(sha256sum govengine/conformance/v1/manifest.json | cut -d' ' -f1)"
29+
reason_registry_sha="$(sha256sum govengine/policy/reasons.py | cut -d' ' -f1)"
30+
"$python_bin" - "$source_commit" "$wheel_sha" "$sdist_sha" "$pyproject_sha" "$compat_sha" "$corpus_sha" "$reason_registry_sha" <<'PY'
2431
import hashlib, json, sys
2532
from pathlib import Path
2633
source, wheel, sdist, pyproject, compatibility, corpus, reason_registry = sys.argv[1:]
@@ -36,11 +43,41 @@ review_path.write_text(json.dumps(review, sort_keys=True) + "\n", encoding="utf-
3643
window = {"schema_version": "govengine.rc_window.v2", "status": "prepared", "version": "1.0.0rc2", "source_commit": source, "prepared_at": "2026-01-01T00:00:00Z", "published_at": None, "observation_ends_at": None, "completed_at": None, "minimum_observation_days": 7, "public_evidence_ref": "", "frozen_inputs": {"pyproject_sha256": pyproject, "v1_compatibility_manifest_sha256": compatibility, "v1_conformance_manifest_sha256": corpus, "policy_reason_registry_sha256": reason_registry}, "security_review": {"path": str(review_path), "sha256": hashlib.sha256(review_path.read_bytes()).hexdigest()}, "facade_exports": 40, "v1_records": 15, "rule": "schema_facade_corpus_or_reason_registry_change_requires_new_rc", "notes": "Synthetic record-only gate."}
3744
window_path.write_text(json.dumps(window, sort_keys=True) + "\n", encoding="utf-8")
3845
PY
39-
git config user.name "GovEngine release gate"
40-
git config user.email "release-gate@example.invalid"
41-
git add docs/security-review/rc2-external-review.json docs/rc-window/1.0.0rc2.json
42-
git commit --quiet -m "Synthetic record-only A/B gate child"
43-
test "$("$python_bin" scripts/validate_release_record_commit.py --review-commit HEAD)" = "$source_commit"
44-
"$python_bin" scripts/validate_rc2_release_records.py --allow-synthetic --review docs/security-review/rc2-external-review.json --window docs/rc-window/1.0.0rc2.json --source-commit "$source_commit" --wheel "$work"/reviewed/*.whl --sdist "$work"/reviewed/*.tar.gz
45-
PYTHON="$python_bin" bash scripts/build_release_artifacts.sh --outdir "$work/published"
46+
git config user.name "GovEngine release gate"
47+
git config user.email "release-gate@example.invalid"
48+
git add docs/security-review/rc2-external-review.json docs/rc-window/1.0.0rc2.json
49+
git commit --quiet -m "Synthetic record-only A/B gate child"
50+
test "$("$python_bin" scripts/validate_release_record_commit.py --review-commit HEAD)" = "$source_commit"
51+
"$python_bin" scripts/validate_rc2_release_records.py \
52+
--allow-synthetic \
53+
--review docs/security-review/rc2-external-review.json \
54+
--window docs/rc-window/1.0.0rc2.json \
55+
--source-commit "$source_commit" \
56+
--wheel "$work"/reviewed/*.whl \
57+
--sdist "$work"/reviewed/*.tar.gz
58+
PYTHON="$python_bin" bash scripts/build_release_artifacts.sh --outdir "$work/published"
59+
elif [ "$gate_mode" = "authentic" ] && [ "$record_commit" != "-" ]; then
60+
"$python_bin" scripts/validate_rc_window.py \
61+
--record docs/rc-window/1.0.0rc2.json \
62+
--expected-version 1.0.0rc2
63+
mkdir -p "$work/records"
64+
git show "$record_commit:docs/security-review/rc2-external-review.json" \
65+
> "$work/records/review.json"
66+
git show "$record_commit:docs/rc-window/1.0.0rc2.json" \
67+
> "$work/records/window.json"
68+
git checkout --quiet --detach "$source_commit"
69+
PYTHON="$python_bin" bash scripts/build_release_artifacts.sh --outdir "$work/reviewed"
70+
"$python_bin" scripts/validate_rc2_release_records.py \
71+
--review "$work/records/review.json" \
72+
--window "$work/records/window.json" \
73+
--source-commit "$source_commit" \
74+
--wheel "$work"/reviewed/*.whl \
75+
--sdist "$work"/reviewed/*.tar.gz
76+
git checkout --quiet --detach "$record_commit"
77+
PYTHON="$python_bin" bash scripts/build_release_artifacts.sh --outdir "$work/published"
78+
else
79+
echo "unsupported release A/B gate state: $gate_mode" >&2
80+
exit 1
81+
fi
82+
4683
"$python_bin" scripts/compare_release_builds.py --reviewed "$work/reviewed" --published "$work/published"

scripts/validate_public_truth.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -717,6 +717,12 @@ def main() -> int:
717717
_assert_contains('.github/workflows/pytest.yml', workflow, 'scripts/build_release_artifacts.sh --outdir dist')
718718
_assert_contains('.github/workflows/pytest.yml', workflow, 'scripts/reproducible_build_gate.sh')
719719
_assert_contains('.github/workflows/pytest.yml', workflow, 'scripts/release_ab_repro_gate.sh')
720+
_assert_contains(
721+
'.github/workflows/pytest.yml',
722+
workflow,
723+
'Exercise lifecycle-aware record-only A/B gate',
724+
)
725+
_assert_contains('.github/workflows/pytest.yml', workflow, 'fetch-depth: 0')
720726
_assert_contains('.github/workflows/pytest.yml', workflow, 'scripts/package_smoke.sh')
721727
_assert_contains('.github/workflows/pytest.yml', workflow, 'govengine-hosted-runner-review-artifacts')
722728

scripts/validate_release_record_commit.py

Lines changed: 193 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,66 @@
11
from __future__ import annotations
22

33
import argparse
4+
import hashlib
5+
import json
6+
import re
47
import subprocess
8+
from dataclasses import dataclass
59
from pathlib import Path
10+
from typing import Any
611

712

13+
REVIEW_PATH = Path("docs/security-review/rc2-external-review.json")
14+
WINDOW_PATH = Path("docs/rc-window/1.0.0rc2.json")
815
EXPECTED_RECORD_CHANGES = (
9-
("A", "docs/rc-window/1.0.0rc2.json"),
10-
("M", "docs/security-review/rc2-external-review.json"),
16+
("A", str(WINDOW_PATH)),
17+
("M", str(REVIEW_PATH)),
1118
)
19+
FULL_SHA = re.compile(r"^[0-9a-f]{40}$")
20+
PENDING_REVIEW: dict[str, Any] = {
21+
"schema_version": "govengine.rc2_external_security_review.v1",
22+
"source_commit": "",
23+
"artifacts": {
24+
"runner": "github-hosted-runner",
25+
"wheel_sha256": "",
26+
"normalized_sdist_sha256": "",
27+
},
28+
"confidential_report_sha256": "",
29+
"reviewer": "",
30+
"reviewed_at": None,
31+
"verdict": "pending_external_reviewer",
32+
"open_p0": None,
33+
"open_p1": None,
34+
}
35+
36+
37+
@dataclass(frozen=True)
38+
class ReleaseABState:
39+
mode: str
40+
source_commit: str
41+
record_commit: str | None
1242

1343

1444
def _git(repo: Path, *args: str) -> str:
1545
return subprocess.check_output(["git", *args], cwd=repo, text=True).strip()
1646

1747

48+
def _git_bytes(repo: Path, *args: str) -> bytes:
49+
return subprocess.check_output(["git", *args], cwd=repo)
50+
51+
52+
def _load_json(text: str) -> Any:
53+
def reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
54+
value: dict[str, Any] = {}
55+
for key, item in pairs:
56+
if key in value:
57+
raise ValueError(f"duplicate JSON key:{key}")
58+
value[key] = item
59+
return value
60+
61+
return json.loads(text, object_pairs_hook=reject_duplicate_keys)
62+
63+
1864
def validate_record_commit(repo: Path, review_commit: str) -> str:
1965
parents = _git(repo, "rev-list", "--parents", "-n", "1", review_commit).split()
2066
if len(parents) != 2:
@@ -29,14 +75,157 @@ def validate_record_commit(repo: Path, review_commit: str) -> str:
2975
return source_commit
3076

3177

78+
def _matches_authentic_record(
79+
repo: Path,
80+
commit: str,
81+
source_commit: str,
82+
current_review: bytes,
83+
) -> bool:
84+
try:
85+
if validate_record_commit(repo, commit) != source_commit:
86+
return False
87+
record_review = _git_bytes(repo, "show", f"{commit}:{REVIEW_PATH}")
88+
record_window = _load_json(
89+
_git_bytes(repo, "show", f"{commit}:{WINDOW_PATH}").decode("utf-8")
90+
)
91+
except (ValueError, subprocess.CalledProcessError, json.JSONDecodeError):
92+
return False
93+
record_reference = (
94+
record_window.get("security_review")
95+
if isinstance(record_window, dict)
96+
else None
97+
)
98+
return (
99+
record_review == current_review
100+
and isinstance(record_reference, dict)
101+
and record_window.get("source_commit") == source_commit
102+
and record_reference.get("path") == str(REVIEW_PATH)
103+
and record_reference.get("sha256")
104+
== hashlib.sha256(record_review).hexdigest()
105+
)
106+
107+
108+
def _squash_candidate(repo: Path, head_commit: str, source_commit: str) -> str:
109+
tree = _git(repo, "rev-parse", f"{head_commit}^{{tree}}")
110+
return subprocess.check_output(
111+
[
112+
"git",
113+
"-c",
114+
"user.name=GovEngine release gate",
115+
"-c",
116+
"user.email=release-gate@example.invalid",
117+
"commit-tree",
118+
tree,
119+
"-p",
120+
source_commit,
121+
],
122+
cwd=repo,
123+
input="Synthetic exact-squash record candidate\n",
124+
text=True,
125+
).strip()
126+
127+
128+
def resolve_release_ab_state(repo: Path) -> ReleaseABState:
129+
head_commit = _git(repo, "rev-parse", "--verify", "HEAD^{commit}")
130+
review_path = repo / REVIEW_PATH
131+
window_path = repo / WINDOW_PATH
132+
review = _load_json(review_path.read_text(encoding="utf-8"))
133+
if not isinstance(review, dict):
134+
raise ValueError("rc2 review record must be a JSON object")
135+
136+
if review == PENDING_REVIEW:
137+
if window_path.exists():
138+
raise ValueError("pending rc2 source must not contain an rc2 window")
139+
return ReleaseABState("synthetic", head_commit, None)
140+
141+
if not window_path.exists():
142+
raise ValueError("approved rc2 review requires an rc2 window")
143+
window = _load_json(window_path.read_text(encoding="utf-8"))
144+
if not isinstance(window, dict):
145+
raise ValueError("rc2 window must be a JSON object")
146+
source_commit = review.get("source_commit")
147+
if (
148+
review.get("verdict") != "approved"
149+
or not isinstance(source_commit, str)
150+
or not FULL_SHA.fullmatch(source_commit)
151+
or window.get("schema_version") != "govengine.rc_window.v2"
152+
or window.get("version") != "1.0.0rc2"
153+
or window.get("source_commit") != source_commit
154+
):
155+
raise ValueError("rc2 review and window identity are inconsistent")
156+
reference = window.get("security_review")
157+
current_review = review_path.read_bytes()
158+
if (
159+
not isinstance(reference, dict)
160+
or reference.get("path") != str(REVIEW_PATH)
161+
or reference.get("sha256") != hashlib.sha256(current_review).hexdigest()
162+
):
163+
raise ValueError("rc2 window does not bind the current review record")
164+
if subprocess.run(
165+
["git", "merge-base", "--is-ancestor", source_commit, head_commit],
166+
cwd=repo,
167+
check=False,
168+
stdout=subprocess.PIPE,
169+
stderr=subprocess.PIPE,
170+
).returncode != 0:
171+
raise ValueError("rc2 source is not an ancestor of the checked commit")
172+
173+
candidates: list[str] = []
174+
commits = _git(
175+
repo,
176+
"rev-list",
177+
"--reverse",
178+
"--ancestry-path",
179+
f"{source_commit}..{head_commit}",
180+
).splitlines()
181+
for commit in commits:
182+
parents = _git(repo, "rev-list", "--parents", "-n", "1", commit).split()[1:]
183+
if parents != [source_commit]:
184+
continue
185+
if _matches_authentic_record(repo, commit, source_commit, current_review):
186+
candidates.append(commit)
187+
188+
expected_changes = [
189+
f"{status}\t{path}" for status, path in EXPECTED_RECORD_CHANGES
190+
]
191+
aggregate_changes = _git(
192+
repo, "diff", "--name-status", source_commit, head_commit
193+
).splitlines()
194+
if (
195+
not candidates
196+
and window.get("status") == "prepared"
197+
and aggregate_changes == expected_changes
198+
):
199+
candidate = _squash_candidate(repo, head_commit, source_commit)
200+
if _matches_authentic_record(repo, candidate, source_commit, current_review):
201+
candidates.append(candidate)
202+
if len(candidates) != 1:
203+
raise ValueError("exactly one authentic rc2 record child must resolve")
204+
return ReleaseABState("authentic", source_commit, candidates[0])
205+
206+
32207
def main() -> int:
33208
parser = argparse.ArgumentParser()
34209
parser.add_argument("--repo", type=Path, default=Path("."))
35210
parser.add_argument("--review-commit", default="HEAD")
211+
parser.add_argument("--resolve-ab-state", action="store_true")
36212
args = parser.parse_args()
37213
try:
38-
print(validate_record_commit(args.repo, args.review_commit))
39-
except (ValueError, subprocess.CalledProcessError) as error:
214+
if args.resolve_ab_state:
215+
state = resolve_release_ab_state(args.repo)
216+
print(
217+
"\t".join(
218+
(state.mode, state.source_commit, state.record_commit or "-")
219+
)
220+
)
221+
else:
222+
print(validate_record_commit(args.repo, args.review_commit))
223+
except (
224+
OSError,
225+
ValueError,
226+
json.JSONDecodeError,
227+
subprocess.CalledProcessError,
228+
) as error:
40229
print(f"release_record_commit_invalid:{error}")
41230
return 1
42231
return 0

0 commit comments

Comments
 (0)