Merge pull request #2604 from gh-xj/feat/release-safety-gates

ci(release): add automated release safety gates
This commit is contained in:
xj 2026-03-02 21:10:32 -08:00 committed by GitHub
commit 0a0433bae6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 257 additions and 0 deletions

View File

@ -0,0 +1,88 @@
---
name: Post-Release Validation
on:
release:
types: ["published"]
permissions:
contents: read
jobs:
validate:
name: Validate Published Release
runs-on: ubuntu-22.04
timeout-minutes: 15
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Download and verify release assets
shell: bash
env:
RELEASE_TAG: ${{ github.event.release.tag_name }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
echo "Validating release: ${RELEASE_TAG}"
# 1. Check release exists and is not draft
release_json="$(gh api \
"repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}")"
is_draft="$(echo "$release_json" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['draft'])")"
if [ "$is_draft" = "True" ]; then
echo "::warning::Release ${RELEASE_TAG} is still in draft."
fi
# 2. Check expected assets against artifact contract
asset_count="$(echo "$release_json" \
| python3 -c "import sys,json; print(len(json.load(sys.stdin)['assets']))")"
contract=".github/release/release-artifact-contract.json"
expected_count="$(python3 -c "
import json
c = json.load(open('$contract'))
total = sum(len(c[k]) for k in c if k != 'schema_version')
print(total)
")"
echo "Release has ${asset_count} assets (contract expects ${expected_count})"
if [ "$asset_count" -lt "$expected_count" ]; then
echo "::error::Expected >=${expected_count} release assets (from ${contract}), found ${asset_count}"
exit 1
fi
# 3. Download checksum file and one archive
gh release download "${RELEASE_TAG}" \
--pattern "SHA256SUMS" \
--dir /tmp/release-check
gh release download "${RELEASE_TAG}" \
--pattern "zeroclaw-x86_64-unknown-linux-gnu.tar.gz" \
--dir /tmp/release-check
# 4. Verify checksum
cd /tmp/release-check
if sha256sum --check --ignore-missing SHA256SUMS; then
echo "SHA256 checksum verification: passed"
else
echo "::error::SHA256 checksum verification failed"
exit 1
fi
# 5. Extract binary
tar xzf zeroclaw-x86_64-unknown-linux-gnu.tar.gz
- name: Smoke-test release binary
shell: bash
env:
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
set -euo pipefail
cd /tmp/release-check
if ./zeroclaw --version | grep -Fq "${RELEASE_TAG#v}"; then
echo "Binary version check: passed (${RELEASE_TAG})"
else
actual="$(./zeroclaw --version)"
echo "::error::Binary --version mismatch: ${actual}"
exit 1
fi
echo "Post-release validation: all checks passed"

View File

@ -79,6 +79,18 @@ def build_markdown(report: dict) -> str:
lines.append(f"- Tag version: `{metadata.get('tag_version')}`")
lines.append("")
ci_gate = report.get("ci_gate", {})
if ci_gate.get("ci_status"):
lines.append("## CI Gate")
lines.append(f"- CI status: `{ci_gate['ci_status']}`")
lines.append("")
dry_run_gate = report.get("dry_run_gate", {})
if dry_run_gate.get("prior_successful_runs") is not None:
lines.append("## Dry Run Gate")
lines.append(f"- Prior successful runs: `{dry_run_gate['prior_successful_runs']}`")
lines.append("")
if report["violations"]:
lines.append("## Violations")
for item in report["violations"]:
@ -139,6 +151,8 @@ def main() -> int:
tagger_date: str | None = None
cargo_version: str | None = None
tag_version: str | None = None
ci_status: str | None = None
dry_run_count: int | None = None
if publish_release:
stable_match = STABLE_TAG_RE.fullmatch(args.release_tag)
@ -293,6 +307,105 @@ def main() -> int:
except RuntimeError as exc:
warnings.append(f"Failed to inspect tagger metadata for `{args.release_tag}`: {exc}")
# --- CI green gate (blocking) ---
if tag_commit:
ci_check_proc = None
try:
ci_check_proc = subprocess.run(
[
"gh", "api",
f"repos/{args.repository}/commits/{tag_commit}/check-runs",
"--jq",
'[.check_runs[] | select(.name == "CI Required Gate")] | '
'if length == 0 then "not_found" '
'elif .[0].conclusion == "success" then "success" '
'elif .[0].status != "completed" then "pending" '
'else .[0].conclusion end',
],
text=True,
capture_output=True,
check=False,
)
ci_status = ci_check_proc.stdout.strip() if ci_check_proc.returncode == 0 else "api_error"
except FileNotFoundError:
ci_status = "gh_not_found"
warnings.append(
"gh CLI not found; CI status check skipped. "
"Install gh to enable CI gate enforcement."
)
if ci_status == "success":
pass # CI passed on the tagged commit
elif ci_status == "not_found":
violations.append(
f"CI Required Gate check-run not found for commit {tag_commit}. "
"Ensure ci-run.yml has completed on main before tagging."
)
elif ci_status == "pending":
violations.append(
f"CI is still running on commit {tag_commit}. "
"Wait for CI Required Gate to complete before publishing."
)
elif ci_status == "api_error":
ci_err = ci_check_proc.stderr.strip() if ci_check_proc else ""
msg = f"Could not query CI status for commit {tag_commit}: {ci_err}"
if "No commit found" in ci_err or "HTTP 422" in ci_err:
# Commit SHA not recognized by GitHub (e.g. test environment
# with local-only commits). Downgrade to warning.
warnings.append(f"{msg}. Commit not found on remote; CI gate skipped.")
elif publish_release:
violations.append(f"{msg}. Failing closed because CI gate could not be verified.")
else:
warnings.append(msg)
elif ci_status == "gh_not_found":
if publish_release:
violations.append(
"gh CLI not found; cannot enforce CI Required Gate in publish mode."
)
# verify mode: already handled as warning in except block
else:
violations.append(
f"CI Required Gate conclusion is '{ci_status}' (expected 'success') "
f"for commit {tag_commit}."
)
# --- Dry run verification gate (advisory) ---
if tag_commit:
try:
dry_run_proc = subprocess.run(
[
"gh", "api",
f"repos/{args.repository}/actions/workflows/pub-release.yml/runs"
f"?head_sha={tag_commit}&status=completed&conclusion=success&per_page=1",
"--jq",
".total_count",
],
text=True,
capture_output=True,
check=False,
)
dry_run_count_str = dry_run_proc.stdout.strip() if dry_run_proc.returncode == 0 else ""
except FileNotFoundError:
dry_run_count_str = ""
warnings.append(
"gh CLI not found; dry-run history check skipped."
)
try:
dry_run_count = int(dry_run_count_str)
except ValueError:
dry_run_count = -1
if dry_run_count == -1:
warnings.append(
f"Could not query dry-run history for commit {tag_commit}. "
"Manual verification recommended."
)
elif dry_run_count == 0:
warnings.append(
f"No prior successful pub-release.yml run found for commit {tag_commit}. "
"Consider running a verification build (publish_release=false) first."
)
if authorized_tagger_emails:
normalized_tagger = normalize_email(tagger_email or "")
if not normalized_tagger:
@ -347,6 +460,13 @@ def main() -> int:
"tag_version": tag_version,
"cargo_version": cargo_version,
},
"ci_gate": {
"tag_commit": tag_commit,
"ci_status": ci_status if publish_release and tag_commit else None,
},
"dry_run_gate": {
"prior_successful_runs": dry_run_count if publish_release and tag_commit else None,
},
"trigger_provenance": {
"repository": args.repository,
"origin_url": args.origin_url.strip() or f"https://github.com/{args.repository}.git",

View File

@ -60,6 +60,55 @@ if [[ "$HEAD_SHA" != "$MAIN_SHA" ]]; then
exit 1
fi
# --- CI green gate (blocks on pending/failure, warns on unavailable) ---
echo "Checking CI status on HEAD ($HEAD_SHA)..."
if command -v gh >/dev/null 2>&1; then
CI_STATUS="$(gh api "repos/$(gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null || echo 'zeroclaw-labs/zeroclaw')/commits/${HEAD_SHA}/check-runs" \
--jq '[.check_runs[] | select(.name == "CI Required Gate")] |
if length == 0 then "not_found"
elif .[0].conclusion == "success" then "success"
elif .[0].status != "completed" then "pending"
else .[0].conclusion end' 2>/dev/null || echo "api_error")"
case "$CI_STATUS" in
success)
echo "CI Required Gate: passed"
;;
pending)
echo "error: CI is still running on $HEAD_SHA. Wait for CI Required Gate to complete." >&2
exit 1
;;
not_found)
echo "warning: CI Required Gate check-run not found for $HEAD_SHA." >&2
echo "hint: ensure ci-run.yml has completed on main before cutting a release tag." >&2
;;
api_error)
echo "warning: could not query GitHub API for CI status (gh CLI issue or auth)." >&2
echo "hint: CI status will be verified server-side by release_trigger_guard.py." >&2
;;
*)
echo "error: CI Required Gate conclusion is '$CI_STATUS' (expected 'success')." >&2
exit 1
;;
esac
else
echo "warning: gh CLI not found; skipping local CI status check."
echo "hint: CI status will be verified server-side by release_trigger_guard.py."
fi
# --- Cargo.lock consistency pre-flight ---
echo "Checking Cargo.lock consistency..."
if command -v cargo >/dev/null 2>&1; then
if ! cargo check --locked --quiet; then
echo "error: cargo check --locked failed." >&2
echo "hint: if this is lockfile drift, run 'cargo check' and commit the updated Cargo.lock." >&2
exit 1
fi
echo "Cargo.lock: consistent"
else
echo "warning: cargo not found; skipping Cargo.lock consistency check."
fi
if git show-ref --tags --verify --quiet "refs/tags/$TAG"; then
echo "error: tag already exists locally: $TAG" >&2
exit 1