#!/usr/bin/env bash # Promotion gate: refuse to promote a dev HEAD that is not fully green. # # Reads check-runs on stdin — one # namestatusconclusiondetails_url # per line, fields separated by ASCII Unit Separator (0x1F) — so it is unit-testable # WITHOUT GitHub. promote_dev_to_prod.yml pipes the live `gh api .../check-runs` # output in. 0x1F (not TAB) is used deliberately: TAB is IFS-whitespace, so an empty # conclusion (every in_progress check has a null conclusion) would collapse and shift # the columns — which would make the promote run fail to exclude itself. 0x1F is # non-whitespace, so `read` preserves empty fields and the columns stay aligned. # # A dev HEAD is promotable ONLY when BOTH hold: # 1. every present check-run is completed with a passing conclusion # (success/neutral/skipped); any pending/failed/cancelled/timed_out check BLOCKS. # 2. every check named in REQUIRED_CHECKS is present AND concluded "success". # This positive allow-list is what stops a partial-signal promotion — e.g. a # commit pushed with [skip ci] (no CI check-runs) that still carries one # unrelated green check, or a required check silently renamed/dropped. The gate # FAILS SAFE: a missing required check blocks rather than promotes. # # Self-exclusion: the promotion workflow's OWN in-progress check-run is dropped by # EXCLUDE_RUN_ID (its github.run_id, matched in the check-run details_url) — an # unforgeable identity, NOT a mutable check name. A check that merely happens to be # named "promote" can no longer hide a real failing/required check. set -euo pipefail EXCLUDE_RUN_ID="${EXCLUDE_RUN_ID:-}" # Mandatory checks (one per line). Defaults to the Agent CI suite, which runs on # every push to dev (see ci.yml). Keep in sync with those job names; if a name # drifts the gate blocks (fails safe) until the list is updated. REQUIRED_CHECKS="${REQUIRED_CHECKS:-Agent lint Agent format check Agent unit tests Playwright E2E}" declare -A GREEN seen=0 bad=0 while IFS=$'\037' read -r name status conclusion details_url; do [ -n "${name:-}" ] || continue # drop the promotion run's own check-run by run id (never by name). if [ -n "${EXCLUDE_RUN_ID}" ] && \ [ "${details_url}" != "${details_url#*/runs/${EXCLUDE_RUN_ID}/}" ]; then continue fi seen=$((seen + 1)) if [ "${status}" != "completed" ]; then echo "BLOCK: check '${name}' is '${status}' (not completed)" >&2 bad=1 continue fi case "${conclusion}" in success) GREEN["${name}"]=1 echo "ok: ${name} (success)" ;; neutral | skipped) echo "ok: ${name} (${conclusion})" ;; *) echo "BLOCK: check '${name}' concluded '${conclusion:-}'" >&2 bad=1 ;; esac done if [ "${seen}" -eq 0 ]; then echo "BLOCK: no check-runs found for this commit — refusing to promote an unverified dev HEAD" >&2 exit 1 fi missing=0 while IFS= read -r req; do [ -n "${req}" ] || continue if [ -z "${GREEN[${req}]:-}" ]; then echo "BLOCK: required check '${req}' is missing or not successful on dev HEAD" >&2 missing=1 fi done <&2 exit 1 fi echo "PASS: ${seen} check(s) present, all green; all required checks present + successful."