From ff421f816b7de565f4fe601f9c4b69413aed03ed Mon Sep 17 00:00:00 2001 From: David Meister Date: Tue, 25 Aug 2026 10:32:49 +0000 Subject: [PATCH 01/10] feat(autopublish): gate publishing on the same commit's CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rainix-autopublish raced the caller repo's test workflows on every push to main: nothing ordered publish after green, so a red merge shipped an immutable Soldeer/cargo/npm revision while its own CI was still running or already failed. New rainix-static ci-gate subcommand polls the repository's workflow runs for GITHUB_SHA — excluding every run of the release workflow itself — and lets the job proceed only when all of them completed green (success / skipped / neutral). Failed, cancelled or timed-out runs fail the gate immediately by name; a commit with no other CI after a grace period fails closed (nothing tested it); pending runs poll to a deadline that fails loudly. Transient API failures retry; a token that cannot read runs errors naming the actions:read grant. The workflow invokes it between the change gates and the first mutating step, so a no-op push still short-circuits for free and every caller inherits publish-after-green with no caller changes. Co-Authored-By: Claude Fable 5 --- .github/workflows/rainix-autopublish.yaml | 28 + rainix-static/src/ci_gate.rs | 791 ++++++++++++++++++++++ rainix-static/src/main.rs | 22 +- 3 files changed, 840 insertions(+), 1 deletion(-) create mode 100644 rainix-static/src/ci_gate.rs diff --git a/.github/workflows/rainix-autopublish.yaml b/.github/workflows/rainix-autopublish.yaml index 29937f1..9c7e7e6 100644 --- a/.github/workflows/rainix-autopublish.yaml +++ b/.github/workflows/rainix-autopublish.yaml @@ -72,6 +72,13 @@ jobs: permissions: id-token: write contents: write + # The commit-CI gate below reads this repository's workflow runs. A + # caller job with no `permissions:` block of its own needs nothing — + # this block narrows the token the caller hands over. A caller job that + # DOES set an explicit block on the job that `uses:` this workflow must + # include `actions: read` in it, because a called workflow can only + # narrow the caller's grant, never widen it. + actions: read steps: # This job needs a deploy-key (ssh-key) checkout, so it runs the shared # `checkout` composite itself with the key, then calls the nix+cachix @@ -221,6 +228,27 @@ jobs: rainix-static soldeer-gate \ --package "$SOLDEER_PACKAGE" \ --github-output "$GITHUB_OUTPUT" + # Publish gate on this commit's own CI (rainlanguage/rainix#326). The + # caller's test workflows trigger on the same push as this one and race + # it — nothing else orders publish after green — so before anything + # bumps, tags, or publishes, wait for every OTHER workflow run on + # github.sha (all trigger events, excluding every run of the caller's + # own release workflow, which would deadlock against itself) and require + # them all to have completed green. This inherits whatever the repo runs + # on push — the full rainix-sol/rs matrix, not a re-run subset — at zero + # extra compute. A failed/cancelled run, a timeout, or a commit with NO + # other CI at all (fail-closed: nothing tested it) each fail the gate + # loudly, nothing publishes, and the next push — or a re-run of this job + # once the commit's CI is green — retries for free. Gated on the change + # outputs so a no-op push short-circuits without waiting. Runs via `nix + # run` (no dev shell): the wrapped binary carries its own curl + CA + # bundle, so soldeer/npm/cargo callers all pay only the small + # rainix-static closure. + - name: Gate on commit CI + if: ${{ steps.cargo.outputs.changed == 'true' || steps.npm.outputs.changed == 'true' || steps.soldeer.outputs.changed == 'true' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: nix run github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#rainix-static -- ci-gate # Run the test suite only when something is actually going to publish. The # change gates above are cheap (cargo package --no-verify + a hash compare, # an npm/soldeer version lookup); the full workspace test is the expensive diff --git a/rainix-static/src/ci_gate.rs b/rainix-static/src/ci_gate.rs new file mode 100644 index 0000000..7c763d4 --- /dev/null +++ b/rainix-static/src/ci_gate.rs @@ -0,0 +1,791 @@ +//! `ci-gate` — publish gate on the gated commit's own CI. +//! +//! rainix-autopublish runs concurrently with the caller repo's test workflows +//! (both trigger on the same push), so without a gate a red commit publishes an +//! immutable registry revision while — or before — its own CI reports. This +//! subcommand polls the repository's workflow runs for `GITHUB_SHA`, excluding +//! every run of the release workflow itself (resolved from `GITHUB_RUN_ID`, so +//! the gate never waits on itself, its re-run attempts, or a concurrent +//! dispatch of the same release workflow), and exits 0 only when every other +//! run on the commit has completed green. A failed, cancelled or timed-out run +//! is a loud immediate error naming it; a commit with NO other workflow runs +//! after a grace period is a loud error too (fail-closed: every rainix +//! consumer runs push-triggered CI, so "nothing else ran" means nothing tested +//! the commit, not that there was nothing to wait for). Transient API failures +//! (5xx, rate limits, transport) retry until the deadline; a token that cannot +//! read Actions runs is a fatal error naming the `actions: read` grant the +//! caller must carry. +//! +//! Auth comes from `GITHUB_TOKEN` and reaches curl via a config file on stdin, +//! never argv (argv is world-readable in /proc while curl runs). + +use crate::fail; +use std::io::Write; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +/// One workflow run on the gated commit, as the Actions API reports it. +#[derive(Debug, Clone, PartialEq)] +struct Run { + workflow_id: u64, + name: String, + path: String, + status: String, + conclusion: Option, + url: String, +} + +/// How a single run counts toward the gate. +#[derive(Debug, PartialEq)] +enum RunState { + Green, + Red, + Pending, +} + +/// Classify one run. Anything not `completed` is pending. Completed runs: +/// `success` passes; `skipped` passes (the whole workflow was skipped by +/// job-level `if`s — observed conclusion for such runs — so it deliberately +/// did not apply to this commit); `neutral` passes (GitHub's own required-check +/// logic treats it as passing). `failure`, `cancelled`, `timed_out`, +/// `action_required`, `stale` and `startup_failure` fail. A conclusion this +/// gate does not recognize is a loud error, never a silent pass — fail-closed +/// against GitHub growing new conclusion values. +fn classify(status: &str, conclusion: Option<&str>) -> Result { + if status != "completed" { + return Ok(RunState::Pending); + } + match conclusion { + Some("success") | Some("skipped") | Some("neutral") => Ok(RunState::Green), + Some("failure") + | Some("cancelled") + | Some("timed_out") + | Some("action_required") + | Some("stale") + | Some("startup_failure") => Ok(RunState::Red), + other => Err(format!( + "workflow run completed with unrecognized conclusion {other:?}; \ + refusing to treat it as passing" + )), + } +} + +/// The gate's decision over one snapshot of the commit's runs. +#[derive(Debug, PartialEq)] +enum Verdict { + /// Every other-workflow run on the commit completed green. + Pass { green: usize }, + /// At least one completed red — the strings name them. Red wins over + /// pending: one failed run already forbids the publish, so the gate does + /// not wait for the rest. + Red(Vec), + /// Still waiting on these runs. + Wait(Vec), + /// No runs besides the release workflow's own exist (yet). + NoOtherCi, +} + +/// Decide the gate verdict from a snapshot of the commit's runs. Runs of the +/// release workflow itself (`own_workflow_id`) are invisible to the gate. +fn verdict(runs: &[Run], own_workflow_id: u64) -> Result { + let mut green = 0usize; + let mut red = Vec::new(); + let mut pending = Vec::new(); + for r in runs.iter().filter(|r| r.workflow_id != own_workflow_id) { + match classify(&r.status, r.conclusion.as_deref())? { + RunState::Green => green += 1, + RunState::Red => red.push(format!( + "{} ({}) concluded {}: {}", + r.name, + r.path, + r.conclusion.as_deref().unwrap_or(""), + r.url + )), + RunState::Pending => pending.push(format!("{} ({}) is {}", r.name, r.path, r.status)), + } + } + Ok(if !red.is_empty() { + Verdict::Red(red) + } else if !pending.is_empty() { + Verdict::Wait(pending) + } else if green == 0 { + Verdict::NoOtherCi + } else { + Verdict::Pass { green } + }) +} + +/// Parse the workflow-runs list response into runs + the API's total count +/// (the pagination loop's termination signal). A missing or malformed field is +/// an error, never a skipped run — a run the gate cannot read must not become +/// a run the gate does not wait for. +fn parse_runs(body: &str) -> Result<(Vec, u64), String> { + let v: serde_json::Value = serde_json::from_str(body) + .map_err(|e| format!("workflow-runs response is not JSON ({e}): {body}"))?; + let total = v + .get("total_count") + .and_then(|t| t.as_u64()) + .ok_or_else(|| format!("workflow-runs response has no numeric total_count: {body}"))?; + let arr = v + .get("workflow_runs") + .and_then(|w| w.as_array()) + .ok_or_else(|| format!("workflow-runs response has no workflow_runs array: {body}"))?; + let mut runs = Vec::new(); + for r in arr { + let u64_field = |k: &str| { + r.get(k) + .and_then(|x| x.as_u64()) + .ok_or_else(|| format!("workflow run has no numeric {k}: {r}")) + }; + let str_field = |k: &str| { + r.get(k) + .and_then(|x| x.as_str()) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .ok_or_else(|| format!("workflow run has no {k}: {r}")) + }; + let conclusion = match r.get("conclusion") { + None | Some(serde_json::Value::Null) => None, + Some(serde_json::Value::String(s)) => Some(s.clone()), + Some(other) => return Err(format!("workflow run conclusion is not a string: {other}")), + }; + runs.push(Run { + workflow_id: u64_field("workflow_id")?, + name: str_field("name")?, + path: str_field("path")?, + status: str_field("status")?, + conclusion, + url: str_field("html_url")?, + }); + } + Ok((runs, total)) +} + +/// Parse the single-run lookup response into its workflow_id — which workflow +/// file the release run belongs to. +fn parse_workflow_id(body: &str) -> Result { + let v: serde_json::Value = serde_json::from_str(body) + .map_err(|e| format!("run-lookup response is not JSON ({e}): {body}"))?; + v.get("workflow_id") + .and_then(|x| x.as_u64()) + .ok_or_else(|| format!("run-lookup response has no numeric workflow_id: {body}")) +} + +/// An API failure, split by what the poll loop should do with it. +#[derive(Debug, PartialEq)] +enum ApiFailure { + /// Retry on the next poll tick until the deadline: outages, 5xx, rate + /// limits. A gate that can wait hours for CI must not die to one blip. + Transient(String), + /// Stop now: bad token, missing permission, malformed response. Waiting + /// cannot fix these. + Fatal(String), +} + +/// Map an HTTP status to Ok (caller parses the body) or a failure. 403 is +/// BOTH GitHub's permission refusal and its rate-limit status; the rate-limit +/// bodies say so, so that text routes to Transient and every other 403 (and +/// 404, which is how the API hides resources the token cannot see) is the +/// caller-permissions error, with the fix in the message. +fn api_status(status: u16, body: &str, what: &str) -> Result<(), ApiFailure> { + match status { + 200 => Ok(()), + 401 => Err(ApiFailure::Fatal(format!( + "{what}: GitHub API returned 401 — GITHUB_TOKEN is missing or invalid: {body}" + ))), + 429 => Err(ApiFailure::Transient(format!( + "{what}: GitHub API rate-limited (HTTP 429): {body}" + ))), + 403 if body.contains("rate limit") => Err(ApiFailure::Transient(format!( + "{what}: GitHub API rate-limited (HTTP 403): {body}" + ))), + 403 | 404 => Err(ApiFailure::Fatal(format!( + "{what}: GitHub API returned HTTP {status} — the workflow token cannot read \ + Actions runs. The rainix-autopublish job requests `actions: read`; a caller \ + job that sets an explicit `permissions:` block on the job that `uses:` \ + rainix-autopublish must include `actions: read` in that block (a called \ + workflow can only narrow the caller's grant, never widen it): {body}" + ))), + 500..=599 => Err(ApiFailure::Transient(format!( + "{what}: GitHub API returned HTTP {status}: {body}" + ))), + other => Err(ApiFailure::Fatal(format!( + "{what}: GitHub API returned unexpected HTTP {other}: {body}" + ))), + } +} + +/// curl config lines carrying the auth + protocol headers. The token travels +/// on curl's stdin via this config, never argv. A token that cannot be quoted +/// into the config safely (curl's double-quoted values take backslash +/// escapes) is refused rather than escaped — real GITHUB_TOKENs are plain +/// ASCII, so anything else is not a token. +fn curl_config(token: &str) -> Result { + if token.is_empty() { + return Err("GITHUB_TOKEN is empty".to_string()); + } + if !token + .bytes() + .all(|b| b.is_ascii_graphic() && b != b'"' && b != b'\\') + { + return Err( + "GITHUB_TOKEN contains whitespace, quotes, or non-ASCII bytes; refusing to \ + pass it to curl" + .to_string(), + ); + } + Ok(format!( + "header = \"Authorization: Bearer {token}\"\n\ + header = \"Accept: application/vnd.github+json\"\n\ + header = \"X-GitHub-Api-Version: 2022-11-28\"\n\ + header = \"User-Agent: rainix-autopublish (+https://github.com/rainlanguage/rainix)\"\n" + )) +} + +/// `owner/repo`, both segments limited to GitHub's name alphabet — anything +/// else could smuggle URL structure into the API path. +fn validate_repo(repo: &str) -> Result<(), String> { + let ok_seg = |s: &str| { + !s.is_empty() + && s.bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.') + }; + match repo.split_once('/') { + Some((owner, name)) if ok_seg(owner) && ok_seg(name) => Ok(()), + _ => Err(format!( + "GITHUB_REPOSITORY ({repo:?}) is not an owner/repo name" + )), + } +} + +/// A full 40-hex commit sha, as GITHUB_SHA always is. +fn validate_sha(sha: &str) -> Result<(), String> { + if sha.len() == 40 && sha.bytes().all(|b| b.is_ascii_hexdigit()) { + Ok(()) + } else { + Err(format!("GITHUB_SHA ({sha:?}) is not a 40-hex commit sha")) + } +} + +/// A numeric run id, as GITHUB_RUN_ID always is. +fn validate_run_id(id: &str) -> Result<(), String> { + if !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()) { + Ok(()) + } else { + Err(format!("GITHUB_RUN_ID ({id:?}) is not a run id")) + } +} + +/// GET an API URL with the token, returning (status, body). Transport +/// failures (spawn, DNS, connect, TLS) are strings for the caller to treat as +/// transient. +fn curl_api(url: &str, token: &str) -> Result<(u16, String), String> { + let cfg = curl_config(token)?; + let mut child = Command::new("curl") + .args(["-sS", "--config", "-", "-w", "\n%{http_code}", url]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("curl {url}: failed to spawn: {e}"))?; + child + .stdin + .take() + .expect("stdin was piped") + .write_all(cfg.as_bytes()) + .map_err(|e| format!("curl {url}: failed to write config: {e}"))?; + let out = child + .wait_with_output() + .map_err(|e| format!("curl {url}: {e}"))?; + if !out.status.success() { + return Err(format!( + "curl {url}: {} ({})", + String::from_utf8_lossy(&out.stderr).trim(), + out.status + )); + } + split_status_body(&String::from_utf8_lossy(&out.stdout)) +} + +/// Split curl `-w '\n%{http_code}'` stdout into (status, body): everything +/// after the LAST newline is the status code, everything before it the body. +fn split_status_body(stdout: &str) -> Result<(u16, String), String> { + let (body, code) = stdout + .rsplit_once('\n') + .ok_or_else(|| format!("curl output has no status-code line: {stdout}"))?; + let status = code + .trim() + .parse() + .map_err(|_| format!("curl status-code line ({code}) is not a number"))?; + Ok((status, body.to_string())) +} + +/// The workflow_id of the run the gate is running inside. +fn fetch_own_workflow_id( + api: &str, + repo: &str, + run_id: &str, + token: &str, +) -> Result { + let url = format!("{api}/repos/{repo}/actions/runs/{run_id}"); + let (status, body) = curl_api(&url, token).map_err(ApiFailure::Transient)?; + api_status(status, &body, "look up own workflow run")?; + parse_workflow_id(&body).map_err(ApiFailure::Fatal) +} + +/// All workflow runs for the commit, across every trigger event, paged until +/// the API's own total_count is reached. +fn list_runs(api: &str, repo: &str, sha: &str, token: &str) -> Result, ApiFailure> { + let mut all: Vec = Vec::new(); + let mut page = 1u32; + loop { + let url = + format!("{api}/repos/{repo}/actions/runs?head_sha={sha}&per_page=100&page={page}"); + let (status, body) = curl_api(&url, token).map_err(ApiFailure::Transient)?; + api_status(status, &body, "list workflow runs")?; + let (runs, total) = parse_runs(&body).map_err(ApiFailure::Fatal)?; + let got = runs.len(); + all.extend(runs); + if all.len() as u64 >= total || got == 0 { + return Ok(all); + } + page += 1; + if page > 20 { + return Err(ApiFailure::Fatal(format!( + "more than 2000 workflow runs reported for {sha}; refusing to page further" + ))); + } + } +} + +/// Run the gate: poll until every other run on GITHUB_SHA is green (exit 0), +/// any is red (loud failure), no other CI exists past the grace period (loud, +/// fail-closed), or the deadline passes (loud, naming what was still pending). +pub(crate) fn run(timeout_secs: u64, poll_secs: u64, grace_secs: u64) { + let env = |k: &str| { + std::env::var(k) + .ok() + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| fail(&format!("ci-gate: {k} is not set"))) + }; + let repo = env("GITHUB_REPOSITORY"); + let sha = env("GITHUB_SHA"); + let run_id = env("GITHUB_RUN_ID"); + let token = env("GITHUB_TOKEN"); + let api = std::env::var("GITHUB_API_URL") + .ok() + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| "https://api.github.com".to_string()); + validate_repo(&repo).unwrap_or_else(|e| fail(&format!("ci-gate: {e}"))); + validate_sha(&sha).unwrap_or_else(|e| fail(&format!("ci-gate: {e}"))); + validate_run_id(&run_id).unwrap_or_else(|e| fail(&format!("ci-gate: {e}"))); + + let start = Instant::now(); + let deadline = Duration::from_secs(timeout_secs); + let grace = Duration::from_secs(grace_secs); + let poll = Duration::from_secs(poll_secs); + + let own_workflow_id = loop { + match fetch_own_workflow_id(&api, &repo, &run_id, &token) { + Ok(id) => break id, + Err(ApiFailure::Fatal(m)) => fail(&format!("ci-gate: {m}")), + Err(ApiFailure::Transient(m)) => { + eprintln!("ci-gate: transient API failure, will retry: {m}"); + if start.elapsed() >= deadline { + fail(&format!( + "ci-gate: timed out after {timeout_secs}s without resolving own \ + workflow run; last failure: {m}" + )); + } + std::thread::sleep(poll); + } + } + }; + + let mut last_wait: Vec = Vec::new(); + let mut last_transient: Option = None; + loop { + match list_runs(&api, &repo, &sha, &token) { + Err(ApiFailure::Fatal(m)) => fail(&format!("ci-gate: {m}")), + Err(ApiFailure::Transient(m)) => { + eprintln!("ci-gate: transient API failure, will retry: {m}"); + last_transient = Some(m); + } + Ok(runs) => match verdict(&runs, own_workflow_id) { + Err(m) => fail(&format!("ci-gate: {m}")), + Ok(Verdict::Pass { green }) => { + println!("ci-gate: all {green} other workflow run(s) on {sha} completed green"); + return; + } + Ok(Verdict::Red(msgs)) => fail(&format!( + "ci-gate: refusing to publish {sha} — {} workflow run(s) on this \ + commit failed: {}", + msgs.len(), + msgs.join("; ") + )), + Ok(Verdict::Wait(pending)) => { + eprintln!( + "ci-gate: waiting on {} run(s): {}", + pending.len(), + pending.join("; ") + ); + last_wait = pending; + } + Ok(Verdict::NoOtherCi) => { + if start.elapsed() >= grace { + fail(&format!( + "ci-gate: no workflow run besides this release workflow exists \ + for {sha} after {grace_secs}s — refusing to publish a commit \ + nothing has tested. Add a workflow that runs the repo's \ + checks on push (every rainix consumer has one), then re-run \ + this job." + )); + } + eprintln!( + "ci-gate: no other workflow runs for {sha} yet; \ + within the {grace_secs}s grace period for them to appear" + ); + } + }, + } + if start.elapsed() >= deadline { + let detail = if !last_wait.is_empty() { + format!("still pending: {}", last_wait.join("; ")) + } else if let Some(t) = last_transient { + format!("last API failure: {t}") + } else { + "no other workflow runs were observed".to_string() + }; + fail(&format!( + "ci-gate: timed out after {timeout_secs}s waiting for CI on {sha}; {detail}" + )); + } + std::thread::sleep(poll); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn run_with(workflow_id: u64, status: &str, conclusion: Option<&str>) -> Run { + Run { + workflow_id, + name: format!("wf-{workflow_id}"), + path: format!(".github/workflows/wf-{workflow_id}.yaml"), + status: status.to_string(), + conclusion: conclusion.map(str::to_string), + url: format!("https://github.com/o/r/actions/runs/{workflow_id}"), + } + } + + #[test] + fn classify_incomplete_is_pending_regardless_of_conclusion() { + for status in ["queued", "in_progress", "waiting", "requested", "pending"] { + assert_eq!(classify(status, None).unwrap(), RunState::Pending); + // Even a conclusion-carrying non-completed run is pending: only + // `completed` has a final verdict. + assert_eq!( + classify(status, Some("success")).unwrap(), + RunState::Pending + ); + } + // A status this gate has never seen can only delay, never pass or fail. + assert_eq!(classify("hologram", None).unwrap(), RunState::Pending); + } + + #[test] + fn classify_green_conclusions() { + for c in ["success", "skipped", "neutral"] { + assert_eq!(classify("completed", Some(c)).unwrap(), RunState::Green); + } + } + + #[test] + fn classify_red_conclusions() { + for c in [ + "failure", + "cancelled", + "timed_out", + "action_required", + "stale", + "startup_failure", + ] { + assert_eq!(classify("completed", Some(c)).unwrap(), RunState::Red); + } + } + + #[test] + fn classify_unknown_completed_conclusion_is_loud() { + // Fail-closed: a conclusion value this gate does not know must never + // be treated as passing (or silently failing). + let e = classify("completed", Some("great_success")).unwrap_err(); + assert!(e.contains("great_success"), "{e}"); + assert!(classify("completed", None).is_err()); + } + + #[test] + fn verdict_all_green_passes_and_counts() { + let runs = vec![ + run_with(1, "completed", Some("success")), + run_with(2, "completed", Some("skipped")), + run_with(9, "in_progress", None), // own workflow: invisible + ]; + assert_eq!(verdict(&runs, 9).unwrap(), Verdict::Pass { green: 2 }); + } + + #[test] + fn verdict_excludes_every_run_of_own_workflow() { + // Two runs of the release workflow itself (e.g. a push run and a + // dispatch re-run) must both be invisible, or the gate deadlocks on + // itself. + let runs = vec![ + run_with(9, "in_progress", None), + run_with(9, "queued", None), + run_with(1, "completed", Some("success")), + ]; + assert_eq!(verdict(&runs, 9).unwrap(), Verdict::Pass { green: 1 }); + } + + #[test] + fn verdict_red_names_the_failed_run() { + let runs = vec![ + run_with(1, "completed", Some("failure")), + run_with(2, "completed", Some("success")), + ]; + match verdict(&runs, 9).unwrap() { + Verdict::Red(msgs) => { + assert_eq!(msgs.len(), 1); + assert!(msgs[0].contains("wf-1"), "{}", msgs[0]); + assert!(msgs[0].contains("failure"), "{}", msgs[0]); + assert!(msgs[0].contains("actions/runs"), "{}", msgs[0]); + } + v => panic!("expected Red, got {v:?}"), + } + } + + #[test] + fn verdict_red_wins_over_pending() { + // One red already forbids the publish; the gate must not keep waiting + // on the rest first. + let runs = vec![ + run_with(1, "completed", Some("cancelled")), + run_with(2, "in_progress", None), + ]; + assert!(matches!(verdict(&runs, 9).unwrap(), Verdict::Red(_))); + } + + #[test] + fn verdict_pending_waits() { + let runs = vec![ + run_with(1, "completed", Some("success")), + run_with(2, "queued", None), + ]; + match verdict(&runs, 9).unwrap() { + Verdict::Wait(p) => { + assert_eq!(p.len(), 1); + assert!(p[0].contains("wf-2"), "{}", p[0]); + assert!(p[0].contains("queued"), "{}", p[0]); + } + v => panic!("expected Wait, got {v:?}"), + } + } + + #[test] + fn verdict_no_other_ci_when_only_own_runs_exist() { + assert_eq!(verdict(&[], 9).unwrap(), Verdict::NoOtherCi); + let only_own = vec![run_with(9, "in_progress", None)]; + assert_eq!(verdict(&only_own, 9).unwrap(), Verdict::NoOtherCi); + } + + #[test] + fn verdict_unknown_conclusion_is_loud_even_beside_green() { + let runs = vec![ + run_with(1, "completed", Some("success")), + run_with(2, "completed", Some("mystery")), + ]; + assert!(verdict(&runs, 9).is_err()); + } + + /// Live API shape, captured from GET + /// /repos/rainlanguage/rain.string/actions/runs?head_sha=256c624… — the + /// release run's status/conclusion as observed while it was still queued + /// (`"pending"` / null), everything else verbatim from the response. + /// Extra fields are present and ignored. + #[test] + fn parse_runs_live_shape() { + let body = r#"{ + "total_count": 2, + "workflow_runs": [ + {"id": 32835741741, "name": "Package Release", + "path": ".github/workflows/package-release.yaml", + "head_sha": "256c62449bcf4678638c6a21695f70269d2b2bef", + "event": "push", "status": "pending", "conclusion": null, + "workflow_id": 289149655, "run_attempt": 1, + "html_url": "https://github.com/rainlanguage/rain.string/actions/runs/32835741741"}, + {"id": 32835741982, "name": "rainix", + "path": ".github/workflows/rainix.yaml", + "head_sha": "256c62449bcf4678638c6a21695f70269d2b2bef", + "event": "push", "status": "completed", "conclusion": "success", + "workflow_id": 125389650, "run_attempt": 1, + "html_url": "https://github.com/rainlanguage/rain.string/actions/runs/32835741982"} + ] + }"#; + let (runs, total) = parse_runs(body).unwrap(); + assert_eq!(total, 2); + assert_eq!(runs.len(), 2); + assert_eq!(runs[0].workflow_id, 289149655); + assert_eq!(runs[0].status, "pending"); + assert_eq!(runs[0].conclusion, None); + assert_eq!(runs[1].conclusion.as_deref(), Some("success")); + // The two workflows are distinguishable for self-exclusion. + assert_ne!(runs[0].workflow_id, runs[1].workflow_id); + } + + #[test] + fn parse_runs_malformed_is_an_error_not_a_skipped_run() { + assert!(parse_runs("not json").is_err()); + assert!(parse_runs("{}").is_err()); + // Missing total_count alone (array fine) is an error, never zero: + // total_count is the pagination loop's termination signal. + assert!(parse_runs(r#"{"workflow_runs":[]}"#).is_err()); + assert!(parse_runs(r#"{"total_count": 1}"#).is_err()); // no array + // A run entry missing its workflow_id must not silently drop out of + // the wait set. + assert!(parse_runs( + r#"{"total_count":1,"workflow_runs":[ + {"name":"x","path":"p","status":"queued","conclusion":null,"html_url":"u"}]}"# + ) + .is_err()); + // conclusion of a non-string, non-null type is malformed. + assert!(parse_runs( + r#"{"total_count":1,"workflow_runs":[ + {"workflow_id":1,"name":"x","path":"p","status":"queued","conclusion":7,"html_url":"u"}]}"# + ) + .is_err()); + } + + #[test] + fn parse_workflow_id_live_shape() { + // Captured from GET /repos/rainlanguage/rain.string/actions/runs/32835741741. + let body = r#"{"id": 32835741741, "workflow_id": 289149655, + "path": ".github/workflows/package-release.yaml", + "status": "completed", "conclusion": "success"}"#; + assert_eq!(parse_workflow_id(body).unwrap(), 289149655); + assert!(parse_workflow_id("{}").is_err()); + assert!(parse_workflow_id("not json").is_err()); + assert!(parse_workflow_id(r#"{"workflow_id":"str"}"#).is_err()); + } + + #[test] + fn api_status_ok_and_auth_failures() { + assert!(api_status(200, "{}", "x").is_ok()); + match api_status(401, "bad credentials", "x") { + Err(ApiFailure::Fatal(m)) => assert!(m.contains("GITHUB_TOKEN"), "{m}"), + other => panic!("expected Fatal, got {other:?}"), + } + } + + #[test] + fn api_status_permission_refusal_names_the_grant() { + // 403 (resource_not_accessible) and 404 (how the API hides what the + // token cannot see) both carry the actionable fix. + for status in [403u16, 404] { + match api_status(status, r#"{"message":"Resource not accessible"}"#, "x") { + Err(ApiFailure::Fatal(m)) => { + assert!(m.contains("actions: read"), "{m}"); + assert!(m.contains("permissions"), "{m}"); + } + other => panic!("expected Fatal for {status}, got {other:?}"), + } + } + } + + #[test] + fn api_status_rate_limits_and_5xx_are_transient() { + for (status, body) in [ + (429u16, "slow down"), + ( + 403, + r#"{"message":"API rate limit exceeded for installation"}"#, + ), + ( + 403, + r#"{"message":"You have exceeded a secondary rate limit"}"#, + ), + (500, "boom"), + (502, "bad gateway"), + (503, ""), + ] { + assert!( + matches!(api_status(status, body, "x"), Err(ApiFailure::Transient(_))), + "{status} {body}" + ); + } + } + + #[test] + fn api_status_unexpected_is_fatal() { + assert!(matches!( + api_status(302, "", "x"), + Err(ApiFailure::Fatal(_)) + )); + assert!(matches!( + api_status(418, "teapot", "x"), + Err(ApiFailure::Fatal(_)) + )); + } + + #[test] + fn curl_config_carries_token_and_protocol_headers() { + let cfg = curl_config("ghs_abc123").unwrap(); + assert!(cfg.contains("Authorization: Bearer ghs_abc123"), "{cfg}"); + assert!(cfg.contains("Accept: application/vnd.github+json"), "{cfg}"); + assert!(cfg.contains("X-GitHub-Api-Version"), "{cfg}"); + assert!(cfg.contains("User-Agent"), "{cfg}"); + } + + #[test] + fn curl_config_refuses_unquotable_tokens() { + assert!(curl_config("").is_err()); + assert!(curl_config("has space").is_err()); + assert!(curl_config("has\"quote").is_err()); + assert!(curl_config("has\\slash").is_err()); + assert!(curl_config("has\nnewline").is_err()); + assert!(curl_config("hàs-utf8").is_err()); + } + + #[test] + fn env_inputs_are_validated() { + assert!(validate_repo("rainlanguage/rain.string").is_ok()); + assert!(validate_repo("no-slash").is_err()); + assert!(validate_repo("a/b/c").is_err()); + assert!(validate_repo("a/b?x=1").is_err()); + assert!(validate_repo("/name").is_err()); + assert!(validate_repo("owner/").is_err()); + + assert!(validate_sha("256c62449bcf4678638c6a21695f70269d2b2bef").is_ok()); + assert!(validate_sha("main").is_err()); + assert!(validate_sha("256c624").is_err()); + assert!(validate_sha("z56c62449bcf4678638c6a21695f70269d2b2bef").is_err()); + + assert!(validate_run_id("32835741741").is_ok()); + assert!(validate_run_id("").is_err()); + assert!(validate_run_id("12x").is_err()); + } + + #[test] + fn curl_output_splits_into_status_and_body() { + assert_eq!( + split_status_body("body\n200").unwrap(), + (200, "body".to_string()) + ); + assert_eq!( + split_status_body("{\"a\":1}\nmore\n404").unwrap(), + (404, "{\"a\":1}\nmore".to_string()) + ); + assert_eq!(split_status_body("\n404").unwrap(), (404, String::new())); + assert!(split_status_body("no-newline").is_err()); + assert!(split_status_body("body\nnot-a-number").is_err()); + } +} diff --git a/rainix-static/src/main.rs b/rainix-static/src/main.rs index 68a0cdf..18cd490 100644 --- a/rainix-static/src/main.rs +++ b/rainix-static/src/main.rs @@ -40,6 +40,18 @@ // origin/main). Snapshots are frozen once on the base branch; a release // ADDS a new , never edits an existing one. Needs the base ref // fetched with history (fetch-depth: 0 + `git fetch origin `). +// ci-gate [--timeout-secs N] [--poll-secs N] [--grace-secs N] +// Publish gate on the gated commit's own CI: poll the repository's +// workflow runs for GITHUB_SHA — every trigger event, excluding every +// run of the release workflow this gate runs inside (resolved from +// GITHUB_RUN_ID) — and exit 0 only when all of them completed green +// (success / skipped / neutral). A failed, cancelled, or timed-out run +// fails the gate immediately, naming it; a commit with NO other workflow +// runs after --grace-secs (default 120) fails closed — nothing tested +// the commit; hitting --timeout-secs (default 7200, polling every +// --poll-secs, default 30) fails naming what was still pending. Needs +// GITHUB_REPOSITORY / GITHUB_SHA / GITHUB_RUN_ID / GITHUB_TOKEN (the +// token needs `actions: read`); runs where curl is on PATH. // soldeer-gate --package [--github-output ] // Soldeer content gate: compare the normalized content of what // `forge soldeer push --dry-run` would upload against the newest published @@ -73,6 +85,7 @@ // where git is on PATH. mod agent_context_cap; +mod ci_gate; mod context_bytes; mod frozen_snapshots; mod no_submodules; @@ -171,6 +184,12 @@ fn main() { } } } + "ci-gate" => { + let timeout = num(&args, "--timeout-secs", 7200); + let poll = num(&args, "--poll-secs", 30); + let grace = num(&args, "--grace-secs", 120); + ci_gate::run(u64::from(timeout), u64::from(poll), u64::from(grace)); + } "soldeer-gate" => { let pkg = flag(&args, "--package") .unwrap_or_else(|| fail("soldeer-gate: --package required")); @@ -236,7 +255,8 @@ fn main() { eprintln!( "rainix-static: unknown subcommand {other:?} \ (available: no-submodules, agent-context-cap, prompt-cap, \ - snapshots-append-only, soldeer-gate, rpc-preflight, release-guard)" + snapshots-append-only, ci-gate, soldeer-gate, rpc-preflight, \ + release-guard)" ); std::process::exit(2); } From 2455992d1a8300447daae66979a50a47cb2b9756 Mon Sep 17 00:00:00 2001 From: David Meister Date: Tue, 25 Aug 2026 10:43:00 +0000 Subject: [PATCH 02/10] fix(pre-commit): rustfmt nested crates instead of erroring at the repo root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conditional rustfmt hook detected a crate via the */Cargo.toml glob but then ran cargo-fmt from the repo root, where no manifest exists — so any repo whose only crate is nested (this one: rainix-static/) failed the hook on every all-files run. Format each detected manifest via --manifest-path instead. Co-Authored-By: Claude Fable 5 --- flake.nix | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/flake.nix b/flake.nix index 0d809e6..4a62116 100644 --- a/flake.nix +++ b/flake.nix @@ -521,14 +521,22 @@ # (instead of nix-store interpolation) keeps rust-toolchain out # of the hook's nix closure, so consumers of sol-shell — which # have no rust to format — do not pull the rust toolchain in. + # Each manifest (repo root or one level down, e.g. this repo's + # rainix-static/Cargo.toml) is formatted via --manifest-path, so + # a crate nested below the repo root is formatted rather than + # cargo erroring on the manifest-less root. rustfmt-conditional = { enable = true; name = "rustfmt"; entry = "${pkgs.writeShellScript "rustfmt-conditional" '' command -v cargo-fmt >/dev/null 2>&1 || exit 0 - if [ -f Cargo.toml ] || [ -f */Cargo.toml ]; then - exec cargo-fmt fmt - fi + status=0 + for manifest in Cargo.toml */Cargo.toml; do + if [ -f "$manifest" ]; then + cargo-fmt fmt --manifest-path "$manifest" || status=1 + fi + done + exit "$status" ''}"; files = "\\.rs$"; pass_filenames = false; From 8c9af7c012c73370f6890a8c6894aea3dfefa39d Mon Sep 17 00:00:00 2001 From: David Meister Date: Tue, 25 Aug 2026 10:43:17 +0000 Subject: [PATCH 03/10] ci: bump RAINIX_SHA to 2455992d1a8300447daae66979a50a47cb2b9756 (ci-gate subcommand) Co-Authored-By: Claude Fable 5 --- .github/workflows/rainix-autopublish.yaml | 2 +- .github/workflows/rainix-copy-artifacts.yaml | 2 +- .github/workflows/rainix-manual-sol-artifacts.yaml | 2 +- .github/workflows/rainix-manual-sol-verify.yaml | 2 +- .github/workflows/rainix-rs-static.yaml | 2 +- .github/workflows/rainix-rs-test.yaml | 2 +- .github/workflows/rainix-rs-wasm-test.yaml | 2 +- .github/workflows/rainix-rs-wasm.yaml | 2 +- .github/workflows/rainix-sol-legal.yaml | 2 +- .github/workflows/rainix-sol-static.yaml | 2 +- .github/workflows/rainix-sol-test.yaml | 2 +- .github/workflows/rainix-subgraph-test.yaml | 2 +- .github/workflows/rainix-tag-release.yaml | 2 +- 13 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/rainix-autopublish.yaml b/.github/workflows/rainix-autopublish.yaml index 9c7e7e6..bef28be 100644 --- a/.github/workflows/rainix-autopublish.yaml +++ b/.github/workflows/rainix-autopublish.yaml @@ -50,7 +50,7 @@ on: SOLDEER_API_TOKEN: required: false env: - RAINIX_SHA: c4cf22d9b76600a4ad33b5552f4083f80d9b83de + RAINIX_SHA: 2455992d1a8300447daae66979a50a47cb2b9756 jobs: release: if: ${{ !startsWith(github.event.head_commit.message, 'Package Release') }} diff --git a/.github/workflows/rainix-copy-artifacts.yaml b/.github/workflows/rainix-copy-artifacts.yaml index e294528..7a64b05 100644 --- a/.github/workflows/rainix-copy-artifacts.yaml +++ b/.github/workflows/rainix-copy-artifacts.yaml @@ -2,7 +2,7 @@ name: rainix-copy-artifacts on: workflow_call: env: - RAINIX_SHA: c4cf22d9b76600a4ad33b5552f4083f80d9b83de + RAINIX_SHA: 2455992d1a8300447daae66979a50a47cb2b9756 jobs: copy-artifacts: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-manual-sol-artifacts.yaml b/.github/workflows/rainix-manual-sol-artifacts.yaml index cd792e6..2f10cb5 100644 --- a/.github/workflows/rainix-manual-sol-artifacts.yaml +++ b/.github/workflows/rainix-manual-sol-artifacts.yaml @@ -85,7 +85,7 @@ on: CI_DEPLOY_FLARE_ETHERSCAN_API_KEY: required: false env: - RAINIX_SHA: c4cf22d9b76600a4ad33b5552f4083f80d9b83de + RAINIX_SHA: 2455992d1a8300447daae66979a50a47cb2b9756 jobs: deploy: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-manual-sol-verify.yaml b/.github/workflows/rainix-manual-sol-verify.yaml index 7faa316..01b7c2a 100644 --- a/.github/workflows/rainix-manual-sol-verify.yaml +++ b/.github/workflows/rainix-manual-sol-verify.yaml @@ -65,7 +65,7 @@ on: CI_DEPLOY_FLARE_ETHERSCAN_API_KEY: required: false env: - RAINIX_SHA: c4cf22d9b76600a4ad33b5552f4083f80d9b83de + RAINIX_SHA: 2455992d1a8300447daae66979a50a47cb2b9756 jobs: verify: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-rs-static.yaml b/.github/workflows/rainix-rs-static.yaml index d1b0dd5..ba45a19 100644 --- a/.github/workflows/rainix-rs-static.yaml +++ b/.github/workflows/rainix-rs-static.yaml @@ -2,7 +2,7 @@ name: rainix-rs-static on: workflow_call: env: - RAINIX_SHA: c4cf22d9b76600a4ad33b5552f4083f80d9b83de + RAINIX_SHA: 2455992d1a8300447daae66979a50a47cb2b9756 jobs: rs-static: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-rs-test.yaml b/.github/workflows/rainix-rs-test.yaml index b35b7a0..eed658a 100644 --- a/.github/workflows/rainix-rs-test.yaml +++ b/.github/workflows/rainix-rs-test.yaml @@ -2,7 +2,7 @@ name: rainix-rs-test on: workflow_call: env: - RAINIX_SHA: c4cf22d9b76600a4ad33b5552f4083f80d9b83de + RAINIX_SHA: 2455992d1a8300447daae66979a50a47cb2b9756 jobs: rs-test: strategy: diff --git a/.github/workflows/rainix-rs-wasm-test.yaml b/.github/workflows/rainix-rs-wasm-test.yaml index 204eeaa..edecff4 100644 --- a/.github/workflows/rainix-rs-wasm-test.yaml +++ b/.github/workflows/rainix-rs-wasm-test.yaml @@ -2,7 +2,7 @@ name: rainix-rs-wasm-test on: workflow_call: env: - RAINIX_SHA: c4cf22d9b76600a4ad33b5552f4083f80d9b83de + RAINIX_SHA: 2455992d1a8300447daae66979a50a47cb2b9756 jobs: rs-wasm-test: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-rs-wasm.yaml b/.github/workflows/rainix-rs-wasm.yaml index 274e615..6a54a54 100644 --- a/.github/workflows/rainix-rs-wasm.yaml +++ b/.github/workflows/rainix-rs-wasm.yaml @@ -2,7 +2,7 @@ name: rainix-rs-wasm on: workflow_call: env: - RAINIX_SHA: c4cf22d9b76600a4ad33b5552f4083f80d9b83de + RAINIX_SHA: 2455992d1a8300447daae66979a50a47cb2b9756 jobs: rs-wasm: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-sol-legal.yaml b/.github/workflows/rainix-sol-legal.yaml index 3407f8e..a50f921 100644 --- a/.github/workflows/rainix-sol-legal.yaml +++ b/.github/workflows/rainix-sol-legal.yaml @@ -2,7 +2,7 @@ name: rainix-sol-legal on: workflow_call: env: - RAINIX_SHA: c4cf22d9b76600a4ad33b5552f4083f80d9b83de + RAINIX_SHA: 2455992d1a8300447daae66979a50a47cb2b9756 jobs: legal: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-sol-static.yaml b/.github/workflows/rainix-sol-static.yaml index 6358810..91e1b34 100644 --- a/.github/workflows/rainix-sol-static.yaml +++ b/.github/workflows/rainix-sol-static.yaml @@ -2,7 +2,7 @@ name: rainix-sol-static on: workflow_call: env: - RAINIX_SHA: c4cf22d9b76600a4ad33b5552f4083f80d9b83de + RAINIX_SHA: 2455992d1a8300447daae66979a50a47cb2b9756 jobs: static: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-sol-test.yaml b/.github/workflows/rainix-sol-test.yaml index 92eaa9f..da75d5c 100644 --- a/.github/workflows/rainix-sol-test.yaml +++ b/.github/workflows/rainix-sol-test.yaml @@ -25,7 +25,7 @@ on: RPC_URL_SEPOLIA_FORK: required: false env: - RAINIX_SHA: c4cf22d9b76600a4ad33b5552f4083f80d9b83de + RAINIX_SHA: 2455992d1a8300447daae66979a50a47cb2b9756 jobs: test: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-subgraph-test.yaml b/.github/workflows/rainix-subgraph-test.yaml index 47dad36..1cc723c 100644 --- a/.github/workflows/rainix-subgraph-test.yaml +++ b/.github/workflows/rainix-subgraph-test.yaml @@ -2,7 +2,7 @@ name: rainix-subgraph-test on: workflow_call: env: - RAINIX_SHA: c4cf22d9b76600a4ad33b5552f4083f80d9b83de + RAINIX_SHA: 2455992d1a8300447daae66979a50a47cb2b9756 jobs: subgraph-test: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-tag-release.yaml b/.github/workflows/rainix-tag-release.yaml index 08fa303..91c5119 100644 --- a/.github/workflows/rainix-tag-release.yaml +++ b/.github/workflows/rainix-tag-release.yaml @@ -124,7 +124,7 @@ on: RPC_URL_SEPOLIA_FORK: required: false env: - RAINIX_SHA: c4cf22d9b76600a4ad33b5552f4083f80d9b83de + RAINIX_SHA: 2455992d1a8300447daae66979a50a47cb2b9756 jobs: # The release tag must point at a commit already merged to the release branch. # `on: push: tags` fires for ANY tag, including one cut from an unmerged branch; From c4a87d9301951ceda40b88270df7ef2c8ad80500 Mon Sep 17 00:00:00 2001 From: David Meister Date: Tue, 25 Aug 2026 10:56:57 +0000 Subject: [PATCH 04/10] fix(ci-gate): bound curl transfers, defer green past the discovery grace, refuse zero poll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on #362, all three real: - curl has no default max-time, so one stalled response could hang the gate past its own deadline; every transfer now carries connect-timeout 30 / max-time 120 in the curl config, failing as transient and retrying instead. A fixed per-transfer bound beats plumbing the remaining gate deadline, which early in a 2h gate would let a single stall run for hours. - An all-green run set observed before the discovery grace elapsed was accepted immediately, though run registration lags the trigger — the same race the fail-closed no-other-CI grace exists for. Green now re-checks until the grace passes (<=120s added to a fully-green publish); red stays immediate. - --poll-secs 0 turned every retry into a busy loop; it is now refused. Co-Authored-By: Claude Fable 5 --- .github/workflows/rainix-autopublish.yaml | 5 +- rainix-static/src/ci_gate.rs | 91 +++++++++++++++++++---- rainix-static/src/main.rs | 13 ++-- 3 files changed, 88 insertions(+), 21 deletions(-) diff --git a/.github/workflows/rainix-autopublish.yaml b/.github/workflows/rainix-autopublish.yaml index bef28be..ad3f24e 100644 --- a/.github/workflows/rainix-autopublish.yaml +++ b/.github/workflows/rainix-autopublish.yaml @@ -234,7 +234,10 @@ jobs: # bumps, tags, or publishes, wait for every OTHER workflow run on # github.sha (all trigger events, excluding every run of the caller's # own release workflow, which would deadlock against itself) and require - # them all to have completed green. This inherits whatever the repo runs + # them all to have completed green, re-checking any all-green set seen + # before the discovery grace elapses (run registration lags the push, so + # an early snapshot could miss a late-registering run). This inherits + # whatever the repo runs # on push — the full rainix-sol/rs matrix, not a re-run subset — at zero # extra compute. A failed/cancelled run, a timeout, or a commit with NO # other CI at all (fail-closed: nothing tested it) each fail the gate diff --git a/rainix-static/src/ci_gate.rs b/rainix-static/src/ci_gate.rs index 7c763d4..d60aa1d 100644 --- a/rainix-static/src/ci_gate.rs +++ b/rainix-static/src/ci_gate.rs @@ -7,9 +7,12 @@ //! every run of the release workflow itself (resolved from `GITHUB_RUN_ID`, so //! the gate never waits on itself, its re-run attempts, or a concurrent //! dispatch of the same release workflow), and exits 0 only when every other -//! run on the commit has completed green. A failed, cancelled or timed-out run -//! is a loud immediate error naming it; a commit with NO other workflow runs -//! after a grace period is a loud error too (fail-closed: every rainix +//! run on the commit has completed green and the discovery grace period has +//! elapsed — run registration lags the triggering event, so an all-green set +//! observed earlier is re-checked until the grace passes. A failed, cancelled +//! or timed-out run is a loud immediate error naming it; a commit with NO +//! other workflow runs after the same grace is a loud error too (fail-closed: +//! every rainix //! consumer runs push-triggered CI, so "nothing else ran" means nothing tested //! the commit, not that there was nothing to wait for). Transient API failures //! (5xx, rate limits, transport) retry until the deadline; a token that cannot @@ -215,11 +218,14 @@ fn api_status(status: u16, body: &str, what: &str) -> Result<(), ApiFailure> { } } -/// curl config lines carrying the auth + protocol headers. The token travels -/// on curl's stdin via this config, never argv. A token that cannot be quoted -/// into the config safely (curl's double-quoted values take backslash -/// escapes) is refused rather than escaped — real GITHUB_TOKENs are plain -/// ASCII, so anything else is not a token. +/// curl config lines carrying the auth + protocol headers and per-transfer +/// bounds (curl has no default `max-time`, so without one a stalled response +/// would hang the gate past its own deadline; a bounded transfer fails as +/// transient and retries instead). The token travels on curl's stdin via this +/// config, never argv. A token that cannot be quoted into the config safely +/// (curl's double-quoted values take backslash escapes) is refused rather +/// than escaped — real GITHUB_TOKENs are plain ASCII, so anything else is not +/// a token. fn curl_config(token: &str) -> Result { if token.is_empty() { return Err("GITHUB_TOKEN is empty".to_string()); @@ -238,10 +244,31 @@ fn curl_config(token: &str) -> Result { "header = \"Authorization: Bearer {token}\"\n\ header = \"Accept: application/vnd.github+json\"\n\ header = \"X-GitHub-Api-Version: 2022-11-28\"\n\ - header = \"User-Agent: rainix-autopublish (+https://github.com/rainlanguage/rainix)\"\n" + header = \"User-Agent: rainix-autopublish (+https://github.com/rainlanguage/rainix)\"\n\ + connect-timeout = 30\n\ + max-time = 120\n" )) } +/// Whether the discovery grace period is still running. Run registration lags +/// the triggering event, so early snapshots are untrustworthy in both +/// directions: "no other runs" may mean runs have not registered yet, and an +/// all-green set may still be missing late-registering runs. At exactly the +/// grace boundary the period is over. +fn within_grace(elapsed: Duration, grace: Duration) -> bool { + elapsed < grace +} + +/// A zero poll interval turns every retry/wait into a busy loop against the +/// API; refuse it. +fn validate_poll_secs(secs: u64) -> Result<(), String> { + if secs == 0 { + Err("--poll-secs must be at least 1".to_string()) + } else { + Ok(()) + } +} + /// `owner/repo`, both segments limited to GitHub's name alphabet — anything /// else could smuggle URL structure into the API path. fn validate_repo(repo: &str) -> Result<(), String> { @@ -358,9 +385,10 @@ fn list_runs(api: &str, repo: &str, sha: &str, token: &str) -> Result, } } -/// Run the gate: poll until every other run on GITHUB_SHA is green (exit 0), -/// any is red (loud failure), no other CI exists past the grace period (loud, -/// fail-closed), or the deadline passes (loud, naming what was still pending). +/// Run the gate: poll until every other run on GITHUB_SHA is green with the +/// discovery grace elapsed (exit 0), any is red (loud failure), no other CI +/// exists past the grace period (loud, fail-closed), or the deadline passes +/// (loud, naming what was still pending). pub(crate) fn run(timeout_secs: u64, poll_secs: u64, grace_secs: u64) { let env = |k: &str| { std::env::var(k) @@ -379,6 +407,7 @@ pub(crate) fn run(timeout_secs: u64, poll_secs: u64, grace_secs: u64) { validate_repo(&repo).unwrap_or_else(|e| fail(&format!("ci-gate: {e}"))); validate_sha(&sha).unwrap_or_else(|e| fail(&format!("ci-gate: {e}"))); validate_run_id(&run_id).unwrap_or_else(|e| fail(&format!("ci-gate: {e}"))); + validate_poll_secs(poll_secs).unwrap_or_else(|e| fail(&format!("ci-gate: {e}"))); let start = Instant::now(); let deadline = Duration::from_secs(timeout_secs); @@ -414,8 +443,17 @@ pub(crate) fn run(timeout_secs: u64, poll_secs: u64, grace_secs: u64) { Ok(runs) => match verdict(&runs, own_workflow_id) { Err(m) => fail(&format!("ci-gate: {m}")), Ok(Verdict::Pass { green }) => { - println!("ci-gate: all {green} other workflow run(s) on {sha} completed green"); - return; + if !within_grace(start.elapsed(), grace) { + println!( + "ci-gate: all {green} other workflow run(s) on {sha} completed green" + ); + return; + } + eprintln!( + "ci-gate: all {green} observed run(s) on {sha} are green, but still \ + within the {grace_secs}s grace period for late-registering runs; \ + re-checking" + ); } Ok(Verdict::Red(msgs)) => fail(&format!( "ci-gate: refusing to publish {sha} — {} workflow run(s) on this \ @@ -432,7 +470,7 @@ pub(crate) fn run(timeout_secs: u64, poll_secs: u64, grace_secs: u64) { last_wait = pending; } Ok(Verdict::NoOtherCi) => { - if start.elapsed() >= grace { + if !within_grace(start.elapsed(), grace) { fail(&format!( "ci-gate: no workflow run besides this release workflow exists \ for {sha} after {grace_secs}s — refusing to publish a commit \ @@ -755,6 +793,29 @@ mod tests { assert!(curl_config("hàs-utf8").is_err()); } + #[test] + fn curl_config_bounds_each_transfer() { + let cfg = curl_config("ghs_abc123").unwrap(); + assert!(cfg.contains("connect-timeout = 30\n"), "{cfg}"); + assert!(cfg.contains("max-time = 120\n"), "{cfg}"); + } + + #[test] + fn grace_defers_early_snapshots_and_ends_exactly_on_time() { + assert!(within_grace(Duration::from_secs(0), Duration::from_secs(120))); + assert!(within_grace(Duration::from_secs(119), Duration::from_secs(120))); + assert!(!within_grace(Duration::from_secs(120), Duration::from_secs(120))); + assert!(!within_grace(Duration::from_secs(121), Duration::from_secs(120))); + assert!(!within_grace(Duration::from_secs(0), Duration::from_secs(0))); + } + + #[test] + fn zero_poll_interval_is_refused() { + assert!(validate_poll_secs(0).is_err()); + assert!(validate_poll_secs(1).is_ok()); + assert!(validate_poll_secs(30).is_ok()); + } + #[test] fn env_inputs_are_validated() { assert!(validate_repo("rainlanguage/rain.string").is_ok()); diff --git a/rainix-static/src/main.rs b/rainix-static/src/main.rs index 18cd490..f095ae9 100644 --- a/rainix-static/src/main.rs +++ b/rainix-static/src/main.rs @@ -45,11 +45,14 @@ // workflow runs for GITHUB_SHA — every trigger event, excluding every // run of the release workflow this gate runs inside (resolved from // GITHUB_RUN_ID) — and exit 0 only when all of them completed green -// (success / skipped / neutral). A failed, cancelled, or timed-out run -// fails the gate immediately, naming it; a commit with NO other workflow -// runs after --grace-secs (default 120) fails closed — nothing tested -// the commit; hitting --timeout-secs (default 7200, polling every -// --poll-secs, default 30) fails naming what was still pending. Needs +// (success / skipped / neutral) and --grace-secs (default 120) has +// elapsed: run registration lags the trigger, so an earlier all-green +// snapshot is re-checked until the grace passes. A failed, cancelled, +// or timed-out run fails the gate immediately, naming it; a commit with +// NO other workflow runs past the same grace fails closed — nothing +// tested the commit; hitting --timeout-secs (default 7200, polling +// every --poll-secs, default 30, minimum 1) fails naming what was still +// pending. Needs // GITHUB_REPOSITORY / GITHUB_SHA / GITHUB_RUN_ID / GITHUB_TOKEN (the // token needs `actions: read`); runs where curl is on PATH. // soldeer-gate --package [--github-output ] From 022c32c614baa2925c1a715aee8b94ae74abbb97 Mon Sep 17 00:00:00 2001 From: David Meister Date: Tue, 25 Aug 2026 11:04:03 +0000 Subject: [PATCH 05/10] style: rustfmt the within_grace boundary test Co-Authored-By: Claude Fable 5 --- rainix-static/src/ci_gate.rs | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/rainix-static/src/ci_gate.rs b/rainix-static/src/ci_gate.rs index d60aa1d..e57f804 100644 --- a/rainix-static/src/ci_gate.rs +++ b/rainix-static/src/ci_gate.rs @@ -802,11 +802,26 @@ mod tests { #[test] fn grace_defers_early_snapshots_and_ends_exactly_on_time() { - assert!(within_grace(Duration::from_secs(0), Duration::from_secs(120))); - assert!(within_grace(Duration::from_secs(119), Duration::from_secs(120))); - assert!(!within_grace(Duration::from_secs(120), Duration::from_secs(120))); - assert!(!within_grace(Duration::from_secs(121), Duration::from_secs(120))); - assert!(!within_grace(Duration::from_secs(0), Duration::from_secs(0))); + assert!(within_grace( + Duration::from_secs(0), + Duration::from_secs(120) + )); + assert!(within_grace( + Duration::from_secs(119), + Duration::from_secs(120) + )); + assert!(!within_grace( + Duration::from_secs(120), + Duration::from_secs(120) + )); + assert!(!within_grace( + Duration::from_secs(121), + Duration::from_secs(120) + )); + assert!(!within_grace( + Duration::from_secs(0), + Duration::from_secs(0) + )); } #[test] From cf3b6826ef9d5633524b46d4b5624e72493b0fe1 Mon Sep 17 00:00:00 2001 From: David Meister Date: Tue, 25 Aug 2026 11:04:59 +0000 Subject: [PATCH 06/10] ci: bump RAINIX_SHA to 022c32c614baa2925c1a715aee8b94ae74abbb97 (ci-gate review fixes) Co-Authored-By: Claude Fable 5 --- .github/workflows/rainix-autopublish.yaml | 2 +- .github/workflows/rainix-copy-artifacts.yaml | 2 +- .github/workflows/rainix-manual-sol-artifacts.yaml | 2 +- .github/workflows/rainix-manual-sol-verify.yaml | 2 +- .github/workflows/rainix-rs-static.yaml | 2 +- .github/workflows/rainix-rs-test.yaml | 2 +- .github/workflows/rainix-rs-wasm-test.yaml | 2 +- .github/workflows/rainix-rs-wasm.yaml | 2 +- .github/workflows/rainix-sol-legal.yaml | 2 +- .github/workflows/rainix-sol-static.yaml | 2 +- .github/workflows/rainix-sol-test.yaml | 2 +- .github/workflows/rainix-subgraph-test.yaml | 2 +- .github/workflows/rainix-tag-release.yaml | 2 +- 13 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/rainix-autopublish.yaml b/.github/workflows/rainix-autopublish.yaml index ad3f24e..d72e778 100644 --- a/.github/workflows/rainix-autopublish.yaml +++ b/.github/workflows/rainix-autopublish.yaml @@ -50,7 +50,7 @@ on: SOLDEER_API_TOKEN: required: false env: - RAINIX_SHA: 2455992d1a8300447daae66979a50a47cb2b9756 + RAINIX_SHA: 022c32c614baa2925c1a715aee8b94ae74abbb97 jobs: release: if: ${{ !startsWith(github.event.head_commit.message, 'Package Release') }} diff --git a/.github/workflows/rainix-copy-artifacts.yaml b/.github/workflows/rainix-copy-artifacts.yaml index 7a64b05..e30bea9 100644 --- a/.github/workflows/rainix-copy-artifacts.yaml +++ b/.github/workflows/rainix-copy-artifacts.yaml @@ -2,7 +2,7 @@ name: rainix-copy-artifacts on: workflow_call: env: - RAINIX_SHA: 2455992d1a8300447daae66979a50a47cb2b9756 + RAINIX_SHA: 022c32c614baa2925c1a715aee8b94ae74abbb97 jobs: copy-artifacts: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-manual-sol-artifacts.yaml b/.github/workflows/rainix-manual-sol-artifacts.yaml index 2f10cb5..a80f906 100644 --- a/.github/workflows/rainix-manual-sol-artifacts.yaml +++ b/.github/workflows/rainix-manual-sol-artifacts.yaml @@ -85,7 +85,7 @@ on: CI_DEPLOY_FLARE_ETHERSCAN_API_KEY: required: false env: - RAINIX_SHA: 2455992d1a8300447daae66979a50a47cb2b9756 + RAINIX_SHA: 022c32c614baa2925c1a715aee8b94ae74abbb97 jobs: deploy: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-manual-sol-verify.yaml b/.github/workflows/rainix-manual-sol-verify.yaml index 01b7c2a..7481ed6 100644 --- a/.github/workflows/rainix-manual-sol-verify.yaml +++ b/.github/workflows/rainix-manual-sol-verify.yaml @@ -65,7 +65,7 @@ on: CI_DEPLOY_FLARE_ETHERSCAN_API_KEY: required: false env: - RAINIX_SHA: 2455992d1a8300447daae66979a50a47cb2b9756 + RAINIX_SHA: 022c32c614baa2925c1a715aee8b94ae74abbb97 jobs: verify: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-rs-static.yaml b/.github/workflows/rainix-rs-static.yaml index ba45a19..2ade175 100644 --- a/.github/workflows/rainix-rs-static.yaml +++ b/.github/workflows/rainix-rs-static.yaml @@ -2,7 +2,7 @@ name: rainix-rs-static on: workflow_call: env: - RAINIX_SHA: 2455992d1a8300447daae66979a50a47cb2b9756 + RAINIX_SHA: 022c32c614baa2925c1a715aee8b94ae74abbb97 jobs: rs-static: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-rs-test.yaml b/.github/workflows/rainix-rs-test.yaml index eed658a..3d00321 100644 --- a/.github/workflows/rainix-rs-test.yaml +++ b/.github/workflows/rainix-rs-test.yaml @@ -2,7 +2,7 @@ name: rainix-rs-test on: workflow_call: env: - RAINIX_SHA: 2455992d1a8300447daae66979a50a47cb2b9756 + RAINIX_SHA: 022c32c614baa2925c1a715aee8b94ae74abbb97 jobs: rs-test: strategy: diff --git a/.github/workflows/rainix-rs-wasm-test.yaml b/.github/workflows/rainix-rs-wasm-test.yaml index edecff4..24f24ac 100644 --- a/.github/workflows/rainix-rs-wasm-test.yaml +++ b/.github/workflows/rainix-rs-wasm-test.yaml @@ -2,7 +2,7 @@ name: rainix-rs-wasm-test on: workflow_call: env: - RAINIX_SHA: 2455992d1a8300447daae66979a50a47cb2b9756 + RAINIX_SHA: 022c32c614baa2925c1a715aee8b94ae74abbb97 jobs: rs-wasm-test: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-rs-wasm.yaml b/.github/workflows/rainix-rs-wasm.yaml index 6a54a54..15f6ea2 100644 --- a/.github/workflows/rainix-rs-wasm.yaml +++ b/.github/workflows/rainix-rs-wasm.yaml @@ -2,7 +2,7 @@ name: rainix-rs-wasm on: workflow_call: env: - RAINIX_SHA: 2455992d1a8300447daae66979a50a47cb2b9756 + RAINIX_SHA: 022c32c614baa2925c1a715aee8b94ae74abbb97 jobs: rs-wasm: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-sol-legal.yaml b/.github/workflows/rainix-sol-legal.yaml index a50f921..b6c87e9 100644 --- a/.github/workflows/rainix-sol-legal.yaml +++ b/.github/workflows/rainix-sol-legal.yaml @@ -2,7 +2,7 @@ name: rainix-sol-legal on: workflow_call: env: - RAINIX_SHA: 2455992d1a8300447daae66979a50a47cb2b9756 + RAINIX_SHA: 022c32c614baa2925c1a715aee8b94ae74abbb97 jobs: legal: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-sol-static.yaml b/.github/workflows/rainix-sol-static.yaml index 91e1b34..f0b0fbe 100644 --- a/.github/workflows/rainix-sol-static.yaml +++ b/.github/workflows/rainix-sol-static.yaml @@ -2,7 +2,7 @@ name: rainix-sol-static on: workflow_call: env: - RAINIX_SHA: 2455992d1a8300447daae66979a50a47cb2b9756 + RAINIX_SHA: 022c32c614baa2925c1a715aee8b94ae74abbb97 jobs: static: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-sol-test.yaml b/.github/workflows/rainix-sol-test.yaml index da75d5c..c6a2ead 100644 --- a/.github/workflows/rainix-sol-test.yaml +++ b/.github/workflows/rainix-sol-test.yaml @@ -25,7 +25,7 @@ on: RPC_URL_SEPOLIA_FORK: required: false env: - RAINIX_SHA: 2455992d1a8300447daae66979a50a47cb2b9756 + RAINIX_SHA: 022c32c614baa2925c1a715aee8b94ae74abbb97 jobs: test: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-subgraph-test.yaml b/.github/workflows/rainix-subgraph-test.yaml index 1cc723c..81e8cbc 100644 --- a/.github/workflows/rainix-subgraph-test.yaml +++ b/.github/workflows/rainix-subgraph-test.yaml @@ -2,7 +2,7 @@ name: rainix-subgraph-test on: workflow_call: env: - RAINIX_SHA: 2455992d1a8300447daae66979a50a47cb2b9756 + RAINIX_SHA: 022c32c614baa2925c1a715aee8b94ae74abbb97 jobs: subgraph-test: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-tag-release.yaml b/.github/workflows/rainix-tag-release.yaml index 91c5119..764f699 100644 --- a/.github/workflows/rainix-tag-release.yaml +++ b/.github/workflows/rainix-tag-release.yaml @@ -124,7 +124,7 @@ on: RPC_URL_SEPOLIA_FORK: required: false env: - RAINIX_SHA: 2455992d1a8300447daae66979a50a47cb2b9756 + RAINIX_SHA: 022c32c614baa2925c1a715aee8b94ae74abbb97 jobs: # The release tag must point at a commit already merged to the release branch. # `on: push: tags` fires for ANY tag, including one cut from an unmerged branch; From 60ba051bae06c184670289de32653f175a64dfaf Mon Sep 17 00:00:00 2001 From: David Meister Date: Tue, 25 Aug 2026 11:49:33 +0000 Subject: [PATCH 07/10] refactor(ci-gate): extract the poll loop's per-snapshot decision into decide() The run() loop resolved each snapshot's verdict (Pass/NoOtherCi/red/pending, including the discovery-grace deferral) inline, so that glue had no unit-level sequence coverage. decide(verdict, elapsed, grace) -> Decision is now the pure per-snapshot decision; run() only performs the side effects for each Decision. Behavior is unchanged. Four sequence tests drive verdict()+decide() across successive snapshots: a late-registering failure inside the grace fails, a late pending run defers the pass until it resolves (grace expiry never converts pending into a pass), green through grace expiry passes, and no-other-CI defers within the grace then fails closed. Co-Authored-By: Claude Fable 5 --- rainix-static/src/ci_gate.rs | 200 +++++++++++++++++++++++++++++------ 1 file changed, 169 insertions(+), 31 deletions(-) diff --git a/rainix-static/src/ci_gate.rs b/rainix-static/src/ci_gate.rs index e57f804..4d3e337 100644 --- a/rainix-static/src/ci_gate.rs +++ b/rainix-static/src/ci_gate.rs @@ -259,6 +259,52 @@ fn within_grace(elapsed: Duration, grace: Duration) -> bool { elapsed < grace } +/// What the poll loop does with one snapshot's verdict. The discovery grace +/// applies to the two verdicts an early snapshot can misreport — an all-green +/// set may be missing late-registering runs, and an empty set may only mean +/// registration lag — so both defer until the grace has elapsed. Red and +/// pending need no grace: a failed run forbids the publish whenever it is +/// seen, and a pending run is waited on regardless of elapsed time. +#[derive(Debug, PartialEq)] +enum Decision { + /// Exit 0: every other run on the commit is green and the grace elapsed. + Publish { green: usize }, + /// Every observed run is green but the grace is still running; a + /// late-registering run could change the verdict, so re-check. + DeferGreen { green: usize }, + /// Fail now, naming the red runs. + FailRed(Vec), + /// Keep polling; these runs are still pending. + KeepWaiting(Vec), + /// No other CI observed yet, but runs may not have registered; re-check. + DeferNoOtherCi, + /// Fail closed: nothing else ran on the commit and the grace elapsed. + FailNoOtherCi, +} + +/// Resolve one snapshot's verdict against the discovery grace: the poll +/// loop's per-snapshot decision, minus its side effects. +fn decide(verdict: Verdict, elapsed: Duration, grace: Duration) -> Decision { + match verdict { + Verdict::Pass { green } => { + if within_grace(elapsed, grace) { + Decision::DeferGreen { green } + } else { + Decision::Publish { green } + } + } + Verdict::Red(msgs) => Decision::FailRed(msgs), + Verdict::Wait(pending) => Decision::KeepWaiting(pending), + Verdict::NoOtherCi => { + if within_grace(elapsed, grace) { + Decision::DeferNoOtherCi + } else { + Decision::FailNoOtherCi + } + } + } +} + /// A zero poll interval turns every retry/wait into a busy loop against the /// API; refuse it. fn validate_poll_secs(secs: u64) -> Result<(), String> { @@ -442,48 +488,44 @@ pub(crate) fn run(timeout_secs: u64, poll_secs: u64, grace_secs: u64) { } Ok(runs) => match verdict(&runs, own_workflow_id) { Err(m) => fail(&format!("ci-gate: {m}")), - Ok(Verdict::Pass { green }) => { - if !within_grace(start.elapsed(), grace) { + Ok(v) => match decide(v, start.elapsed(), grace) { + Decision::Publish { green } => { println!( "ci-gate: all {green} other workflow run(s) on {sha} completed green" ); return; } - eprintln!( + Decision::DeferGreen { green } => eprintln!( "ci-gate: all {green} observed run(s) on {sha} are green, but still \ within the {grace_secs}s grace period for late-registering runs; \ re-checking" - ); - } - Ok(Verdict::Red(msgs)) => fail(&format!( - "ci-gate: refusing to publish {sha} — {} workflow run(s) on this \ - commit failed: {}", - msgs.len(), - msgs.join("; ") - )), - Ok(Verdict::Wait(pending)) => { - eprintln!( - "ci-gate: waiting on {} run(s): {}", - pending.len(), - pending.join("; ") - ); - last_wait = pending; - } - Ok(Verdict::NoOtherCi) => { - if !within_grace(start.elapsed(), grace) { - fail(&format!( - "ci-gate: no workflow run besides this release workflow exists \ - for {sha} after {grace_secs}s — refusing to publish a commit \ - nothing has tested. Add a workflow that runs the repo's \ - checks on push (every rainix consumer has one), then re-run \ - this job." - )); + ), + Decision::FailRed(msgs) => fail(&format!( + "ci-gate: refusing to publish {sha} — {} workflow run(s) on this \ + commit failed: {}", + msgs.len(), + msgs.join("; ") + )), + Decision::KeepWaiting(pending) => { + eprintln!( + "ci-gate: waiting on {} run(s): {}", + pending.len(), + pending.join("; ") + ); + last_wait = pending; } - eprintln!( + Decision::FailNoOtherCi => fail(&format!( + "ci-gate: no workflow run besides this release workflow exists \ + for {sha} after {grace_secs}s — refusing to publish a commit \ + nothing has tested. Add a workflow that runs the repo's \ + checks on push (every rainix consumer has one), then re-run \ + this job." + )), + Decision::DeferNoOtherCi => eprintln!( "ci-gate: no other workflow runs for {sha} yet; \ within the {grace_secs}s grace period for them to appear" - ); - } + ), + }, }, } if start.elapsed() >= deadline { @@ -824,6 +866,102 @@ mod tests { )); } + /// One poll-loop snapshot, as the loop resolves it: verdict over the + /// observed runs, then the grace-aware decision. + fn snapshot(runs: &[Run], own: u64, elapsed_secs: u64, grace_secs: u64) -> Decision { + decide( + verdict(runs, own).unwrap(), + Duration::from_secs(elapsed_secs), + Duration::from_secs(grace_secs), + ) + } + + #[test] + fn sequence_late_registering_failure_within_grace_fails() { + // t=10s: only the fast workflow has registered, already green. The + // grace defers the pass — this snapshot must NOT publish. + let first = vec![run_with(1, "completed", Some("success"))]; + assert_eq!( + snapshot(&first, 9, 10, 120), + Decision::DeferGreen { green: 1 } + ); + // t=40s: a late-registering run appears, already failed. The deferral + // is exactly what lets the gate see it; the failure names the run. + let second = vec![ + run_with(1, "completed", Some("success")), + run_with(2, "completed", Some("failure")), + ]; + match snapshot(&second, 9, 40, 120) { + Decision::FailRed(msgs) => { + assert_eq!(msgs.len(), 1); + assert!(msgs[0].contains("wf-2"), "{}", msgs[0]); + } + d => panic!("expected FailRed, got {d:?}"), + } + } + + #[test] + fn sequence_late_pending_run_defers_pass_until_it_resolves() { + // t=10s: all observed runs green, within grace — defer. + let first = vec![run_with(1, "completed", Some("success"))]; + assert_eq!( + snapshot(&first, 9, 10, 120), + Decision::DeferGreen { green: 1 } + ); + // t=40s: a late-registering run is still in progress — wait on it. + let second = vec![ + run_with(1, "completed", Some("success")), + run_with(2, "in_progress", None), + ]; + assert!(matches!( + snapshot(&second, 9, 40, 120), + Decision::KeepWaiting(_) + )); + // t=200s: grace long over, but the run is STILL pending — expiry of + // the grace never converts a pending run into a pass. + assert!(matches!( + snapshot(&second, 9, 200, 120), + Decision::KeepWaiting(_) + )); + // t=230s: the pending run resolves green — only now does it publish. + let resolved = vec![ + run_with(1, "completed", Some("success")), + run_with(2, "completed", Some("success")), + ]; + assert_eq!( + snapshot(&resolved, 9, 230, 120), + Decision::Publish { green: 2 } + ); + } + + #[test] + fn sequence_green_through_grace_expiry_passes() { + let runs = vec![ + run_with(1, "completed", Some("success")), + run_with(2, "completed", Some("skipped")), + ]; + // Green snapshots inside the grace defer, including just before it. + assert_eq!( + snapshot(&runs, 9, 0, 120), + Decision::DeferGreen { green: 2 } + ); + assert_eq!( + snapshot(&runs, 9, 119, 120), + Decision::DeferGreen { green: 2 } + ); + // At the boundary the grace is over: no late arrivals came, publish. + assert_eq!(snapshot(&runs, 9, 120, 120), Decision::Publish { green: 2 }); + } + + #[test] + fn sequence_no_other_ci_defers_within_grace_then_fails_closed() { + // Only the release workflow's own run exists. Within grace this may + // be registration lag — re-check; past it, fail closed. + let only_own = vec![run_with(9, "in_progress", None)]; + assert_eq!(snapshot(&only_own, 9, 10, 120), Decision::DeferNoOtherCi); + assert_eq!(snapshot(&only_own, 9, 120, 120), Decision::FailNoOtherCi); + } + #[test] fn zero_poll_interval_is_refused() { assert!(validate_poll_secs(0).is_err()); From 9744637fbbd5271ada93038e503f4242d465b11f Mon Sep 17 00:00:00 2001 From: David Meister Date: Tue, 25 Aug 2026 12:03:47 +0000 Subject: [PATCH 08/10] ci: bump RAINIX_SHA to 60ba051bae06c184670289de32653f175a64dfaf (ci-gate decide extraction) Co-Authored-By: Claude Fable 5 --- .github/workflows/rainix-autopublish.yaml | 2 +- .github/workflows/rainix-copy-artifacts.yaml | 2 +- .github/workflows/rainix-manual-sol-artifacts.yaml | 2 +- .github/workflows/rainix-manual-sol-verify.yaml | 2 +- .github/workflows/rainix-rs-static.yaml | 2 +- .github/workflows/rainix-rs-test.yaml | 2 +- .github/workflows/rainix-rs-wasm-test.yaml | 2 +- .github/workflows/rainix-rs-wasm.yaml | 2 +- .github/workflows/rainix-sol-legal.yaml | 2 +- .github/workflows/rainix-sol-static.yaml | 2 +- .github/workflows/rainix-sol-test.yaml | 2 +- .github/workflows/rainix-subgraph-test.yaml | 2 +- .github/workflows/rainix-tag-release.yaml | 2 +- 13 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/rainix-autopublish.yaml b/.github/workflows/rainix-autopublish.yaml index d72e778..d98a6ed 100644 --- a/.github/workflows/rainix-autopublish.yaml +++ b/.github/workflows/rainix-autopublish.yaml @@ -50,7 +50,7 @@ on: SOLDEER_API_TOKEN: required: false env: - RAINIX_SHA: 022c32c614baa2925c1a715aee8b94ae74abbb97 + RAINIX_SHA: 60ba051bae06c184670289de32653f175a64dfaf jobs: release: if: ${{ !startsWith(github.event.head_commit.message, 'Package Release') }} diff --git a/.github/workflows/rainix-copy-artifacts.yaml b/.github/workflows/rainix-copy-artifacts.yaml index e30bea9..7e1299b 100644 --- a/.github/workflows/rainix-copy-artifacts.yaml +++ b/.github/workflows/rainix-copy-artifacts.yaml @@ -2,7 +2,7 @@ name: rainix-copy-artifacts on: workflow_call: env: - RAINIX_SHA: 022c32c614baa2925c1a715aee8b94ae74abbb97 + RAINIX_SHA: 60ba051bae06c184670289de32653f175a64dfaf jobs: copy-artifacts: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-manual-sol-artifacts.yaml b/.github/workflows/rainix-manual-sol-artifacts.yaml index a80f906..133626a 100644 --- a/.github/workflows/rainix-manual-sol-artifacts.yaml +++ b/.github/workflows/rainix-manual-sol-artifacts.yaml @@ -85,7 +85,7 @@ on: CI_DEPLOY_FLARE_ETHERSCAN_API_KEY: required: false env: - RAINIX_SHA: 022c32c614baa2925c1a715aee8b94ae74abbb97 + RAINIX_SHA: 60ba051bae06c184670289de32653f175a64dfaf jobs: deploy: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-manual-sol-verify.yaml b/.github/workflows/rainix-manual-sol-verify.yaml index 7481ed6..db6f336 100644 --- a/.github/workflows/rainix-manual-sol-verify.yaml +++ b/.github/workflows/rainix-manual-sol-verify.yaml @@ -65,7 +65,7 @@ on: CI_DEPLOY_FLARE_ETHERSCAN_API_KEY: required: false env: - RAINIX_SHA: 022c32c614baa2925c1a715aee8b94ae74abbb97 + RAINIX_SHA: 60ba051bae06c184670289de32653f175a64dfaf jobs: verify: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-rs-static.yaml b/.github/workflows/rainix-rs-static.yaml index 2ade175..74ce1ce 100644 --- a/.github/workflows/rainix-rs-static.yaml +++ b/.github/workflows/rainix-rs-static.yaml @@ -2,7 +2,7 @@ name: rainix-rs-static on: workflow_call: env: - RAINIX_SHA: 022c32c614baa2925c1a715aee8b94ae74abbb97 + RAINIX_SHA: 60ba051bae06c184670289de32653f175a64dfaf jobs: rs-static: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-rs-test.yaml b/.github/workflows/rainix-rs-test.yaml index 3d00321..8c7f20e 100644 --- a/.github/workflows/rainix-rs-test.yaml +++ b/.github/workflows/rainix-rs-test.yaml @@ -2,7 +2,7 @@ name: rainix-rs-test on: workflow_call: env: - RAINIX_SHA: 022c32c614baa2925c1a715aee8b94ae74abbb97 + RAINIX_SHA: 60ba051bae06c184670289de32653f175a64dfaf jobs: rs-test: strategy: diff --git a/.github/workflows/rainix-rs-wasm-test.yaml b/.github/workflows/rainix-rs-wasm-test.yaml index 24f24ac..eac5152 100644 --- a/.github/workflows/rainix-rs-wasm-test.yaml +++ b/.github/workflows/rainix-rs-wasm-test.yaml @@ -2,7 +2,7 @@ name: rainix-rs-wasm-test on: workflow_call: env: - RAINIX_SHA: 022c32c614baa2925c1a715aee8b94ae74abbb97 + RAINIX_SHA: 60ba051bae06c184670289de32653f175a64dfaf jobs: rs-wasm-test: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-rs-wasm.yaml b/.github/workflows/rainix-rs-wasm.yaml index 15f6ea2..e35d391 100644 --- a/.github/workflows/rainix-rs-wasm.yaml +++ b/.github/workflows/rainix-rs-wasm.yaml @@ -2,7 +2,7 @@ name: rainix-rs-wasm on: workflow_call: env: - RAINIX_SHA: 022c32c614baa2925c1a715aee8b94ae74abbb97 + RAINIX_SHA: 60ba051bae06c184670289de32653f175a64dfaf jobs: rs-wasm: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-sol-legal.yaml b/.github/workflows/rainix-sol-legal.yaml index b6c87e9..ad4c717 100644 --- a/.github/workflows/rainix-sol-legal.yaml +++ b/.github/workflows/rainix-sol-legal.yaml @@ -2,7 +2,7 @@ name: rainix-sol-legal on: workflow_call: env: - RAINIX_SHA: 022c32c614baa2925c1a715aee8b94ae74abbb97 + RAINIX_SHA: 60ba051bae06c184670289de32653f175a64dfaf jobs: legal: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-sol-static.yaml b/.github/workflows/rainix-sol-static.yaml index f0b0fbe..2d05a41 100644 --- a/.github/workflows/rainix-sol-static.yaml +++ b/.github/workflows/rainix-sol-static.yaml @@ -2,7 +2,7 @@ name: rainix-sol-static on: workflow_call: env: - RAINIX_SHA: 022c32c614baa2925c1a715aee8b94ae74abbb97 + RAINIX_SHA: 60ba051bae06c184670289de32653f175a64dfaf jobs: static: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-sol-test.yaml b/.github/workflows/rainix-sol-test.yaml index c6a2ead..5c6646b 100644 --- a/.github/workflows/rainix-sol-test.yaml +++ b/.github/workflows/rainix-sol-test.yaml @@ -25,7 +25,7 @@ on: RPC_URL_SEPOLIA_FORK: required: false env: - RAINIX_SHA: 022c32c614baa2925c1a715aee8b94ae74abbb97 + RAINIX_SHA: 60ba051bae06c184670289de32653f175a64dfaf jobs: test: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-subgraph-test.yaml b/.github/workflows/rainix-subgraph-test.yaml index 81e8cbc..3ee73cb 100644 --- a/.github/workflows/rainix-subgraph-test.yaml +++ b/.github/workflows/rainix-subgraph-test.yaml @@ -2,7 +2,7 @@ name: rainix-subgraph-test on: workflow_call: env: - RAINIX_SHA: 022c32c614baa2925c1a715aee8b94ae74abbb97 + RAINIX_SHA: 60ba051bae06c184670289de32653f175a64dfaf jobs: subgraph-test: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-tag-release.yaml b/.github/workflows/rainix-tag-release.yaml index 764f699..7f73d96 100644 --- a/.github/workflows/rainix-tag-release.yaml +++ b/.github/workflows/rainix-tag-release.yaml @@ -124,7 +124,7 @@ on: RPC_URL_SEPOLIA_FORK: required: false env: - RAINIX_SHA: 022c32c614baa2925c1a715aee8b94ae74abbb97 + RAINIX_SHA: 60ba051bae06c184670289de32653f175a64dfaf jobs: # The release tag must point at a commit already merged to the release branch. # `on: push: tags` fires for ANY tag, including one cut from an unmerged branch; From 64c884b9215f1391549926125de18c3a1b6330b6 Mon Sep 17 00:00:00 2001 From: David Meister Date: Tue, 25 Aug 2026 12:51:47 +0000 Subject: [PATCH 09/10] docs: cut comment restatement and duplication from the ci-gate change Enum variant docs restated their names; the gate rationale appeared on three surfaces (module doc, within_grace, Decision) and the usage entry in main.rs duplicated the module doc; test narration editorialized what the assertions already state. Each fact now lives once. Co-Authored-By: Claude Fable 5 --- .github/workflows/rainix-autopublish.yaml | 35 ++++------- rainix-static/src/ci_gate.rs | 73 +++++++---------------- rainix-static/src/main.rs | 21 +++---- 3 files changed, 39 insertions(+), 90 deletions(-) diff --git a/.github/workflows/rainix-autopublish.yaml b/.github/workflows/rainix-autopublish.yaml index d98a6ed..291fc85 100644 --- a/.github/workflows/rainix-autopublish.yaml +++ b/.github/workflows/rainix-autopublish.yaml @@ -72,12 +72,9 @@ jobs: permissions: id-token: write contents: write - # The commit-CI gate below reads this repository's workflow runs. A - # caller job with no `permissions:` block of its own needs nothing — - # this block narrows the token the caller hands over. A caller job that - # DOES set an explicit block on the job that `uses:` this workflow must - # include `actions: read` in it, because a called workflow can only - # narrow the caller's grant, never widen it. + # The commit-CI gate reads this repository's workflow runs. A caller + # job with an explicit `permissions:` block on its `uses:` job must + # include `actions: read` there; a caller with no block needs nothing. actions: read steps: # This job needs a deploy-key (ssh-key) checkout, so it runs the shared @@ -228,25 +225,13 @@ jobs: rainix-static soldeer-gate \ --package "$SOLDEER_PACKAGE" \ --github-output "$GITHUB_OUTPUT" - # Publish gate on this commit's own CI (rainlanguage/rainix#326). The - # caller's test workflows trigger on the same push as this one and race - # it — nothing else orders publish after green — so before anything - # bumps, tags, or publishes, wait for every OTHER workflow run on - # github.sha (all trigger events, excluding every run of the caller's - # own release workflow, which would deadlock against itself) and require - # them all to have completed green, re-checking any all-green set seen - # before the discovery grace elapses (run registration lags the push, so - # an early snapshot could miss a late-registering run). This inherits - # whatever the repo runs - # on push — the full rainix-sol/rs matrix, not a re-run subset — at zero - # extra compute. A failed/cancelled run, a timeout, or a commit with NO - # other CI at all (fail-closed: nothing tested it) each fail the gate - # loudly, nothing publishes, and the next push — or a re-run of this job - # once the commit's CI is green — retries for free. Gated on the change - # outputs so a no-op push short-circuits without waiting. Runs via `nix - # run` (no dev shell): the wrapped binary carries its own curl + CA - # bundle, so soldeer/npm/cargo callers all pay only the small - # rainix-static closure. + # Publish gate on this commit's own CI (rainlanguage/rainix#326): the + # caller's test workflows race this run on the same push, so nothing + # bumps, tags, or publishes until every other workflow run on + # github.sha is green (semantics: rainix-static/src/ci_gate.rs). Gated + # on the change outputs so a no-op push skips the wait. `nix run`, no + # dev shell: the wrapped binary carries its own curl + CA bundle, so + # all callers pay only the small rainix-static closure. - name: Gate on commit CI if: ${{ steps.cargo.outputs.changed == 'true' || steps.npm.outputs.changed == 'true' || steps.soldeer.outputs.changed == 'true' }} env: diff --git a/rainix-static/src/ci_gate.rs b/rainix-static/src/ci_gate.rs index 4d3e337..8751889 100644 --- a/rainix-static/src/ci_gate.rs +++ b/rainix-static/src/ci_gate.rs @@ -12,9 +12,9 @@ //! observed earlier is re-checked until the grace passes. A failed, cancelled //! or timed-out run is a loud immediate error naming it; a commit with NO //! other workflow runs after the same grace is a loud error too (fail-closed: -//! every rainix -//! consumer runs push-triggered CI, so "nothing else ran" means nothing tested -//! the commit, not that there was nothing to wait for). Transient API failures +//! every rainix consumer runs push-triggered CI, so "nothing else ran" means +//! nothing tested the commit, not that there was nothing to wait for). +//! Transient API failures //! (5xx, rate limits, transport) retry until the deadline; a token that cannot //! read Actions runs is a fatal error naming the `actions: read` grant the //! caller must carry. @@ -73,18 +73,14 @@ fn classify(status: &str, conclusion: Option<&str>) -> Result } } -/// The gate's decision over one snapshot of the commit's runs. +/// The gate's reading of one snapshot of the commit's runs. Red wins over +/// pending: one failed run already forbids the publish, so the gate does not +/// wait for the rest. #[derive(Debug, PartialEq)] enum Verdict { - /// Every other-workflow run on the commit completed green. Pass { green: usize }, - /// At least one completed red — the strings name them. Red wins over - /// pending: one failed run already forbids the publish, so the gate does - /// not wait for the rest. Red(Vec), - /// Still waiting on these runs. Wait(Vec), - /// No runs besides the release workflow's own exist (yet). NoOtherCi, } @@ -250,40 +246,27 @@ fn curl_config(token: &str) -> Result { )) } -/// Whether the discovery grace period is still running. Run registration lags -/// the triggering event, so early snapshots are untrustworthy in both -/// directions: "no other runs" may mean runs have not registered yet, and an -/// all-green set may still be missing late-registering runs. At exactly the -/// grace boundary the period is over. +/// Whether the discovery grace period is still running. At exactly the grace +/// boundary the period is over. fn within_grace(elapsed: Duration, grace: Duration) -> bool { elapsed < grace } -/// What the poll loop does with one snapshot's verdict. The discovery grace -/// applies to the two verdicts an early snapshot can misreport — an all-green -/// set may be missing late-registering runs, and an empty set may only mean -/// registration lag — so both defer until the grace has elapsed. Red and -/// pending need no grace: a failed run forbids the publish whenever it is -/// seen, and a pending run is waited on regardless of elapsed time. +/// What the poll loop does with one snapshot's verdict. Pass and NoOtherCi +/// are the two verdicts registration lag can misreport, so both defer within +/// the discovery grace; red fails and pending waits regardless of elapsed +/// time. #[derive(Debug, PartialEq)] enum Decision { - /// Exit 0: every other run on the commit is green and the grace elapsed. Publish { green: usize }, - /// Every observed run is green but the grace is still running; a - /// late-registering run could change the verdict, so re-check. DeferGreen { green: usize }, - /// Fail now, naming the red runs. FailRed(Vec), - /// Keep polling; these runs are still pending. KeepWaiting(Vec), - /// No other CI observed yet, but runs may not have registered; re-check. DeferNoOtherCi, - /// Fail closed: nothing else ran on the commit and the grace elapsed. FailNoOtherCi, } -/// Resolve one snapshot's verdict against the discovery grace: the poll -/// loop's per-snapshot decision, minus its side effects. +/// Resolve one snapshot's verdict against the discovery grace. fn decide(verdict: Verdict, elapsed: Duration, grace: Duration) -> Decision { match verdict { Verdict::Pass { green } => { @@ -431,10 +414,8 @@ fn list_runs(api: &str, repo: &str, sha: &str, token: &str) -> Result, } } -/// Run the gate: poll until every other run on GITHUB_SHA is green with the -/// discovery grace elapsed (exit 0), any is red (loud failure), no other CI -/// exists past the grace period (loud, fail-closed), or the deadline passes -/// (loud, naming what was still pending). +/// Poll GITHUB_SHA's runs to a `Decision`, or fail loudly at the deadline +/// naming what was still pending. pub(crate) fn run(timeout_secs: u64, poll_secs: u64, grace_secs: u64) { let env = |k: &str| { std::env::var(k) @@ -646,8 +627,6 @@ mod tests { #[test] fn verdict_red_wins_over_pending() { - // One red already forbids the publish; the gate must not keep waiting - // on the rest first. let runs = vec![ run_with(1, "completed", Some("cancelled")), run_with(2, "in_progress", None), @@ -866,8 +845,6 @@ mod tests { )); } - /// One poll-loop snapshot, as the loop resolves it: verdict over the - /// observed runs, then the grace-aware decision. fn snapshot(runs: &[Run], own: u64, elapsed_secs: u64, grace_secs: u64) -> Decision { decide( verdict(runs, own).unwrap(), @@ -878,15 +855,13 @@ mod tests { #[test] fn sequence_late_registering_failure_within_grace_fails() { - // t=10s: only the fast workflow has registered, already green. The - // grace defers the pass — this snapshot must NOT publish. + // t=10s: only one run has registered yet, green. let first = vec![run_with(1, "completed", Some("success"))]; assert_eq!( snapshot(&first, 9, 10, 120), Decision::DeferGreen { green: 1 } ); - // t=40s: a late-registering run appears, already failed. The deferral - // is exactly what lets the gate see it; the failure names the run. + // t=40s: a late-registering run arrives already failed. let second = vec![ run_with(1, "completed", Some("success")), run_with(2, "completed", Some("failure")), @@ -902,13 +877,13 @@ mod tests { #[test] fn sequence_late_pending_run_defers_pass_until_it_resolves() { - // t=10s: all observed runs green, within grace — defer. + // t=10s: only one run has registered yet, green. let first = vec![run_with(1, "completed", Some("success"))]; assert_eq!( snapshot(&first, 9, 10, 120), Decision::DeferGreen { green: 1 } ); - // t=40s: a late-registering run is still in progress — wait on it. + // t=40s: a late-registering run arrives, still in progress. let second = vec![ run_with(1, "completed", Some("success")), run_with(2, "in_progress", None), @@ -917,13 +892,12 @@ mod tests { snapshot(&second, 9, 40, 120), Decision::KeepWaiting(_) )); - // t=200s: grace long over, but the run is STILL pending — expiry of - // the grace never converts a pending run into a pass. + // t=200s: grace over; the pending run still blocks. assert!(matches!( snapshot(&second, 9, 200, 120), Decision::KeepWaiting(_) )); - // t=230s: the pending run resolves green — only now does it publish. + // t=230s: it resolves green. let resolved = vec![ run_with(1, "completed", Some("success")), run_with(2, "completed", Some("success")), @@ -940,7 +914,6 @@ mod tests { run_with(1, "completed", Some("success")), run_with(2, "completed", Some("skipped")), ]; - // Green snapshots inside the grace defer, including just before it. assert_eq!( snapshot(&runs, 9, 0, 120), Decision::DeferGreen { green: 2 } @@ -949,14 +922,12 @@ mod tests { snapshot(&runs, 9, 119, 120), Decision::DeferGreen { green: 2 } ); - // At the boundary the grace is over: no late arrivals came, publish. assert_eq!(snapshot(&runs, 9, 120, 120), Decision::Publish { green: 2 }); } #[test] fn sequence_no_other_ci_defers_within_grace_then_fails_closed() { - // Only the release workflow's own run exists. Within grace this may - // be registration lag — re-check; past it, fail closed. + // The release workflow's own run (workflow id 9) is the only one. let only_own = vec![run_with(9, "in_progress", None)]; assert_eq!(snapshot(&only_own, 9, 10, 120), Decision::DeferNoOtherCi); assert_eq!(snapshot(&only_own, 9, 120, 120), Decision::FailNoOtherCi); diff --git a/rainix-static/src/main.rs b/rainix-static/src/main.rs index f095ae9..2ee5b37 100644 --- a/rainix-static/src/main.rs +++ b/rainix-static/src/main.rs @@ -41,20 +41,13 @@ // ADDS a new , never edits an existing one. Needs the base ref // fetched with history (fetch-depth: 0 + `git fetch origin `). // ci-gate [--timeout-secs N] [--poll-secs N] [--grace-secs N] -// Publish gate on the gated commit's own CI: poll the repository's -// workflow runs for GITHUB_SHA — every trigger event, excluding every -// run of the release workflow this gate runs inside (resolved from -// GITHUB_RUN_ID) — and exit 0 only when all of them completed green -// (success / skipped / neutral) and --grace-secs (default 120) has -// elapsed: run registration lags the trigger, so an earlier all-green -// snapshot is re-checked until the grace passes. A failed, cancelled, -// or timed-out run fails the gate immediately, naming it; a commit with -// NO other workflow runs past the same grace fails closed — nothing -// tested the commit; hitting --timeout-secs (default 7200, polling -// every --poll-secs, default 30, minimum 1) fails naming what was still -// pending. Needs -// GITHUB_REPOSITORY / GITHUB_SHA / GITHUB_RUN_ID / GITHUB_TOKEN (the -// token needs `actions: read`); runs where curl is on PATH. +// Publish gate on the gated commit's own CI: exit 0 only when every +// other workflow run on GITHUB_SHA is green and the discovery grace +// has elapsed; red, no-other-CI past the grace, and the deadline all +// fail loudly (semantics: ci_gate.rs module doc). Defaults: timeout +// 7200, poll 30 (minimum 1), grace 120. Needs GITHUB_REPOSITORY / +// GITHUB_SHA / GITHUB_RUN_ID / GITHUB_TOKEN (`actions: read`) and +// curl on PATH. // soldeer-gate --package [--github-output ] // Soldeer content gate: compare the normalized content of what // `forge soldeer push --dry-run` would upload against the newest published From 210e3358b7613c1dbb22d907e9024dcf3d600a99 Mon Sep 17 00:00:00 2001 From: David Meister Date: Tue, 25 Aug 2026 12:52:00 +0000 Subject: [PATCH 10/10] ci: bump RAINIX_SHA to 64c884b9215f1391549926125de18c3a1b6330b6 (comment trim) Co-Authored-By: Claude Fable 5 --- .github/workflows/rainix-autopublish.yaml | 2 +- .github/workflows/rainix-copy-artifacts.yaml | 2 +- .github/workflows/rainix-manual-sol-artifacts.yaml | 2 +- .github/workflows/rainix-manual-sol-verify.yaml | 2 +- .github/workflows/rainix-rs-static.yaml | 2 +- .github/workflows/rainix-rs-test.yaml | 2 +- .github/workflows/rainix-rs-wasm-test.yaml | 2 +- .github/workflows/rainix-rs-wasm.yaml | 2 +- .github/workflows/rainix-sol-legal.yaml | 2 +- .github/workflows/rainix-sol-static.yaml | 2 +- .github/workflows/rainix-sol-test.yaml | 2 +- .github/workflows/rainix-subgraph-test.yaml | 2 +- .github/workflows/rainix-tag-release.yaml | 2 +- 13 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/rainix-autopublish.yaml b/.github/workflows/rainix-autopublish.yaml index 291fc85..6afde00 100644 --- a/.github/workflows/rainix-autopublish.yaml +++ b/.github/workflows/rainix-autopublish.yaml @@ -50,7 +50,7 @@ on: SOLDEER_API_TOKEN: required: false env: - RAINIX_SHA: 60ba051bae06c184670289de32653f175a64dfaf + RAINIX_SHA: 64c884b9215f1391549926125de18c3a1b6330b6 jobs: release: if: ${{ !startsWith(github.event.head_commit.message, 'Package Release') }} diff --git a/.github/workflows/rainix-copy-artifacts.yaml b/.github/workflows/rainix-copy-artifacts.yaml index 7e1299b..3f906ef 100644 --- a/.github/workflows/rainix-copy-artifacts.yaml +++ b/.github/workflows/rainix-copy-artifacts.yaml @@ -2,7 +2,7 @@ name: rainix-copy-artifacts on: workflow_call: env: - RAINIX_SHA: 60ba051bae06c184670289de32653f175a64dfaf + RAINIX_SHA: 64c884b9215f1391549926125de18c3a1b6330b6 jobs: copy-artifacts: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-manual-sol-artifacts.yaml b/.github/workflows/rainix-manual-sol-artifacts.yaml index 133626a..611b5af 100644 --- a/.github/workflows/rainix-manual-sol-artifacts.yaml +++ b/.github/workflows/rainix-manual-sol-artifacts.yaml @@ -85,7 +85,7 @@ on: CI_DEPLOY_FLARE_ETHERSCAN_API_KEY: required: false env: - RAINIX_SHA: 60ba051bae06c184670289de32653f175a64dfaf + RAINIX_SHA: 64c884b9215f1391549926125de18c3a1b6330b6 jobs: deploy: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-manual-sol-verify.yaml b/.github/workflows/rainix-manual-sol-verify.yaml index db6f336..e3e0915 100644 --- a/.github/workflows/rainix-manual-sol-verify.yaml +++ b/.github/workflows/rainix-manual-sol-verify.yaml @@ -65,7 +65,7 @@ on: CI_DEPLOY_FLARE_ETHERSCAN_API_KEY: required: false env: - RAINIX_SHA: 60ba051bae06c184670289de32653f175a64dfaf + RAINIX_SHA: 64c884b9215f1391549926125de18c3a1b6330b6 jobs: verify: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-rs-static.yaml b/.github/workflows/rainix-rs-static.yaml index 74ce1ce..f87b754 100644 --- a/.github/workflows/rainix-rs-static.yaml +++ b/.github/workflows/rainix-rs-static.yaml @@ -2,7 +2,7 @@ name: rainix-rs-static on: workflow_call: env: - RAINIX_SHA: 60ba051bae06c184670289de32653f175a64dfaf + RAINIX_SHA: 64c884b9215f1391549926125de18c3a1b6330b6 jobs: rs-static: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-rs-test.yaml b/.github/workflows/rainix-rs-test.yaml index 8c7f20e..55bd674 100644 --- a/.github/workflows/rainix-rs-test.yaml +++ b/.github/workflows/rainix-rs-test.yaml @@ -2,7 +2,7 @@ name: rainix-rs-test on: workflow_call: env: - RAINIX_SHA: 60ba051bae06c184670289de32653f175a64dfaf + RAINIX_SHA: 64c884b9215f1391549926125de18c3a1b6330b6 jobs: rs-test: strategy: diff --git a/.github/workflows/rainix-rs-wasm-test.yaml b/.github/workflows/rainix-rs-wasm-test.yaml index eac5152..33e444d 100644 --- a/.github/workflows/rainix-rs-wasm-test.yaml +++ b/.github/workflows/rainix-rs-wasm-test.yaml @@ -2,7 +2,7 @@ name: rainix-rs-wasm-test on: workflow_call: env: - RAINIX_SHA: 60ba051bae06c184670289de32653f175a64dfaf + RAINIX_SHA: 64c884b9215f1391549926125de18c3a1b6330b6 jobs: rs-wasm-test: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-rs-wasm.yaml b/.github/workflows/rainix-rs-wasm.yaml index e35d391..be85752 100644 --- a/.github/workflows/rainix-rs-wasm.yaml +++ b/.github/workflows/rainix-rs-wasm.yaml @@ -2,7 +2,7 @@ name: rainix-rs-wasm on: workflow_call: env: - RAINIX_SHA: 60ba051bae06c184670289de32653f175a64dfaf + RAINIX_SHA: 64c884b9215f1391549926125de18c3a1b6330b6 jobs: rs-wasm: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-sol-legal.yaml b/.github/workflows/rainix-sol-legal.yaml index ad4c717..f1ce0b7 100644 --- a/.github/workflows/rainix-sol-legal.yaml +++ b/.github/workflows/rainix-sol-legal.yaml @@ -2,7 +2,7 @@ name: rainix-sol-legal on: workflow_call: env: - RAINIX_SHA: 60ba051bae06c184670289de32653f175a64dfaf + RAINIX_SHA: 64c884b9215f1391549926125de18c3a1b6330b6 jobs: legal: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-sol-static.yaml b/.github/workflows/rainix-sol-static.yaml index 2d05a41..c2e6361 100644 --- a/.github/workflows/rainix-sol-static.yaml +++ b/.github/workflows/rainix-sol-static.yaml @@ -2,7 +2,7 @@ name: rainix-sol-static on: workflow_call: env: - RAINIX_SHA: 60ba051bae06c184670289de32653f175a64dfaf + RAINIX_SHA: 64c884b9215f1391549926125de18c3a1b6330b6 jobs: static: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-sol-test.yaml b/.github/workflows/rainix-sol-test.yaml index 5c6646b..8fd5e5a 100644 --- a/.github/workflows/rainix-sol-test.yaml +++ b/.github/workflows/rainix-sol-test.yaml @@ -25,7 +25,7 @@ on: RPC_URL_SEPOLIA_FORK: required: false env: - RAINIX_SHA: 60ba051bae06c184670289de32653f175a64dfaf + RAINIX_SHA: 64c884b9215f1391549926125de18c3a1b6330b6 jobs: test: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-subgraph-test.yaml b/.github/workflows/rainix-subgraph-test.yaml index 3ee73cb..6051e7c 100644 --- a/.github/workflows/rainix-subgraph-test.yaml +++ b/.github/workflows/rainix-subgraph-test.yaml @@ -2,7 +2,7 @@ name: rainix-subgraph-test on: workflow_call: env: - RAINIX_SHA: 60ba051bae06c184670289de32653f175a64dfaf + RAINIX_SHA: 64c884b9215f1391549926125de18c3a1b6330b6 jobs: subgraph-test: runs-on: ubuntu-latest diff --git a/.github/workflows/rainix-tag-release.yaml b/.github/workflows/rainix-tag-release.yaml index 7f73d96..af65eda 100644 --- a/.github/workflows/rainix-tag-release.yaml +++ b/.github/workflows/rainix-tag-release.yaml @@ -124,7 +124,7 @@ on: RPC_URL_SEPOLIA_FORK: required: false env: - RAINIX_SHA: 60ba051bae06c184670289de32653f175a64dfaf + RAINIX_SHA: 64c884b9215f1391549926125de18c3a1b6330b6 jobs: # The release tag must point at a commit already merged to the release branch. # `on: push: tags` fires for ANY tag, including one cut from an unmerged branch;