import json
import re
import subprocess
import sys
from pathlib import Path
workflow = Path(".github/workflows/release.yml").read_text()
build_start = workflow.index(" build:\n")
checksums_start = workflow.index(" checksums:\n")
release_start = workflow.index(" release:\n")
build = workflow[build_start:checksums_start]
checksums = workflow[checksums_start:release_start]
release_please_start = workflow.index(" release-please:\n")
release = workflow[release_start:release_please_start]
release_please = json.loads(Path("release-please-config.json").read_text())
required_build_fragments = (
" permissions:",
" contents: write",
"- name: Generate early Linux deploy checksum",
"if: matrix.target == 'x86_64-unknown-linux-gnu'",
"- name: Publish Linux deploy archive immediately",
"bobbin-${{ env.VERSION }}-${{ matrix.target }}.${{ matrix.archive }}",
"- name: Build repository index pack",
'PACK="bobbin-${{ env.VERSION }}-repository.bbpack"',
'"$BOBBIN" pack verify "$PACK" --path "$PACK_HOME/home"',
'sha256sum "$PACK" > "$PACK.sha256"',
"bobbin-${{ env.VERSION }}-repository.bbpack.sha256",
"SHA256SUMS.txt",
"- name: Publish target archive immediately",
"if: matrix.target != 'x86_64-unknown-linux-gnu'",
)
for fragment in required_build_fragments:
assert fragment in build, f"release build contract missing: {fragment}"
assert release_please["draft"] is True, "Release Please must not expose an assetless release"
assert release_please["force-tag-creation"] is True, "draft release must still create the build trigger tag"
def with_block_of(chunk):
lines = chunk[chunk.index(" with:\n"):].split("\n")
block = [lines[0]]
for line in lines[1:]:
if line.startswith(" "):
block.append(line)
else:
break
return "\n".join(block)
def upload_steps(job):
steps = []
for chunk in re.split(r"^ - name: ", job, flags=re.M)[1:]:
if " uses: softprops/action-gh-release@" not in chunk:
continue
name = chunk.splitlines()[0].strip()
step_id = re.search(r"^ id: (\S+)$", chunk, re.M)
with_block = with_block_of(chunk)
steps.append((name, step_id.group(1) if step_id else None, with_block))
return steps
build_uploads = upload_steps(build)
release_uploads = upload_steps(release)
assert len(build_uploads) >= 2 and len(release_uploads) >= 1, "upload steps missing"
for name, _, with_block in build_uploads + release_uploads:
assert "\n draft: true" in with_block, f"{name}: upload must preserve draft status"
for uploads in (build_uploads, release_uploads):
firsts = {sid: w for _, sid, w in uploads if sid}
assert firsts, "each upload's first attempt must carry an id for its retry"
for sid, with_block in firsts.items():
gate = f"if: steps.{sid}.outcome == 'failure'"
retry_chunks = [
c for c in re.split(r"^ - name: ", build + release, flags=re.M)
if c.startswith("Retry ") and gate in c
]
assert len(retry_chunks) == 1, f"{sid}: expected exactly one retry step"
retry_with = with_block_of(retry_chunks[0])
assert retry_with == with_block, f"{sid}: retry must repeat the identical upload"
assert "continue-on-error" not in retry_chunks[0], (
f"{sid}: the retry must NOT be continue-on-error, or a sustained failure passes"
)
assert "- name: Publish complete GitHub Release" in release
assert "--draft=false" in release, "the publish step must clear the draft"
assert release.index("Finalize GitHub Release assets and checksums") < release.index(
"Publish complete GitHub Release"
), "the release must become public only after final asset upload"
assert build.index("Generate early Linux deploy checksum") < build.index(
"Publish Linux deploy archive immediately"
), "checksum must exist before the early Linux upload"
assert build.index("Build repository index pack") < build.index(
"Publish Linux deploy archive immediately"
), "verified repository pack must exist before the early Linux upload"
assert "needs: build" in checksums, "full checksums must still wait for every target"
release_needs = re.search(r"^ needs:\s*(.+)$", release, re.M)
assert release_needs, "finalizer must declare `needs`"
needed = set(re.findall(r"[A-Za-z][\w-]*", release_needs.group(1)))
assert {"build", "checksums"} <= needed, (
f"finalizer must wait for build and checksums; needs = {sorted(needed)}"
)
release_if = re.search(r"^ if: (.+?)^ [a-z]", release, re.M | re.S)
release_if = release_if.group(1) if release_if else ""
if "always()" in release_if:
for job in ("build", "checksums"):
assert f"needs.{job}.result == 'success'" in release_if, (
f"finalizer uses always(), so it must require needs.{job}.result == 'success' "
"explicitly -- always() means a failed or skipped dependency no longer blocks it"
)
assert "Finalize GitHub Release assets and checksums" in release
assert "find . -maxdepth 1 -type f ! -name SHA256SUMS.txt" in release
assert 'name "*.bbpack"' in checksums
assert 'name "*.bbpack"' in release
print("release workflow contract: ok")
subprocess.run([sys.executable, "scripts/test-release-provenance.py"], check=True)
assert "--latest=false" in release, (
"the publish step must be able to publish WITHOUT claiming latest; a bare "
"--latest lets an older release demote a newer one (aegis-egqrv4)"
)
assert "sort -V" in release, (
"the latest decision must compare versions with `sort -V`; a lexical sort "
"ranks 0.9.0 above 0.10.0 and inverts the guard in both directions"
)
assert "--exclude-drafts" in release and "--exclude-pre-releases" in release, (
"the highest published version must ignore drafts and prereleases — a draft "
"is the NORMAL state for most of a release's ~80-minute build"
)
assert "it is a PRERELEASE" in release, (
"the publish step must refuse --latest for a prerelease; sort -V ranks a "
"prerelease above its own final release (aegis-egqrv4)"
)
assert release.count("--latest") >= 2, (
"expected both the --latest and --latest=false branches of the decision"
)
gate = Path("scripts/test-release-latest-gate.sh")
if gate.exists():
result = subprocess.run(["bash", str(gate)], capture_output=True, text=True)
if result.returncode != 0:
sys.stdout.write(result.stdout)
sys.stderr.write(result.stderr)
raise SystemExit("release latest-gate logic test failed")
else:
raise SystemExit(f"missing {gate}: the latest-gate logic is unverified")