A policy-driven CI control plane for the OSAC repositories. For any pull request it answers, in one place: what is blocking it, why, who must act, and what happens next.
It reads the live state of a pull request from GitHub (labels, reviews, check runs, changed files, merge queue), applies a reviewable policy file, and publishes the answer as a check run named OSAC CI on the PR, with a plain-language summary. The same program also explains a PR on the command line, writes a report of every open PR, and replays history to prove that its answers match what really happened.
Contents: Background · Concepts · The life of a pull request · Pull request flows by user type · Components · Policy reference · Operating it · Security model · Development
OSAC is an open-source platform for sovereign AI infrastructure. Its code lives mostly in one mono-repository,
osac-project/osac. Four repositories take part in getting a change merged:
| Repository | Role |
|---|---|
osac-project/osac |
The product mono-repo and the main customer of this project. Its workflows run unit tests, linters, integration tests and full-installation (E2E) tests on every pull request. |
osac-project/osac-test-infra |
Reusable workflows and actions shared by the E2E suites: slash-command handling, the E2E cost gate, fork handling, the infrastructure backends. |
osac-project/github-config |
Terraform for the organization: repositories, teams, branch rulesets and which checks are required. |
osac-project/osac-ci |
This repository: the program that decides, from all of the above, whether a pull request may merge. |
Three facts about how osac merges explain why a control plane is useful:
- Many checks must pass. A repository ruleset lists about 26 required status checks. Each comes from a different workflow, and many are path-filtered: a workflow that has nothing to do reports a skipped or green check.
- Approval is a pair of labels. Reviewers comment
/lgtmand/approve; a bot (Prow) turns those comments into thelgtmandapprovedlabels, and stripslgtmagain when new commits are pushed. A third label,jira/valid-reference, shows that the PR points at a Jira issue. A fourth set of labels blocks a merge (do-not-merge/hold,do-not-merge/work-in-progress,do-not-merge/invalid-owners-file,needs-rebase). - Merging goes through a merge queue. A script (
auto-queue.sh) enables auto-merge on a PR whose labels are in order. GitHub's merge queue then builds a temporary commit (the base branch plus this PR, plus any PRs ahead of it), runs the required checks on it once more, and merges only if they pass. Nobody is forced to use the queue: users with enough rights can merge directly, which skips the queue's final check.
Two more things are specific to OSAC:
- E2E tests are expensive. A full installation of the platform on real or virtual infrastructure takes about
100 minutes and costs money. So the E2E jobs do not start on every push: they wait for an unlock signal (a human
/lgtm, a/e2e-readycomment, or an approval from the CodeRabbit review bot on the exact current commit). The requirede2e-*-gatechecks stay pending, not failed, until the suites have run. - Forks are untrusted. A pull request from a fork must not read repository secrets or start paid jobs just because
someone opened it. Members of the organization are trusted. For everyone else a member must say
/ok-to-test.
The result is that "what is the PR waiting for?" has no single place to look. The answer is spread over labels, review states, 26 checks and workflow logs. osac-ci computes that answer from one policy.
| Term | Meaning |
|---|---|
| Snapshot | Everything the planner may look at for one PR, collected from GitHub: draft flag, author, whether the PR comes from a fork, labels, reviews, check runs, changed files, merge-queue membership. |
| Policy | A YAML file (policy/*.yml) that lists the jobs that must pass, the labels that gate a merge, how approval works, who is trusted and which signals unlock E2E. Reviewed like code. |
| Job | One required check described in the policy: its check name, whether it is a cheap check or a costly E2E suite, which files make it apply. |
| Planner | A pure function plan(snapshot, policy, mode) that returns a verdict. It does no I/O, so the same input always gives the same answer. |
| Verdict | The planner's answer: a state, a headline, the blockers, the status of every job, the next action and who must take it. |
| State | Exactly one of 15 names that describe the PR right now (see the state machine). A state is derived from the snapshot every time, never remembered. |
| Mode | pr evaluates the head commit of the pull request; queue evaluates the merge-queue commit. |
| OSAC CI check | The check run the publisher posts on a commit. It is the verdict in GitHub's vocabulary: success, in_progress, action_required or failure. |
| Fail closed | Anything unexpected (bad policy, unreadable team, API error) becomes the planner-error state with its cause. Never a pass. |
| Native approval | Approval taken from GitHub reviews and CODEOWNERS instead of the lgtm/approved labels. |
| Sweep | A scheduled run that re-evaluates open PRs, to repair any event that was missed. |
| Replay | Running the planner over merged PRs to check that it agrees with what actually happened. |
The planner looks at the snapshot and returns the first state in this list that applies:
planner-error, if anything could not be read or decided.- In queue mode:
queue-failedif a required check failed, elsequeue-checks-runningif one is pending, elsequeue-passed. draft, if the PR is a draft.checks-failed, if a required cheap check failed.e2e-failed, if a required E2E gate failed.needs-authorization, if a fork PR must run an E2E job but may not yet use secrets.checks-running, if a cheap check is running or has not reported.awaiting-approval, if a required label is missing, a blocking label is present, or native approval is not met.awaiting-e2e-signal, if an E2E job is held back by the unlock rule, thenawaiting-unlock, if a job is held back by a lock of the policy (see Locks).e2e-running, if E2E checks are in progress.in-queueif the PR is in the merge queue, otherwiseready-to-enqueue.
Because a state is derived and not stored, there is no history to corrupt: if a label is removed, a review is dismissed or a commit is pushed, the next evaluation simply lands on another state. The diagram shows the usual journey.
stateDiagram-v2
direction TB
state "draft" as draft
state "checks-running" as checks_running
state "checks-failed" as checks_failed
state "needs-authorization" as needs_authorization
state "awaiting-approval" as awaiting_approval
state "awaiting-e2e-signal" as awaiting_e2e_signal
state "e2e-running" as e2e_running
state "e2e-failed" as e2e_failed
state "ready-to-enqueue" as ready_to_enqueue
state "in-queue" as in_queue
state "queue-checks-running" as queue_checks_running
state "queue-failed" as queue_failed
state "queue-passed" as queue_passed
state "planner-error" as planner_error
[*] --> draft: opened as a draft
[*] --> checks_running: opened for review
draft --> checks_running: marked ready for review
checks_running --> checks_failed: a required check fails
checks_failed --> checks_running: author pushes a fix or re-runs
checks_running --> needs_authorization: fork PR, E2E would start
needs_authorization --> checks_running: org member comments /ok-to-test sha
checks_running --> awaiting_approval: checks pass, approval missing
awaiting_approval --> awaiting_e2e_signal: approved, E2E still locked
awaiting_approval --> e2e_running: approved, E2E unlocked
awaiting_e2e_signal --> e2e_running: unlock signal arrives
e2e_running --> e2e_failed: an E2E gate fails
e2e_failed --> checks_running: author pushes a fix or re-runs
e2e_running --> ready_to_enqueue: all gates pass
ready_to_enqueue --> in_queue: auto-queue enables auto-merge
in_queue --> queue_checks_running: queue builds the merge commit
queue_checks_running --> queue_failed: a required check fails
queue_checks_running --> queue_passed: all required checks pass
queue_failed --> checks_running: ejected, author pushes a fix
queue_passed --> [*]: merged
awaiting_approval --> checks_running: new commit pushed
awaiting_e2e_signal --> checks_running: new commit pushed
ready_to_enqueue --> awaiting_approval: approval lost or blocking label added
in_queue --> awaiting_approval: approval lost or blocking label added
planner_error --> checks_running: re-run the OSAC CI check
Any state can also move to planner-error (not drawn on every edge to keep the picture readable) and back to a normal
state once the cause is fixed and the check is re-run. A push of new commits sends a PR back to the beginning, because
checks are tied to a commit.
| State | Meaning | Who acts | What they do | OSAC CI check |
|---|---|---|---|---|
draft |
Drafts never enter the merge queue. | author | Mark the PR ready for review. | action_required |
needs-authorization |
A fork PR would start E2E or use secrets, and nobody from the organization has approved that commit. | org member | Comment /ok-to-test <full commit sha> (the verdict prints the exact command). |
action_required |
checks-running |
A required cheap check is queued, running or has not reported yet. | nobody | Wait. | in_progress |
checks-failed |
A required cheap check failed. | author | Fix and push, or comment /retest for a flaky check. |
action_required |
awaiting-approval |
Approval is missing: the lgtm/approved labels, or the native approvals and code-owner approval; or a blocking label is on the PR. |
reviewer or code owner | Review and approve; remove the blocking label. | action_required |
awaiting-e2e-signal |
Everything else is fine, but the cost gate holds the E2E jobs back. | reviewer | Provide an unlock signal (/lgtm, /e2e-ready, a CodeRabbit approval on the current commit, or per policy a human approval). |
action_required |
awaiting-unlock |
Everything else is fine, but a lock of the policy holds a job back (the blockers name the job and the lock). | reviewer | Open the lock: the verdict names the shortest way. | action_required |
e2e-running |
E2E suites are running (about 100 minutes). | nobody | Wait. | in_progress |
e2e-failed |
An E2E suite failed. | author | Fix and push, or /retest if it looks like an infrastructure flake. |
action_required |
ready-to-enqueue |
All pull-request requirements are met. | nobody | The queue script picks it up. | success |
in-queue |
The PR is waiting in the merge queue. | nobody | Wait. | success |
queue-checks-running |
(queue commit) Required checks on the merge commit are pending. | nobody | Wait. | in_progress |
queue-failed |
(queue commit) A required check failed on the merge commit. The PR is ejected from the queue. | author | Fix and push. | failure (red on purpose, so the queue ejects the entry instead of waiting for its timeout) |
queue-passed |
(queue commit) All required checks passed; the queue merges the PR. | nobody | Nothing. | success |
planner-error |
The verdict could not be computed (unreadable policy, API failure, unreadable owner team). | infra group | Re-run the OSAC CI check; if it keeps failing, contact the infra group. | failure |
action_required is yellow, not red: something is blocked and a person must act, but nothing is broken.
flowchart TD
A[E2E job is about to start] --> B{PR from a fork?}
B -- no --> E
B -- yes --> C{Author or fork owner in the org,<br/>author a trusted bot,<br/>or an org member authorized this commit?}
C -- no --> N[needs-authorization]
C -- yes --> E{Unlock signal present?}
E -- yes --> OK[the job may run]
E -- no --> W[awaiting-e2e-signal]
Fork authorization and the cost gate are two separate questions: the first protects secrets, the second protects money. A fork PR needs both.
There are two kinds of contributors. What differs is how much the system trusts the person, and so which steps happen automatically and which need a member of the organization.
An org member either pushes a branch to the repository itself or opens a PR from their own fork. Both are trusted: secrets are available and nobody has to authorize anything.
- Open the PR. Use the title format
OSAC-1234: description(orNO-ISSUE: description). The PR starts inchecks-running; open it as a draft to stay indraftwhile it is not ready. - Cheap checks run on every push: pre-commit, linters, unit and integration tests, generated-code checks, Helm lint. Each is path-filtered, so a docs-only PR runs very little.
- Review. Where the policy uses labels, a reviewer comments
/lgtmand an approver comments/approve; where it uses native approval (see below), reviewers click Approve in GitHub and a code owner must be among them. Thejira/valid-referencelabel shows that the PR references a valid Jira issue. - E2E unlock. The
/lgtm(or a native approval, or/e2e-ready, or a CodeRabbit approval of the current commit) unlocks the full-installation suites. The PR showse2e-runningfor about 100 minutes. - Enqueue. Once all requirements are met the PR is
ready-to-enqueue; the queue script enables auto-merge. - Merge queue. GitHub builds the merge commit, runs the required checks once more, and merges when they pass
(
queue-passed). If something fails the PR is ejected (queue-failed); fix it and the PR goes through again. - After a push new commits start the cheap checks again. With labels,
lgtmis dropped by the bot on every push. With native approval, GitHub keeps an approval when a rebase leaves the diff identical, and the planner does too (see Native approval); a real change makes the approval stale.
Useful comments, written on the PR: /retest re-runs failed checks, /test e2e re-runs E2E, /cancel stops runs,
/e2e-ready unlocks E2E for the current commit. These are handled by workflows in osac and osac-test-infra.
An external contributor can only open a PR from a fork. The system will build and test it, but spends no money and reveals no secret until a member vouches for the exact code.
- Fork and open the PR. Cheap checks that need no secrets run as for anyone else. The PR may show
needs-authorizationas soon as it would start a job that needs secrets or E2E. - A member authorizes the commit. A member of the organization reads the change and comments
/ok-to-test <full 40-character commit sha>. The OSAC CI verdict prints the exact command to copy. A workflow verifies that the commenter really is an org member (it does not trust the comment's own label), then records the authorization as a check run named OSAC CI authorization on that commit. - The authorization belongs to that commit. If the contributor pushes again, the new commit has no authorization
and the PR returns to
needs-authorization. This is intentional: a member who reviewed one version must not accidentally approve a later, different one. The full SHA is required because a short prefix could be matched by a crafted commit; a command with a short SHA is refused. - Review and E2E as for a member. Authorization only protects secrets. E2E still needs an unlock signal
(
awaiting-e2e-signal), and approval still needs a reviewer (awaiting-approval). - Merge queue. The same as for a member.
If an external contributor's PR is made by a trusted bot (for example Dependabot, listed in the policy's
trusted_bots), step 2 is skipped.
| Org member, branch in the repo | Org member, from a fork | External contributor | |
|---|---|---|---|
| Secrets and paid jobs | yes | yes | after /ok-to-test <sha> for that exact commit |
| New push | nothing to redo | nothing to redo | needs a new /ok-to-test <sha> |
| E2E unlock | needed | needed | needed (separate from /ok-to-test) |
| Review and merge queue | same | same | same |
| Piece | Where | What it does |
|---|---|---|
| Domain model | src/osac_ci/model.py |
Plain data types: Snapshot (input), Verdict (output), State, JobStatus, Review, CheckRun, LabelEvent. |
| Planner | src/osac_ci/planner.py |
The pure decision function plan(snapshot, policy, mode). plan_or_error wraps it so any exception becomes the planner-error state. Holds the state ordering and the "who acts next" texts. |
| Policy loader | src/osac_ci/policy.py |
Parses and validates a policy file. Unknown keys are errors, so a typo cannot silently disable a rule. |
| Rules | src/osac_ci/rules/ |
One module per rule, each pure: labels.py (required and blocking labels), readiness.py (the legacy E2E unlock ladder), e2e_unlock.py (the explicit unlock policy), fork.py (who may use secrets), approval.py (native approval), codeowners.py (CODEOWNERS matching), protected.py (files that need named approvers), owners.py (approvers from an OWNERS file). |
| Path matching | src/osac_ci/paths.py |
Evaluates glob patterns and the named filters of ci-filters.yml, the same way the workflows' path-filter action does. |
| Change fingerprint | src/osac_ci/fingerprint.py |
A digest of what a commit adds and removes, ignoring surrounding context, so an approval can survive an unchanged rebase. |
| Timeline | src/osac_ci/timeline.py |
Rebuilds the labels, reviews and checks of a PR as they stood at a past moment (used by replay). |
| GitHub adapter | src/osac_ci/github/ |
The only code that does network I/O (standard library only). api.py is the HTTP client, snapshot.py turns about 7 to 11 API calls into a Snapshot. Deliberately avoids the bulk statusCheckRollup query, which times out on PRs with 100 checks. |
| Publisher | src/osac_ci/publish.py |
Posts the verdict as the OSAC CI check run, for a PR or for a merge-queue commit; the sweep budget lives here. |
| Authorizer | src/osac_ci/authorize.py |
Handles /ok-to-test <sha> and /override <sha> <reason> comments. |
| Report | src/osac_ci/report.py, render.py |
A table of every open PR (markdown, HTML, JSON). |
| E2E compare | src/osac_ci/e2e_compare.py |
Sets the E2E start decision against what the current label-based flow did, on every open PR. |
| Bot approval | src/osac_ci/bot_approve.py |
Says whether a Dependabot or Konflux PR may be approved without a person (a dry run: it approves nothing). |
| E2E start | src/osac_ci/e2e_start.py |
Says whether the full-install E2E should start for a PR now and why (a dry run: it starts nothing). |
| Compare | src/osac_ci/compare.py |
On every open PR, compares "OSAC CI says ready" with "every required check passed". |
| Replay | src/osac_ci/replay.py |
Compares the planner's answers with merged PRs. parity/explained.yaml lists signed-off disagreements. |
| CLI | src/osac_ci/cli.py |
The osac-ci command. |
| Policies | policy/ |
osac-ci.yml (this repository), osac.yml (the OSAC mono-repo's current rules), osac-filters.yml (a copy of its path filters), toy.yml (a tiny example that proves the engine is generic). |
| Workflow | Triggered by | Purpose |
|---|---|---|
ci.yml |
pull request, merge queue, push to main |
lint, type check, workflow security lint, unit and contract tests, the legacy differential tests. |
osac-ci-check.yml |
PR events, ci finished, every 10 minutes, manual |
Posts the OSAC CI check for this repository's pull requests. |
osac-ci-refresh.yml |
a PR comment starting with /osac-ci refresh |
Posts a fresh OSAC CI verdict for that PR right away. |
osac-ci-review.yml |
PR review submitted, dismissed or edited | Holds no secret and checks out nothing; it only asks the main workflow to re-evaluate the PR. |
osac-ci-authorize.yml |
a PR comment starting with /ok-to-test |
Verifies the commenter is an org member and records the authorization on that exact commit. |
osac-ci-queue.yml |
a required check finished on a merge-queue branch, manual | Posts the OSAC CI check on the merge-queue commit. |
report.yml (open-pr-report) |
nightly | Publishes the open-PR report as an artifact and job summary. |
compare.yml (compare-required-checks) |
daily | Publishes the comparison with the required checks as an artifact and job summary. |
uv sync
uv run osac-ci policy check policy/osac.yml
uv run osac-ci explain --policy policy/osac.yml --snapshot some-snapshot.json # offline
GH_TOKEN=... uv run osac-ci explain-pr --policy policy/osac.yml --repo osac-project/osac --number 1438
GH_TOKEN=... uv run osac-ci report --policy policy/osac.yml --repo osac-project/osac --out-dir report
GH_TOKEN=... uv run osac-ci compare --policy policy/osac.yml --repo osac-project/osac
GH_TOKEN=... uv run osac-ci e2e-decide --policy policy/osac.yml --repo osac-project/osac --number 1543
GH_TOKEN=... uv run osac-ci bot-decide --repo osac-project/osac --number 1569
GH_TOKEN=... uv run osac-ci e2e-compare --policy policy/osac.yml --repo osac-project/osac
GH_TOKEN=... uv run osac-ci replay --policy policy/osac.yml --repo osac-project/osac --days 60 --limit 100
GH_TOKEN=... uv run osac-ci publish --policy policy/osac-ci.yml --repo osac-project/osac-ci --number 12 --dry-runpolicy check, explain, explain-pr, report, compare, e2e-decide, e2e-compare, bot-decide and replay only read. publish, publish-queue and authorize
write check runs; --dry-run prints instead. bot-approve submits an approving review (see below).
A policy is one YAML file. The minimum is the jobs and the merge labels; everything else is optional and the defaults reproduce the behavior of the existing OSAC scripts.
version: 1
repo: osac-project/osac
merge:
required_labels: [lgtm, approved, jira/valid-reference]
blocking_labels: [do-not-merge/hold, needs-rebase]
jobs:
unit-tests: {check: "Run unit tests"} # a cheap job: the check must pass
e2e-vmaas: # a costly job: also behind the E2E unlock
kind: e2e
check: e2e-vmaas-gate
needs_readiness: true
suite: vmaasjobs.<id>.required_at chooses where a check is required: pr, queue, or both (default). paths and
exclude_paths make a job apply only to matching files; a job with no matching file is not-applicable, so nothing
waits for it. Run uv run osac-ci policy check <file> to validate a policy.
Each OSAC workflow decides today whether it applies, with the dorny/paths-filter action on the shared
ci-filters.yml, and reports a skipped or green check. The policy can name those same filters per job, so the planner
knows what should apply without reading each workflow:
path_filters:
mode: shadow # or enforce
file: osac-filters.yml # a ci-filters.yml-style file next to the policy (inline `filters:` also works)
jobs:
unit-tests: {check: "Run unit tests", filters: [code, unit-tests-fulfillment-service]} # ALL must hold
e2e-vmaas:
check: e2e-vmaas-gate
filters: [vmaas-outside-known-safe]
filters_any: [e2e-code, e2e-suite] # AT LEAST ONE must holdA filter holds when some changed file matches it (the rules in osac_ci/paths.py, held to the real action by an
oracle test over thousands of paths). filters and filters_any combine like the workflows' if: expressions: every
name in filters and at least one in filters_any. A job uses either globs (paths) or named filters, not both.
shadow(default, and whatpolicy/osac.ymluses): the filters decide nothing. The planner trusts each check as before and adds a verdict note where a filter and the check disagree, in the two unambiguous cases only: a failure on files the filters call irrelevant, or a skip on files they call relevant (a readiness-gated E2E job is skipped on purpose, so it is exempt). A green check proves nothing either way, since a workflow that finds nothing to do still reports success.osac-ci replaycounts these and lists them.enforce: the filters decide. A job whose filters do not hold is not applicable, so nothing waits for it, and even a failure of its check is ignored. A PR that lists no changed files, or whose file list is incomplete, never skips anything.
policy/osac-filters.yml is a copy of the osac file .github/filters/ci-filters.yml (its header records the osac
commit). Refresh it when that file changes, and move to enforce only when the replay shows the mapping agrees.
A pull request can change the workflows, filters and scripts that produce the checks it is judged by. A check that
reports success then proves little: a job changed to end with exit 0 still reports success under its old name. Deleting
a job is caught (a required check that never reports keeps the PR blocked), but weakening one is not, and a skip counts
as a pass. The control that works is a person who is trusted for those files reading the change:
protected_paths:
- paths: [".github/**", "CODEOWNERS"]
exclude_paths: ["**/*.md"] # optional: files inside the protected ones that need no approval
approvers: ["@osac-project/wg-infra"] # "@login" or "@org/team": one of them must approve
carry_over: never # or trivial-rebase, as for `approval:`When a changed file matches paths, the PR needs an approval from one of approvers that covers the current changes
(given on this commit, or carried over a rebase when the rule allows it). The author never approves their own PR, a
review that requests changes is not an approval, and a team that cannot be read is a planner-error. The verdict is
awaiting-approval and names the approvers. The rule lives in the policy, so the PR cannot change who has to approve,
and it works with label approval as well as native approval. It is evaluated at pull request time, not on a merge-queue
commit.
A rule can also take approvers from a component's own OWNERS file, so the people who maintain a component can approve changes to its own workflow without the infrastructure group:
protected_paths:
- paths: [".github/workflows/**"]
exclude_paths: ["**/*.md", ".github/workflows/osac-ui-lint.yaml"] # judged by the rule below instead
approvers: ["@osac-project/wg-infra"]
- paths: [".github/workflows/osac-ui-lint.yaml"]
approvers: ["@osac-project/wg-infra"]
approvers_from: ["osac-ui/OWNERS"] # every approver listed in that Prow-style file, read from the base branchAny one person from approvers or the OWNERS files is enough, but a change that matches several rules needs each rule
satisfied, so keep the paths of different rules apart with exclude_paths. An OWNERS file that does not exist names
nobody; one that cannot be read or understood is a planner-error, never an empty list. policy/osac.yml delegates only
the osac-ui lint and typecheck workflows: they run on pull_request, use no secret and report no required check. Every
other workflow either reports a required check, runs with secrets or publishes an image, so it stays with the
infrastructure group.
policy/osac.yml protects the files that define osac's checks (.github/workflows, actions, scripts and
filters, CODEOWNERS, .pre-commit-config.yaml), with @osac-project/wg-infra as the approvers. The team is read
with the organization token. Markdown files are excluded (a README inside those folders cannot change a check), and
documentation directly in .github/ is not under any of the globs.
An urgent change that cannot wait for an approval (for example when its author is the only infrastructure person available) can be waived for one exact commit:
override:
approvers: ["@osac-project/wg-infra"]An approver comments /override <full 40-character commit sha> <reason>, with a reason of 10 to 140 characters (after Unicode normalization and collapsing whitespace) on one
line. The comment itself is what authorizes: whenever a verdict is computed, the pull request's comments are read and a
comment counts only if GitHub says an approver wrote it (the policy's team, as it is now), the pull request's author is
not that person, the comment names the current head commit in full, and it has never been edited. A workflow cannot
write a comment as another person, and it can write any check run: every workflow of the repository posts checks as the
same app, so a workflow added by the pull request itself could forge a check run that names an approver. That is why a
check run is never accepted as the proof.
A workflow also answers the comment and records the waiver as a check run named OSAC CI override, with the person and
the reason, so the commit shows who waived what and why. That check run is the audit record and nothing more. The
verdict notes the waiver ("protected-path approval waived by ..."). A new push has a new commit and needs a new
override, the full SHA is required for the same reason as for /ok-to-test (a short prefix can be forged), and an edited
comment has to be written again. The override lifts only the protected-path approval. A failed check, a missing label or
a draft stay what they are.
path_filters.skipped_applicable: fail (with mode: enforce) closes the other half: a check that was skipped for a
job that applies to the PR (its filters or globs match, or nothing narrows it, as for pre-commit) counts as failed
instead of passed. A readiness-gated E2E job is exempt, since it is
skipped on purpose until it is unlocked.
A job with paths is required only when a changed file matches, so a check can be mandatory for exactly the folder it
belongs to and never wait for anything else. In policy/osac.yml the osac-ui lint, proxy lint, typecheck and unit tests
work this way: an osac-ui change must pass them, and a backend-only change is never held up by them (they are
not-applicable). The ruleset lists its checks unconditionally and relies on every workflow reporting a skip as a pass;
a folder-scoped job needs neither. Those workflows run on pull_request with a trigger-level paths: and never on the
merge queue, so the jobs are required at pr time only (required_at: [pr]); requiring them on the queue commit would
wait for checks that never report.
A PR from a fork may use secrets, and start E2E, when its author is an org member, the fork is owned by an org member,
the author is in trusted_bots, or an org member authorized it. PRs from branches of the repository itself are
trusted (pushing there needs write access). The authorization mode decides what "authorized" means:
label(default): theok-to-testlabel. It survives pushes until a workflow strips it, so a commit pushed in that window is trusted as well.sha-bound: an org member comments/ok-to-test <full sha>with the commit's full 40-character SHA.osac-ci-authorize.ymlverifies the commenter with the read-only org token and posts a check run namedOSAC CI authorizationon that commit; the planner accepts it only on the exact head, from the workflow app, with an authorizer who is still an org member. A new push has a new SHA and no such check, so it is unauthorized at once: nothing to strip, no window. The SHA is part of the command because a bare/ok-to-testwould authorize "the head when the workflow looks", and a push in those seconds would be authorized unseen. Labels are ignored.
trust:
authorization: sha-bound
trusted_bots: ["dependabot[bot]"]Which signal lets an expensive E2E job start. It is the cost gate, separate from who may use secrets and from merge
approval. Without this section the legacy ladder applies: a current or ever-applied lgtm, a trusted e2e-ready
label, or a CodeRabbit approval on the head commit. mode: policy replaces it with one explicit list:
e2e:
unlock:
mode: policy
any_of: [human-approval, coderabbit-approval] # any one is enough
per_suite: {bmaas/sanity: [human-approval]} # optional: a suite with its own list
block_on_changes_requested: true # an open human "changes requested" blocks every signal| Signal | Holds when |
|---|---|
human-approval |
the PR is approved by the approval: policy: its minimum number of human approvals of the current changes (this commit, or carried over a rebase) and, if required, the code owners; needs that section |
coderabbit-approval |
CodeRabbit's latest decision is APPROVED on exactly the head commit |
lgtm-label |
the lgtm label is on the PR now (useful while approvals still use labels) |
e2e-ready-label |
the e2e-ready label, applied by github-actions[bot] |
There is no sticky signal: the legacy ladder keeps E2E unlocked once lgtm was ever applied, even after the code
changed, for as long as no human "changes requested" review is open. In policy mode each signal is judged against the
PR's state now, by its own rule. The verdict names what would unlock it; when suites need different signals it joins
their requirements with "and".
A lock holds a job back until something says it may start. It is how any job, not only E2E, can wait for a person, and
it is reported as locked: yellow (action_required), never red, and never a pass.
locks:
membership: # who is asking
open_when: [org-member, trusted-bot, authorized-commit] # any one opens it
cost: # has anyone looked at it
open_when: [human-approval, coderabbit-approval]
block_on_changes_requested: true
jobs:
e2e-vmaas: {kind: e2e, check: e2e-vmaas-gate, suite: vmaas, locks: [membership, cost]}
e2e-bmaas: {kind: e2e, check: e2e-bmaas-gate, suite: bmaas/sanity, locks: [membership, cost],
lock_overrides: {cost: [human-approval]}} # this suite needs a person
unit-tests: {check: "Run unit tests", locks: [membership]} # any job can be locked
default_locks: [membership] # every job unless it says locks: []- A job is open when every lock it names is open; a lock is open when any one of its signals holds. The first closed lock, in the order the job lists them, is the one reported.
- Signals about who is asking:
org-member(the author or the owner of the fork is a member, or the PR comes from a branch of the repository itself),trusted-bot,authorized-commit(/ok-to-test <full sha>, or the label in label mode, astrust.authorizationsays). Signals about review:human-approval,coderabbit-approval,lgtm-label,e2e-ready-label, with the meanings of the E2E unlock.legacy-readinessis today's osac-test-infra ladder as it is, stickylgtmincluded, and must stand alone in its lock. A lock that uses it describes the current behavior exactly. needs_readiness: truekeeps working and means the legacy gate; a job uses it orlocks, not both, and it takes nodefault_locks. A policy written with locks (membershipand acostlock usinglegacy-readiness, or the signals ofe2e.unlock) gives the same verdict as the same policy written withneeds_readiness, for any pull request. A property test checks this over random pull requests and four policy shapes, and a replay over openosacpull requests agreed on the state of all 100 of them.- Locked, not failed and not passed. While a job has no result of its own, a closed lock holds it and its own check
is ignored. A job that ran keeps its result (success, failure, cancelled) whatever the locks say now. A check that
was skipped is no result, because GitHub counts a skipped required check as a pass, with one exception: when the
path filters say the job does not apply to these files, a skip is the workflow reporting "nothing to do" (real pull
requests show it) and it stands. A job that does not apply is
not-applicable, never locked. Locks are not evaluated for a merge-queue commit. - States: a membership lock gives
needs-authorization, any other lock givesawaiting-unlock. Both come after failed checks and approval, as the E2E ones always did. - This only reports. It starts nothing and stops nothing: the workflows still decide whether a job runs. The one
difference from the old form that shows today is a skipped E2E gate of a pull request that was never unlocked: the
old form calls it passed, the lock form calls it locked. On the open
osacpull requests that was the only difference, on about a third of them, and the state of the PR was the same on all of them.
When a policy has locks, the OSAC CI check run carries a hidden block in its output text, with a small table of the
same facts for people:
<!-- osac-ci-locks:v1 {"head":"<sha>","jobs":{"e2e-vmaas":{"lock":"cost","state":"locked"}}} -->
locked means a lock holds the job, open that every lock is open, not-applicable that the job has nothing to do for
these files. A job without locks is not listed, and a policy without locks adds nothing to the check run. A job that
wants to know whether it may start asks osac-ci lock-status --repo R --sha SHA --job ID, which prints locked, open,
not-applicable or unknown, and needs no policy. It uses GH_TOKEN or GITHUB_TOKEN when one is set, and goes on without any for a public repository (GitHub's low unauthenticated rate limit applies, and a private repository answers as if the check did not exist, which is unknown). unknown covers every case with no usable fact (no
OSAC CI check on the commit, no block, a block for another commit, a job without locks, an API error); a caller decides
what it means, and for advisory use it means go on.
This is a fact for advisory use. Any workflow in a repository can write a check run of any name, so a pull request can post a block of its own. A job that reads it only decides whether to spend runner minutes, and a pull request could just delete that step. Anything that spends money or uses a secret must not read it; it asks the planner from a workflow the pull request cannot edit. The verdict itself never depends on this block.
For a job that only costs runner minutes, the composite action .github/actions/lock-gate reads the lock fact above and
answers locked, open, not-applicable or unknown. Put it in a job of its own and make the real job depend on it
with a job-level if:
gate:
runs-on: ubuntu-latest
outputs: {locked: "${{ steps.gate.outputs.locked }}"}
steps:
- id: gate
uses: osac-project/osac-ci/.github/actions/lock-gate@<full sha>
with: {repo: "${{ github.repository }}", sha: "${{ github.event.pull_request.head.sha }}", job: unit-tests}
unit-tests:
needs: gate
if: needs.gate.outputs.locked != 'true'- Skip the job, not its steps. A job whose steps are all skipped still ends as a success, which both GitHub and
OSAC CI read as a result, so the lock would turn into a pass. A job skipped by its own
ifends asskipped, which is not a result: OSAC CI keeps reporting it as locked, and shows it yellow, not red. - Only
lockedleaves the job out.open,not-applicableandunknown(no fact yet, an API error) all run it. A gate that cannot tell never blocks work. wait-seconds(0 to 300, default 0) waits for OSAC CI to post its check on the commit. Right after a push the workflows start before the check exists, so without a wait the first run of a new commit usually seesunknown.- A job that was left out does not start by itself when the lock opens. Something has to run it again: today a person or a re-run, and later the trusted starter that opens locks (the next step of the design). Until then this is for trying locks out, not for production gating.
- This is advisory, like the fact it reads. A pull request can remove the gate from its own copy of the workflow, and any workflow can write a check run of any name. Jobs that spend money or use a secret must be started by a workflow the pull request cannot edit.
A policy with an approval: section decides approval from GitHub reviews and CODEOWNERS, not from labels:
approval:
min_approvals: 1 # people other than the author
require_code_owners: true # every changed file with owners needs one of them
carry_over: trivial-rebase # or: never
merge:
required_labels: [jira/valid-reference] # the approval labels are no longer required- Only human reviewers count; the latest review of each person decides; outstanding "changes requested" blocks. The author never approves their own PR.
- CODEOWNERS is read from the base branch, so a PR cannot make its author an owner. Team owners are expanded with
the org members API; a team that cannot be read is a
planner-error, never a pass. - An approval given on an older commit still counts when the PR makes exactly the same changes (a rebase, or the base
merged in):
osac_ci/fingerprint.pycompares what each commit adds and removes, ignoring context, line numbers and blob ids. Re-indenting, a different mode or a different binary count as a change. The merge queue still tests the PR on top of the current base, so a rebase that changes behavior is caught there. - An approved PR also unlocks E2E the way the
lgtmlabel does.
Without an approval: section nothing changes: policy/osac.yml keeps the label rules.
osac-ci publish --policy P --repo R (--number N | --all) evaluates a PR from live GitHub state and posts one check
run named OSAC CI on its head commit (--dry-run prints instead of posting). The table of states above shows which
check conclusion each state maps to. The newest check run of a name decides, so publishing again repairs a stale
verdict, and an unchanged verdict is not posted twice.
.github/workflows/osac-ci-check.yml runs it for this repository's own PRs (policy policy/osac-ci.yml) on PR
events, when ci finishes, and every 10 minutes for all open PRs. The check is informational: it is not a required
status check.
Organization lookups (is the author a member, who is in an owner team) use a separate token from
OSAC_CI_ORG_TOKEN. In Actions it is minted from the osac-ci-reader GitHub App, which only has the organization
permission Members: read; the key lives in the org-read environment, which only main may use. A review runs the
pull request branch's own workflow file, so osac-ci-review.yml (no secrets, no checkout) just asks the main workflow
to re-evaluate that PR. Without the token the owner requirement cannot be checked and the verdict is a planner error,
never a pass.
Known limit: a slow sweep can post a verdict computed a few seconds earlier than a per-PR run's; the next event or sweep corrects it.
The verdict is recomputed on every event that changes it (a push, a label, a review, a finished workflow) and by a sweep every 10 minutes. Events can be missed and GitHub drops many scheduled runs, so a verdict can be out of date. How to tell and what to do:
| Symptom | What to do |
|---|---|
| The check shows a state that no longer matches the PR (an approval or a label is not reflected, a finished check still shows as running) | Comment /osac-ci refresh on the PR. The osac-ci-refresh workflow recomputes the verdict from live state and posts it. |
| There is no OSAC CI check on the head commit | Same comment. If it still does not appear, look at the runs of osac-ci-check for an error. |
The check says planner-error |
Read the message first: a missing organization token, an unreadable file or a GitHub error. Fix the cause (for example the org-read environment secrets), then comment /osac-ci refresh. |
| A refresh did not change a verdict that looks wrong | The verdict is computed from live GitHub state, so the state is what to inspect: osac-ci explain-pr --policy P --repo R --number N prints the same verdict and every blocker locally. |
| Many PRs look stale at once | Run the sweep by hand: gh workflow run <osac-ci-check workflow> -f number= (an empty number sweeps every open PR) or locally osac-ci publish --all --stale-only. |
Who may ask: the author of the pull request, or anyone GitHub reports as an owner, member or collaborator of the
repository, so an outsider cannot spend the API quota by commenting in a loop. Anything after /osac-ci refresh on the
line is ignored, and the comment never reaches a command line. The command is idempotent: refreshing an up-to-date
verdict posts nothing new.
How stale verdicts are: every sweep prints how many open PRs it had to look at (stale-only: 3 of 172 open PRs needed a new verdict) and why (no verdict, planner error, changed since the verdict, stuck in progress, old). To see it at any
moment: GH_TOKEN=... osac-ci publish --policy P --repo R --all --stale-only --dry-run.
A repository posts the check by calling the composite action .github/actions/publish, pinned to a full commit SHA of
osac-ci. The action installs the locked environment of osac-ci at that commit and runs osac-ci publish with a policy
file from osac-ci, so the code, the policy and the pin only change through a reviewed change in both repositories. The
calling workflow keeps the decisions that belong to it: which events trigger it, which token it holds, the concurrency
group and the sweep budget.
- uses: osac-project/osac-ci/.github/actions/publish@<full commit sha>
with:
github-token: ${{ github.token }} # needs checks: write and pull-requests: read
repo: ${{ github.repository }}
policy: policy/osac.yml
number: ${{ github.event.pull_request.number }} # or head-sha from a workflow_run eventA workflow_run event does not name a pull request that comes from a fork, and the usual "which pull request contains
this commit" lookup is empty for one. The action therefore finds it itself (osac-ci find-pr): the open pull requests
whose head is exactly the run's head commit. It does not use the run's head owner and branch: a run started by a review
reports the base repository as its head repository (with the fork's branch name), so they cannot identify the pull
request, and a pull request found from them could be an unrelated one. The commit is exact and works for those runs too,
which is what lets a review refresh the verdict at once through a tiny relay workflow, with no waiting for the next
sweep. A run whose commit is no longer any pull request's head matches nothing; the push that moved it has already
triggered its own refresh. Without a
pull request, sweep: "true" evaluates open pull requests within the recent, rotate, reserve and interval
budget described below. The action never checks out the calling repository, so it cannot run pull request code.
Two kinds of organization lookup exist, and they are controlled differently:
- Author membership (is the author, or the owner of the fork, a member; who authorized a commit). This is what
lookup-membershipswitches. By default it is off and membership is approximated from the author association GitHub reports (a member whose membership is private looks like an outsider). Withlookup-membership: "true"the lookup uses theorg-token, and a missing or unusable token turns the verdict into a visibleplanner-error. - Owner teams (the members of the teams named in CODEOWNERS). A policy with an
approval:section andrequire_code_ownersalways needs these, whateverlookup-membershipsays. They are read with theorg-tokenwhenever one is given. Without it the read fails and the verdict is aplanner-error, never a pass.
So a policy with native approval and team code owners needs an org-token even when lookup-membership is "false":
org-token: ${{ steps.org-token.outputs.token }} # short-lived token of the read-only app, minted by an earlier step
lookup-membership: "false" # still needed above for CODEOWNERS teams; "true" adds author lookupsThe queue runs the required checks on its own commit (gh-readonly-queue/<base>/pr-<n>-<sha>) and waits for all of
them, so a required OSAC CI has to be reported on that commit too, or the entry waits for its timeout.
osac-ci publish-queue --repo R --sha SHA --branch BRANCH evaluates the commit in queue mode (the jobs required at
queue; the files are the difference to the base branch, so they cover every PR in the entry) and posts the verdict:
successonce every required check passed,in_progresswhile one is pending or has not reported,failurewhen one failed. This one is red on purpose: anaction_requiredcheck would leave the entry waiting for its timeout instead of being ejected at once.- A commit that cannot be read is a visible
failurenaming the cause, never a pass. When the list of changed files cannot be read completely (the compare API stops at 300 files) no path-gated job is skipped on a guess.
A repository other than this one calls the composite action .github/actions/publish-queue (inputs repo, sha,
branch, policy, note, github-token), pinned to a full commit sha of osac-ci, from a workflow triggered by
workflow_run for the workflows that report its required checks. It refuses a branch that is not a queue branch before
the program runs, passes everything as data, and checks nothing out. .github/workflows/osac-ci-queue.yml runs it when a required check of a queue branch completes, from the default
branch. Two events carry that: workflow_run when a GitHub Actions workflow finishes, and check_run for a check from
another app. Both are needed: GitHub does not trigger check_run workflows when the check suite was created by GitHub
Actions (to prevent recursive workflows), so the Actions checks, which are nearly all of them, only arrive through
workflow_run. That event takes exact workflow names, no wildcards, so every workflow that produces a required check
has to be listed (here ci; for a larger repository the list has to be generated and kept in step with the policy). It
uses the built-in token only (no secret, no organization lookups) and ignores its own checks. A merge_group workflow
is deliberately not used: it would run the file from the queue commit, which holds the pull request's own changes.
workflow_dispatch takes a commit and a queue branch for a manual run. Not yet exercised on a real queue: whether
workflow_run fires for runs started by merge_group is to be confirmed on the first one.
osac-ci publish --all re-evaluates every open PR, which repairs a missed event. It costs requests, and the sweep runs
all day. Measured on 10 real osac PRs: about 7 requests per PR with label approval and about 11 with native
approval (CODEOWNERS, owner teams, change fingerprints), plus one to post. For about 140 open PRs one full sweep is
roughly 1,000 to 1,600 requests. GitHub documents 1,000 requests an hour per repository for the built-in token (more
on Enterprise Cloud), so a full sweep every 10 minutes would not fit in a large repository. Budget it:
--recent N: the N most recently updated PRs every sweep, since activity is where a verdict goes stale.--rotate M: plus M of the others, a different slice each sweep (the slice advances once per--intervalseconds, default 600, and wraps), so every PR is looked at within ceil(others / M) sweeps with no stored state.--reserve R: stop starting PRs once fewer than R REST requests are left. They are reported as skipped, not failed, and the next sweep picks them up. It is approximate: with several workers a few can read the same count before any has spent requests, so a sweep can overshoot by about the number of workers times the cost of one PR. A reserve alone only protects the quota; use it with--recentand--rotateso the PRs listed last are not starved. The recent and rotated PRs are interleaved, so under quota pressure both groups keep being served.--limitcaps the PRs of one sweep and is shared between the two groups. The count comes from the response headers of the REST bucket (core); GraphQL has its own bucket and never stands in for it.
A sensible setting for a repository of the size of osac is a run every 30 minutes with
--recent 15 --rotate 15 --reserve 150 --interval 1800: about 30 PRs, 240 to 350 requests a sweep, 500 to 700 an hour,
and the whole backlog covered in about 4 hours. --interval must match the schedule: the rotation advances one slice
per interval, so a 30-minute schedule with the default 600 seconds would skip two slices in three and never reach most
of the backlog. Event-driven runs (PR events, ci finished, reviews) still update a PR at once; the sweep only repairs
what they missed. A token with a higher limit (an app installation token) is the alternative if that is not enough.
The events already say when a verdict changes (a push, a label, a finished workflow); a sweep only exists to repair the
cases where an event was missed or its run failed. Those leave a trace that one listing shows without reading each PR.
osac-ci publish --all --stale-only lists every open PR with its newest OSAC CI check run (one GraphQL query, about 7
points per 100 PRs, from the GraphQL budget that the rest of a sweep does not use) and reads only the PRs where:
- there is no verdict on the head commit yet (most recently active first);
- the verdict is a failure, which for a pull request means a
planner-errorto retry; - the PR changed after the verdict was posted (a label, a review, a comment, a push), even when that verdict is still in progress;
- the verdict is still "in progress" after
--stale-afterseconds (default 1,800) with no change since, so the event that ends it may have been missed; - the verdict is older than
--max-ageseconds (default 21,600, 0 for never): a safety net for what no event reports, such as a team membership change or a CODEOWNERS change on the base branch; - the check run carries no time at all (a queued run that never started), so it cannot be compared with anything.
Among the stale PRs the one whose verdict went out of date longest ago goes first, so a busy sweep cannot starve it. PRs with no verdict start with the most recently active.
Everything else is skipped, so a quiet repository costs one or two requests a sweep instead of hundreds, and the sweep
can run every few minutes. A re-checked verdict that turns out unchanged is posted again, because the time of the newest
check run is what the next sweep compares with; otherwise a PR that only got a comment would be read by every sweep.
--limit keeps the most urgent first, --reserve still protects the REST quota, and the option cannot be combined with
--recent or --rotate. Verdicts are recognised by the app that posted them: the default is the built-in token of
Actions (app id 15368). A check posted with another app's token needs --verdict-app-id with that id, or 0 for any app;
without it every PR would look as if it had no verdict.
GH_TOKEN=$(gh auth token) uv run osac-ci report --policy policy/osac.yml --repo osac-project/osac --out-dir reportWrites report.md, report.html (filterable, self-contained) and report.json. A PR that cannot be read shows up
as a planner-error row, so nothing silently disappears. PR titles are treated as untrusted text in every format.
The open-pr-report workflow runs it every night and keeps the files as a workflow artifact and in the job summary. On the
built-in token it makes about 7 requests per PR, which may hit its rate limit on a very large run.
GH_TOKEN=$(gh auth token) uv run osac-ci compare --policy policy/osac.yml --repo osac-project/osacBefore anyone relies on the OSAC CI answer, it has to agree with the rule that gates merging today: the list of required status checks in the branch ruleset (read from the public branch-rules API, so it needs no administrative permission). For each open pull request, both answers come from the same snapshot, so a difference is never a timing accident. Each PR lands in one row:
| Outcome | Meaning |
|---|---|
agree-ready, agree-blocked |
Both say the same. |
looser |
OSAC CI says ready while a required check has not passed. The unsafe direction: if OSAC CI replaced the list, this PR would get in. Listed with the required checks in the way. |
stricter |
Every required check passed, OSAC CI holds the PR back (an approval, an authorization, an E2E signal, a lock). Safe, but it would block a PR that merges today. Counted by cause. |
unreadable |
The PR could not be read, so nothing is claimed about it. |
A required check counts as passed the way GitHub counts it: the latest run is complete and its conclusion is success,
neutral or skipped, and when the ruleset names the app the check must come from (every required check in OSAC does), a
run of the same name from another app does not count. A requirement that names no app can also be met by a legacy
commit status of that name in the state success (one extra request per PR, made only when such a requirement exists). The branch given with --branch must exist; a name that does not
is an error, because the rules endpoint answers with an empty list for any name and that would read as "nothing is
required". A PR already in the merge queue counts as ready. Draft PRs are left out (--include-drafts adds
them). The report also compares the two lists of checks: checks the ruleset requires that the policy does not know
(OSAC CI would not wait for them), and checks the policy requires that the ruleset does not.
GH_TOKEN=$(gh auth token) uv run osac-ci compare --policy policy/osac.yml --repo osac-project/osac --backfill 60Waiting for new PRs is slow, so --backfill DAYS asks the question about PRs merged into --branch in the last DAYS
days (the 100 most recently merged, or --limit). Each is rebuilt as it stood at the decision (the replay machinery):
when it was enqueued if the queue merged it, when it merged if it was merged directly. The outcomes are the same, split
by how the PR was merged (a PR that could not be read has an unknown route and is counted in its own column):
- For queue-merged PRs GitHub had already enforced the required checks, so
looseris not expected; the useful rows arestricter, the PRs OSAC CI would have held back that merged. - For PRs merged directly (a bypass of the rules)
looseris the row to look for: OSAC CI said ready while a required check had not passed.
Limits: the required checks are today's, not those of the time; commit statuses are not rebuilt for a past moment, so a requirement that names no app is judged on check runs alone.
The command exits 0 whatever it finds; --fail-on-looser exits 1 when there is a looser row. --json prints JSON
instead of markdown, and --json-file PATH writes the JSON next to the markdown so one run gives both. The
compare-required-checks workflow runs it every day and keeps the files as an artifact and in the job summary.
GH_TOKEN=$(gh auth token) uv run osac-ci e2e-decide --policy policy/osac.yml --repo osac-project/osac --number 1543The full-install E2E takes about 100 minutes of a real cluster, so it should start only for a pull request that is ready,
not on every push. e2e-decide answers "should it start for this pull request now?" from the current state of the PR,
and says why. It starts nothing and always exits 0 (3 when GitHub could not be read, never a guess). The answer is, in
this order:
| Answer | When |
|---|---|
skip-closed, skip-draft |
the PR is not open, or is a draft |
skip-not-applicable |
no E2E suite is affected by the changed files (docs only, osac-ui only), by the same path filters the workflows use today; a file list that is incomplete (the API stops at 3000) never skips a suite |
blocked-changes-requested |
someone with write access has an unresolved request for changes (CodeRabbit's advice never blocks) |
wait-approval |
nobody with write access (the repository role admin, maintain or write, read from the repository's own permission for that person, not from their association), and not CodeRabbit or the OSAC CI app (osac-ci[bot], which approves dependency updates), has approved the head commit; the author cannot approve their own PR. On a pull request from a fork (or from a deleted fork) the two bots do not count either: the author of a fork controls what CodeRabbit reads, so only a person with write access can start the run of that code |
blocked-cheap-checks-failed |
a cheap check has already failed (every required check except the E2E gates, check-labels, jira-reference and E2E); checked with the same meaning of "passed" as compare, including the app a check must come from and, for a requirement that names no app, a commit status of that name in the state success |
start |
none of the above; the suites to start are listed. Cheap checks that are still running or have not reported do not delay it: the E2E runs in parallel with them (both must pass for the merge), and they are listed in the reason |
A person's approval counts only when they can push to the repository: GET /repos/{repo}/collaborators/{user}/permission says admin or write (a maintainer or a custom role based on write is write; triage is read). Being an organization member or a collaborator is not enough, since either can have read or triage access only, and an approval starts a run of the pull request's code with secrets. The decision asks once per reviewer whose review could count; a reviewer who is not a collaborator has no role, and a failure to read a role is an error, never an approval. CodeRabbit and the OSAC CI app are the two bots that count without a role.
An approval counts only when it is on the current head commit. GitHub keeps an approval through a clean rebase and moves it to the new head (the repository has to dismiss stale approvals on push, so a real change drops it); a review left on an older commit is listed as "approval on an older commit (not counted)", so a repository without that rule is never read as approved by a stale review. The summary also lists the E2E checks that are already on the commit, which is what the current label-based flow did, so the two can be compared.
.github/actions/e2e-decide runs it from another repository's workflow (a job summary plus a JSON file), pinned to a
commit of osac-ci like the publish action. The workflow decides when, and it never waits for the cheap workflows: it asks when a pull request gets a push (so the status says what it waits for), after a review, and when the pull request changes between draft and ready. The E2E starts as soon as the approval is there, in parallel with the cheap workflows.
Besides json-file it has three outputs for a job that acts on the answer: ready (true only when exactly one pull
request was evaluated and the answer is start; every other case, including no pull request found or several with the same
head, is false), reason (one line), suites (a JSON list such as ["vmaas","caas"], empty when not ready) and number (the
pull request when exactly one was evaluated, whatever the answer; empty otherwise). Outputs
are strings, and the string false is truthy in a workflow expression, so test it explicitly, for example
if: needs.decide.outputs.ready == 'true' (or fromJSON(needs.decide.outputs.ready)); a bare if: needs.decide.outputs.ready
would start on false. A job that needs the list of pull requests reads the JSON file instead.
The full-install E2E starts from a workflow on the default branch, after the pull request is approved (CI runs in parallel and is not waited for), so
its jobs are not on the pull request. A required check that has not reported blocks the merge without saying why. These two
commands give the pull request ONE commit status named E2E and keep it up to date, from the first push to the result. It is
a commit status and not a check run on purpose: the pull request page draws statuses, but not check runs that a workflow
writes through the API (they hang on an unrelated workflow's check group and show as "Expected"). The newest status of a
context counts, so a status left behind never blocks a newer result.
GH_TOKEN=... uv run osac-ci e2e-report --repo osac-project/osac --decision-file decision.json --run-id 123 --run-url URL
GH_TOKEN=... uv run osac-ci e2e-finish --repo osac-project/osac --sha SHA --result success --run-url URLe2e-report takes the JSON file of e2e-decide and shows its answer on the status (the description is the title and the
reason in 140 characters; the link goes to the workflow run, whose job summary has the whole explanation):
| Answer | The status |
|---|---|
wait-approval, blocked-cheap-checks-failed, blocked-changes-requested, skip-draft |
pending, "Waiting for an approval: " and the like. Written again only when the text changes |
skip-not-applicable |
success, "Not affected by this change" |
start |
pending, "Running: ", linked to the workflow run; the report answers start=true to that run only. The same step writes a line for each suite (named by the policy, see below) as "Waiting for a runner", so the lines appear together |
skip-closed |
nothing |
A commit gets its E2E once. A later start answer for the same commit starts nothing when another run is still going
(already-running) or a result is already there (already-ran: re-run it from its workflow run, or push a new commit). A
run that ended without leaving a result is replaced and the E2E starts again. The same workflow run started again from the
Actions page starts again. The run that owns a status is read from its link. The calling workflow must run one decision at a
time per head commit (a concurrency group keyed by the commit, without cancel-in-progress): e2e-report looks again after
it claims and refuses a start when another run's claim is newer, but only serializing closes the race completely. Only
statuses written by github-actions[bot] are read. Nothing is written unless exactly one pull request has the head commit.
Actions: e2e-report (outputs start, sha, state, suites, number; compare start with 'true') and e2e-finish
(run it last, whatever happened). The token needs statuses: write, actions: read and pull-requests: read. The ruleset
requires the context E2E (a status carries no app, so the requirement names only the context).
All the E2E lines (E2E and the suite lines) can be written two ways, chosen by --kind (action input kind):
| Kind | What it is | What the page shows |
|---|---|---|
statuses (default) |
commit statuses of the token's identity (github-actions[bot] for a workflow's built-in token) |
a yellow dot for "pending" (there is no spinner for a status), the text after it |
checks |
check runs of the GitHub App whose installation token is given (osac-ci) |
a dot while queued (waiting for an approval, or for a runner), a spinner only while it runs, a tick or cross when completed, under the exact name |
A check run written with a workflow's built-in token is attached to the oldest check suite the Actions app has on that
commit, which may belong to a pull_request_review run, and the page does not draw those. An app installation token gets
the app's own suite per commit, which the page always draws. So checks needs a token minted for an app with
checks: write (the OSAC CI app), and the merge rule can then require the check from that app only. The kind changes only
where the lines go: the decisions, the claim and the newest-wins rule are the same.
The suites run in a workflow of the default branch, so their jobs are not listed on the pull request. Two commands put a line on the pull request's head commit for each suite that runs, so the person looking at the pull request sees which suite is running or failed, and its "Details" link goes straight to that suite's job logs.
Which suites exist, and what their lines are called, is the repository's policy, not this program's. Each E2E job of the
policy has a check: name (E2E / VMaaS Full Install, E2E / Netris Fabric, ...) and filters, which say when the suite
runs (a suite for a rarely touched area simply has narrower filters, and a change that does not match them gets no line for
it). e2e-decide puts the name of each suite it picks in its answer (suite_checks: suite id -> line name, the
suite-checks output of e2e-report), and the commands below take that object. A new suite is a policy entry and a
workflow job named after the suite; nothing changes here. The name must be one short printable line, unique among the
suites, and never E2E itself (the required line).
GH_TOKEN=... uv run osac-ci e2e-suites-track --repo osac-project/osac --sha SHA --run-id 123 \
--suite-checks '{"vmaas":"E2E / VMaaS Full Install","caas":"E2E / CaaS Full Install"}'
GH_TOKEN=... uv run osac-ci e2e-suites-settle --repo osac-project/osac --sha SHA --run-id 123 \
--suite-checks '{"vmaas":"E2E / VMaaS Full Install","caas":"E2E / CaaS Full Install"}'The job of a suite in the workflow run is found by the suite id: the calling job is named after the suite (vmaas), so the
job that does the work is listed as vmaas / ... / e2e; of the jobs under that name the deepest one that was not skipped
counts. e2e-suites-track runs next to the suite jobs: it writes "Waiting for a runner" while a suite's job is queued,
"Running" once it has a runner, links the job, and ends when every job runs (or after --timeout). e2e-suites-settle runs
last, in a job that needs the tracking job, whatever happened, and reads each suite's result from its job ("Passed",
"Failed", "Cancelled"; a suite with no job, or whose run stopped, is "Cancelled"), so the workflow passes no results. It
leaves a line that belongs to a newer run alone. A suite that does not run has no line. These are for people: the required
line stays E2E. Actions: e2e-suites-track and e2e-suites-settle (input suite-checks, plus sha, run-id, kind).
The token needs actions: read and the permission of the chosen kind (statuses: write, or an app token with
checks: write).
git diff --name-only BASE HEAD > files
uv run osac-ci e2e-suites --policy .github/e2e-policy.yml --files-from files # ["vmaas","caas"]
uv run osac-ci e2e-suites --policy .github/e2e-policy.yml --files-from files --checks # {"vmaas": "E2E / VMaaS Full Install", ...}A merge queue entry has no pull request to ask, but it needs the same answer: which suites does this change touch. e2e-suites
applies the E2E jobs of a policy to a list of changed files with the rules e2e-decide uses, and prints the suite names as
JSON. An empty list of files means nothing is known, so no suite is skipped. .github/actions/e2e-suites runs it from a
workflow (input policy-file and files-file, outputs suites and suite-checks). e2e-decide takes the same kind of policy through its
policy-file input, so a repository keeps its E2E policy next to its path filters and nothing is copied here.
GH_TOKEN=$(gh auth token) uv run osac-ci e2e-compare --policy policy/osac.yml --repo osac-project/osacFor every open, non-draft PR, the e2e-decide answer and the state of the real E2E jobs on the same head commit, both
read from the same moment. The real jobs are the check runs e2e-<suite>-full-install / e2e (not the gate placeholders):
absent or skipped means the current flow has not started that suite, in progress means running, anything else means it ran.
| Outcome | Meaning |
|---|---|
agree-start |
the decision says start and the E2E has started |
agree-wait |
the decision says wait or skip and the E2E has not started |
new-only |
the decision says start, the current flow has not started the E2E: it would run here if the new flow replaced the old one |
old-only |
the current flow started the E2E, the decision would not (no approval, a cheap check failed, changes requested, not affected by the change): it would not run here. Counted by the decision's reason |
--json, --json-file, --include-drafts, --limit, --workers and --branch work as in compare; only PRs that target
--branch are compared, since that branch's rules decide what the cheap checks are. It reads the required
checks once and about six requests per PR. A PR that cannot be read is its own row.
GH_TOKEN=$(gh auth token) uv run osac-ci bot-decide --repo osac-project/osac --number 1569Dependabot and Konflux open many small pull requests that only move versions. With native GitHub approval each would need
a person. bot-decide answers whether an automatic approval is safe, and says why not otherwise. It approves nothing
and needs no policy file. The answer is approve only when everything holds on the exact head commit:
- the PR is open, not a draft, and its author is a known bot account (
dependabot[bot]or the Konflux app) of type Bot; - every commit is listed (a PR with more than 250 commits cannot be, so it is refused) and is a plain commit with one
parent, authored by the bot, committed by the bot or by GitHub (
web-flow), and carrying a valid signature. A person who adds a commit, rebases by hand, or presses "Update branch" breaks one of these, and then a person has to approve; - every changed file is a dependency file (
go.mod,go.sum,package.json, lock files,pyproject.toml,Containerfile*,Dockerfile*,requirements*.txt) and the files list is complete. A workflow, a script or a source file is never approved, whoever wrote it.
Other answers: skip-closed, skip-draft, skip-not-a-bot, refuse-commits and refuse-files (every problem is
listed, not only the first). The approval would have to be made for the head commit it was checked on, so a later push is
not covered, and the repository's own rule dismisses it. The author identity alone is metadata: the signature and the
committer make it hard to forge and the file list limits the harm if it were. .github/actions/bot-decide runs it from
another repository's workflow, pinned to a commit like the other actions.
GH_TOKEN=$(gh auth token) OSAC_CI_APPROVE_TOKEN=... uv run osac-ci bot-approve --repo osac-project/osac --number 1569bot-approve makes the same decision and, only for an approve answer, submits an approving review. It writes, unlike
bot-decide, and takes two credentials: GH_TOKEN (or GITHUB_TOKEN) reads, OSAC_CI_APPROVE_TOKEN (the approving
account's or app's own credential) only submits the review, so the reading token never needs the right to approve. The
command stops before reading anything if the approval credential is missing. The review is made for the commit the answer
was made for (commit_id); the pull request is read once more just before, and a changed head, a closed or draft pull
request is an error and approves nothing. A repeated event does not stack reviews: an approval of this commit that
carries the marker of this command is left alone. A refusal by GitHub is an error (exit 3), never an answer. Results:
approved, already-approved, not-approved (with the reasons of bot-decide). .github/actions/bot-approve runs it
from another repository's workflow, with outputs json-file, approved (a string: compare it with 'true') and
reason.
GH_TOKEN=$(gh auth token) uv run osac-ci replay --policy policy/osac.yml --repo osac-project/osac --days 60 --limit 100PRs the merge queue merged are held to the planner: it must call each one ready, and every disagreement must be listed
in parity/explained.yaml. PRs merged directly (outside the queue) are reported separately as bypasses, with the
verdict the rules would have given. The run exits 1 only for unexplained queue-merged disagreements.
By default (--at final) it reads each PR's final state. --at enqueue judges the decision that was actually made:
each PR as it stood when it was enqueued (a direct merge: when it merged), rebuilt from the timestamps on its label
events, reviews and check runs (osac_ci/timeline.py). A check that finished after that moment was still running
then; a label applied after it is not credited to the decision. A queue-merged PR agrees when today's enqueue rule held
at that moment, which is what the system applied: auto-queue.sh reads labels and draft state and never a check
result. The full verdict is shown next to it, so the report counts what the planner would have held back that the
current flow let in. Direct merges are held to the full verdict at the moment they merged.
Measured on OSAC, last 60 days, final state: 100 merged PRs; 85 through the queue (the planner agrees on all 85); 15
merged directly, of which 12 the rules would have blocked. At decision time (--at enqueue), a later window of 100
PRs: 76 through the queue and the enqueue rule held for all 76; the planner would additionally have held back 4 of them
because checks or E2E were not ready then. 24 merged directly, of which the planner would have blocked 18 at the
moment they merged (most while a required check was still running).
This repository decides whether code may merge, so it is built to be hard to abuse:
- Two trust zones. Workflows that hold a credential (
osac-ci-check,osac-ci-authorize) are triggered by events that run the default branch's workflow file and check out only the default branch. No pull request code runs in them. Workflows that run pull request code (ci) hold no secret. - Pull request text is data. Titles, branch names and comment bodies reach programs through environment variables, never inside a shell script or a workflow expression.
- Least privilege. The organization lookups use a GitHub App that can only read organization membership; its key
lives in an environment only
mainmay use. Check-writing workflows havechecks: writeand nothing more. - Fail closed. An unreadable team, an unreadable file list or a bad policy is a visible failure, not a pass.
- Reviewed policy.
policy/,CODEOWNERSand.github/need approval from@osac-project/wg-infra; the repository's ruleset requires code-owner review. - Pinned dependencies. Actions are pinned to full commit SHAs; Python dependencies are locked in
uv.lock;zizmorlints the workflows in CI. - Exact-commit authorization.
/ok-to-testis bound to a full commit SHA and verified against live organization membership (see Who may use secrets).
uv sync
uv run ruff check . && uv run ruff format --check . && uv run basedpyright
uv run pytest # everything, including differential tests if the sibling repos are present
uv run pytest -m "not legacy" # without the legacy differential testsThe tests are layered:
| Layer | Where | What it proves |
|---|---|---|
| Unit | tests/unit/ |
each rule and the planner on small cases, with recorded snapshots for the explanation text |
| Property | tests/property/ |
invariants over generated PRs (for example "never ready while a required check is failing") |
| Contract | tests/contract/ |
the GitHub adapter, publisher, replay and report against a fake GitHub; the path matcher against recorded output of the real action |
| Legacy differential | tests/legacy/ |
the real bash functions from sibling checkouts (../osac, ../osac-test-infra) against the Python port, labels exhaustively and readiness with generated scenarios. Point OSAC_AUTO_QUEUE_SH and OSAC_CHECK_E2E_READINESS_SH at other copies if your layout differs |
| Replay | osac-ci replay |
the planner against real merged PRs |
Read AGENTS.md before changing anything. Policy and workflow changes need @osac-project/wg-infra review.