On this page
One predicate behind both the release CI gate and the publish gate: did this project's own CI check actually run, and pass, on a given commit?
#rlsbl.ci_checks
#rlsbl.ci_checks
One predicate for both CI gates: did THIS project's own CI RUN and PASS on a commit?
rlsbl gates a release twice on the same question, in two places:
- the RELEASE gate (
rlsbl release run/resume/ the batch
orchestrator) asks it before tagging the candidate commit;
- the PUBLISH gate (the generated workflow in :mod:
rlsbl.publish_gate)
asks it again before pushing artifacts to a registry.
They used to answer it with different machinery. The release gate watched WORKFLOW RUNS (gh run list --commit) and accepted the run's own conclusion; the publish gate polls CHECK RUNS filtered by the releasing project's name regex and accepts only success. In a monorepo those two answers come apart exactly when the CI router's dorny/paths-filter sees no change under a project's paths: the project's job concludes skipped, the router's workflow run still concludes success, so the release gate tagged and the publish gate then refused the same commit -- a tag and a GitHub Release for a version that can never publish.
This module is the single predicate both gates now use:
- the check-run NAME MATCHER is the publish gate's own
(:func:rlsbl.publish_gate.ci_check_regex_for_targets for standalone repositories, :func:rlsbl.ci_router._router_ci_check_regex for monorepo members), never a second matcher that can drift;
- the CONCLUSION POLICY is :data:
PASSING_CONCLUSION: onlysuccess
passes. skipped and cancelled prove nothing about the commit and are hard errors on both sides.
The two gates read that answer from the two sources available to them. The publish gate runs inside Actions and polls the commit's CHECK RUNS. The release gate reads the JOBS of the runs it already discovered and watched, through the attempt-scoped endpoint (:func:fetch_run_jobs). Actions names a commit's check runs after the jobs that produced them, so the two see the same names with the same conclusions -- and the attempt-scoped read is the one that answers for the attempt that just concluded, which is what an in-place rerun leaves behind.
Three outcomes stay explicitly distinct, and the release gate reports each differently (see :func:verify_project_ci_ran):
- The repository declares no push-triggered workflow at all -- handled
upstream by :func:rlsbl.commands.watch.push_triggered_workflows; the release proceeds with a loud notice and this module is never consulted.
- A project in scope ships no CI workflow of its own (nothing was ever
inlined into the router for it) -- an observable fact about the project, reported as its own loud notice, and its checks are not required.
- Workflows exist and the project HAS CI, but its checks are absent or
concluded anything other than success on the candidate -- a hard error naming the filter and the remedy.
#CheckFilter
One project's check-run name filter for a single candidate commit.
regex is None when the project ships no CI workflow of its own -- outcome 2 in the module docstring, reported rather than enforced.
#ProjectCINotRunError
The releasing project's own CI did not RUN-and-PASS on the candidate.
Deliberately distinct from a red CI: the code at the candidate may be perfectly fine, and the remedy is not "fix the failure" but "make the candidate contain a commit this project's CI actually runs on".
#standalone_check_filter
def standalone_check_filter(config, registry, *, label=None) -> CheckFilterThe filter a standalone repository's own publish gate bakes in.
Built from the same target set (:func:gate_targets_from_config) and the same matcher (:func:ci_check_regex_for_targets) that rlsbl scaffold renders into publish.yml.
#monorepo_check_filters
def monorepo_check_filters(workspace_root, project_dirs) -> list[CheckFilter]Filters for the workspace projects released under one tag.
project_dirs maps project name -> absolute project directory. Each filter is the monorepo publish router's own (:func:rlsbl.ci_router._router_ci_check_regex), recomputed from the project's CI files on disk -- the same discovery monorepo sync used to mint the router's job keys.
#workspace_check_filters
def workspace_check_filters(workspace_root, project_dirs) -> list[CheckFilter]Filters for every workspace project covered by project_dirs.
Each directory is resolved to its workspace project; a project belonging to a releasable pulls in that releasable's other members, because one tag publishes them all. Used by the batch orchestrator, whose single CI gate stands in for every member's individual gate.
#release_check_filters
def release_check_filters(*, config, registry, project_dir, workspace_root=None, monorepo_name=None, releasable_name=None)Resolve the check filters the release CI gate must satisfy.
Standalone repositories yield one filter. A monorepo yields one per project released under the tag: every member of the releasable -- exactly the set whose publish jobs the tag will trigger.
The releasing project is ALWAYS in the result, keyed by its own name and directory. The workspace read only widens that set to the releasable's other members; it can never narrow it away, so no workspace state can leave the releasing project unverified.
#_check_run_order
def _check_run_order(run)Recency key for one check run: started_at, then numeric id.
#has_verdict
def has_verdict(run)True when run concluded something other than skipped.
A check still in flight has no conclusion at all, and skipped is the ABSENCE of one, so neither can supersede a skip -- only a real verdict can.
#latest_check_runs
def latest_check_runs(check_runs, regex, *, exclude_run_id=None)Matching check runs, collapsed to one verdict per job.
Mirrors the publish gate's jq pipeline: filter by name, drop this workflow's own runs, group by name ACROSS EVERY CHECK SUITE on the commit and keep the newest (started_at, then numeric id) so a retried run supersedes the stale one it replaced.
Recency alone cannot decide a skip, and that is not a detail. A run_all dispatch (see :data:RUN_ALL_REMEDY) deliberately puts TWO check runs of the same name on one commit: skipped from the push-triggered suite whose paths filter found nothing, and a real conclusion from the dispatched suite. rlsbl dispatches immediately after pushing the candidate, while the push run's project jobs are still queued behind the router's detect job -- so GitHub stamps the skip LATER than the dispatched run's verdict as a matter of course. Collapsing purely by time hands the gate the skip and refuses a commit whose jobs all ran and passed.
So a skip never wins over a verdict: when the latest check run for a name is skipped, it is replaced by the latest COMPLETED non-skipped check run of that same name, in whichever suite and whatever order the two were recorded. Success passes, failure fails -- nothing is waived. When every check run for that name is skipped (or none has concluded), the skip stands and the gate refuses.
One more thing a per-name collapse cannot see: GitHub does not expand a matrix for a job its if skipped. The whole job collapses to ONE check run under the unsuffixed name (cli-ci / test), while the run that actually executes it emits one per leg (cli-ci / test (3.12)). The two never share a name, so the skip would outlive the run that answered it. A skipped check run is therefore also dropped when a completed, non-skipped check run for the SAME job -- its matrix expansion, by name -- exists; the legs are then judged on their own conclusions, so a red leg still fails the gate. Nothing else can cover a skip: not a sibling job, not a merely prefix-sharing name, and not a leg that was itself skipped.
#failing_check_runs
def failing_check_runs(runs)(name, conclusion) for every run that did not conclude success.
An incomplete run counts as failing here: the release gate only asks after every workflow run for the commit has already concluded, so a check still in flight at that point is not a pass either.
#_decode_paginated
def _decode_paginated(raw, key)Collect key out of every page gh api --paginate concatenated.
--paginate on a non-array response emits one JSON object per page back to back, so the stream is decoded rather than parsed as one document.
#_gh_json
def _gh_json(path, *, paginate=False, cwd=None, config=None)GET path through gh api, returning the raw stdout.
#_job_verdict
def _job_verdict(job)One job, in the record shape the collapse logic reads.
Actions names a commit's check runs after the jobs that produced them, so a job record carries exactly the fields :func:latest_check_runs needs. html_url is carried as details_url because that is where the run id lives for the exclude_run_id filter, in the same /actions/runs/<id>/ form the check-run payload uses.
#fetch_run_jobs
def fetch_run_jobs(run_id, *, cwd=None, config=None)Every job of run_id's LATEST attempt, as verdict records.
Two reads, both keyed by the run id the caller already holds: the run object (for run_attempt), then the ATTEMPT-SCOPED job list.
The unscoped /actions/runs/<id>/jobs collection is never read, and that is deliberate rather than defensive. It answers for a run's whole history without saying which attempt a job belongs to, while every caller here wants the attempt that just concluded -- an in-place rerun keeps the run id and only the attempt advances. It is also the endpoint that 404s on repositories where the run object and the attempt-scoped job list both answer normally, which is how a green candidate's release aborted on a bare gh: Not Found (HTTP 404). There is one endpoint for jobs, and it is this one.
#fetch_ci_jobs
def fetch_ci_jobs(run_ids, *, cwd=None, config=None)The jobs of every run in run_ids, as one flat list of verdicts.
The release gate collapses these by name across runs (:func:latest_check_runs), so a member's job in a run_all dispatch supersedes the same member's skipped job in the push run.
#verify_project_ci_ran
def verify_project_ci_ran(sha, filters, *, run_ids=None, cwd=None, config=None, log=None, fetch=None, attempts=CHECK_DISCOVERY_ATTEMPTS, interval=CHECK_DISCOVERY_INTERVAL, exclude_run_id=None)Hard-error unless every project in filters ran and passed CI on sha.
Raises :class:ProjectCINotRunError otherwise. Projects with no CI workflow of their own (CheckFilter.regex is None) get a loud notice on stderr and are not enforced -- an absent workflow is an observable fact about the project, not a skipped job.
run_ids are the workflow runs the caller already discovered and watched for sha; their jobs are the verdicts this gate reads (:func:fetch_ci_jobs). It is required unless fetch supplies the records directly -- a caller with neither is a hard error rather than a gate that silently verifies nothing.