Workflow trigger audit
praetorctl audit and the MCP standards_audit read every workflow under .github/workflows
and report the triggers that start runs nobody needs: a push that runs on every branch, and a
pull request job that runs in full on a draft. This is the HISS-18 (CI efficiency) check on a
repository's own workflows. One adopter repository measured more than 1,000 workflow runs a day
from exactly these two shapes (#817).
The check is one function, forge.AuditWorkflowTriggers in
internal/forge/workflow_trigger_audit.go, and both audits call it
(auditWorkflowTriggers in cmd/standardsctl/audit.go, auditGates in
cmd/standards-mcp/audit_tools.go). Findings are warnings: the audit passes and lists them
(Why findings warn).
What the audit reports
Each finding names the workflow file, the line of the trigger or job, and what starts the run:
| Finding | Why it costs runs | Test |
|---|---|---|
push with no branches or tags filter, in any of its three shapes (on: push, on: [push], a push: mapping) |
Every push to any branch or tag starts the workflow, before a pull request exists. A paths filter alone does not narrow the branches. |
TestAuditWorkflowTriggers_Negative_ReportsEachWastedRun |
push with only a branches-ignore filter |
Every branch the filter does not name starts it. | same |
push with a branches pattern made of asterisks alone, such as '**' or '*' |
'**' matches every branch name, and '*' every branch name without a slash. |
same |
A job a pull_request run starts whose first step is not the draft step |
A draft runs the whole job, and runs it again on every push to the draft. | same |
A job whose condition reads github.event.pull_request.draft |
GitHub reports a job its condition skipped as successful, and a required check accepts that, so a draft marked ready can merge on the skip. The one exception is the opt-in draft skip, accepted behind a proven aggregate (below). | same |
| A job that needs a job held back on a draft, whatever its condition and its own first step | GitHub skips a job whose need failed or was skipped unless its condition runs it after one, and reports the skip as successful, which a required check accepts, so a draft passes the job without its work; its own draft step never runs. The check does not read the status functions in the condition (Limits). Each job of a chain is reported, naming the need that holds it back. | TestAuditWorkflowTriggers_JobsSkippedThroughNeeds, TestAuditWorkflowTriggers_NeedSkipWhateverTheCondition, TestAuditWorkflowTriggers_NeedsChainAtTheJobBound |
Jobs whose needs form a cycle, or need a job on one |
No order runs them, so the check cannot say what they do on a draft. One finding names them all, on the line of the first of them. | TestAuditWorkflowTriggers_ReportsNeedsCycle |
A pull_request trigger whose jobs stop on a draft but that does not list ready_for_review in types |
Marking the draft ready starts no run, so its failed check stands until the next push. | same |
A step in a pull_request workflow or branch-push workflow running a full-tree command without a guard limiting it to default branch, tags or schedule |
Commands such as pre-commit run --all-files, prek run --all-files, lefthook run <hook> --all-files, any --all-files flag, molecule test --all, or pre-commit/action without extra_args (its default is --all-files) scan the entire repository on every pull request or branch push instead of scoping to changed files. A job or step condition counts as a guard only when it names the default branch, a tag or schedule and carries no \|\|, != or negation, which could run the step on a pull request again. |
TestAuditWorkflowTriggers_FullTree_Negative_PullRequestPreCommitAllFiles, TestAuditWorkflowTriggers_FullTree_Negative_MoleculeAll, TestAuditWorkflowTriggers_FullTree_PushFilters, TestAuditWorkflowTriggers_FullTree_Negative_WideningConditions, TestAuditWorkflowTriggers_FullTree_PreCommitAction |
pull_request_target and issue_comment triggers are not judged; the permission audit covers
them (forge.AuditPullRequestPermissions).
What passes
The accepted shape is the one the hosted gates Praetor ships already use, defined once in
internal/ghworkflow/hostedgate.go (When the workflow runs):
push filtered to the default branch, pull_request on opened, synchronize, reopened and
ready_for_review, and a job whose first step, Stop on a draft pull request, fails a draft
with an error annotation. The step names shell: bash, because a step without a shell runs
under PowerShell on a Windows runner, or under a defaults.run.shell, which cannot read its
script and so prints no annotation; a draft step without it is reported like a missing one.
The check reads that step through ghworkflow.DraftStepFault, the
function the hosted gate check uses, so the audit and the emitter cannot disagree
(TestAuditWorkflowTriggers_EngineWorkflowsPass, TestDraftStepFault).
An optional job passes too. A job with continue-on-error: true is no required check:
requiredJob in internal/forge/workflow_checks.go leaves it out of the required contexts, so
its success on a draft lets nothing merge. Such a job may stop a draft in its first step, or skip
it with the job condition, and is no finding; it is still one when it runs its work on a draft
(TestAuditWorkflowTriggers_Positive_OptionalJobMayStopOrSkipADraft). A code scanning job that
must not block a merge takes this shape.
Each job that does work begins with the draft step itself rather than needing another job that does. A job also passes when:
- it calls a reusable workflow of this repository (
uses: ./.github/workflows/<file>) whose jobs, judged with their needs like the caller's, each begin with the draft step and need no job held back on a draft, or never start on a pull request, as long as at least one of them does start; or whose jobs all never start on a pull request, so nothing runs on a draft. A job that needs such an idle caller is judged on its own, because the check does not read how GitHub reports the caller (TestAuditWorkflowTriggers_FollowsNeedsInsideLocalCallee,TestAuditWorkflowTriggers_CalleeNeedsChain); - a
pull_requestrun never starts it, such as a deploy job guarded bygithub.event_name != 'pull_request', or it has no condition of its own and needs such a job, which skips it on every pull request and not only on a draft.
A push filtered to named branches, such as [main, 'release/**'], or to tags alone passes.
TestAuditWorkflowTriggers_Positive_HostedGateShapePasses covers each of these.
The opt-in draft skip
The opt-in draft skip is accepted by shape in any workflow, including managed hosted gates
rendered with hosted_gates.draft: skip: the gate carries exactly
github.event_name != 'pull_request' || github.event.pull_request.draft == false, and a proven
aggregate in the same workflow needs it, runs under always() alone and fails when it is skipped
(forge.DraftSkipFault, using internal/forge/workflow_aggregate.go). Jobs that need the gate
are also accepted when that same aggregate needs and proves failure coverage for each of them;
an omitted dependent is still reported, as is a dependent whose own condition reads the draft
flag. An aggregate that passes on the gate's skip proves none of these jobs
(TestAuditWorkflowTriggers_DraftSkipFanout).
The check orders a workflow's jobs by their needs (util.DependencyOrder, Kahn's algorithm) and
decides each job once, after every job it needs, from the job itself and the decisions of its
needs. The names of the jobs therefore never change a verdict
(TestAuditWorkflowTriggers_VerdictIndependentOfJobNames renames one workflow every way).
Workflows Praetor writes and runs
Every workflow Praetor ships follows the shape, so a fresh adoption and Praetor's own audit report no finding:
- Flavor CI workflows. The
ci.ymlthatpraetorctl flavor apply(and sopraetorctl adopt) scaffolds for the Go, Rust, Node, JVM and Flutter flavors renders the shape from the same constants through the template functionshostedGateOn,hostedGateDraftStepandhostedGateStepIf(templates/hostedgate.go): it runs on a push tomainand on the four pull request types, its first step is the draft step, and every later step carriesgithub.event.pull_request.draft != true. Each rendering passesghworkflow.HostedGateFault(TestCIWorkflowTemplatesRenderTheHostedGateShapeintemplates/hostedgate_test.go), and a fresh adoption of each flavor audits clean (TestAdoptThenAuditReportsNoFlavorWorkflowTriggerFindingincmd/standardsctl/audit_workflow_triggers_test.go). - Superseded runs and checkout credentials. Every emitted gate (the flavor CI workflows,
the Go Security Scan,
praetor-api.yml,praetor-docs.ymlandreuse.yml) carriesghworkflow.HostedGateConcurrencyright after its trigger block: the group is the workflow plusgithub.event.number || github.run_id, andcancel-in-progressis true only forpull_request. A new push to a pull request cancels that pull request's older run. Every other event gets a group of its own run id, because GitHub replaces the older pending run of a group whenever a newer one joins, even withoutcancel-in-progress: a group keyed on the branch would drop queued pushes tomain. Workflows without apull_requesttrigger (Praetor's ownadopt.yml,adopt-comment.yml,release-binaries.yml,wiki-sync.yml) carry no group, since a run-id group would change nothing there. Everyactions/checkoutstep in an emitted gate setspersist-credentials: false, since no gate pushes and the default leaves the job token in.git/configfor every later step (zizmorartipacked).ghworkflow.HostedGateFaultrefuses a gate without the exact group or with a checkout that keeps the token (TestHostedGateFault_Negative_RequiresConcurrency,TestHostedGateFault_CheckoutCredentialsininternal/ghworkflow/hostedgate_test.go). - Updating an adopted copy. Every text the CI templates rendered before is recorded
(
internal/flavor/ci_prior.go, fixtures ininternal/flavor/testdata/ci-prior), so a plainpraetorctl adoptorpraetorctl flavor applyrefreshes an uneditedci.ymlwithout--forceand an edited one stays until--force(TestCIWorkflowApply_Positive_RefreshesAnEarlierText,TestCIWorkflowApply_Negative_KeepsAnEditedCopyininternal/flavor/ci_prior_test.go). - Praetor's own workflows.
ci.yml,compliance.yml,pages.yml,portability.ymlandsecurity.ymlrun onready_for_reviewand begin every job a pull request starts with the draft step; their schedule, dispatch and protected-branch push triggers stay.ci.ymlalso keepsedited, because its governance step reads the pull request body, and its push oncheckpoint/**, the hosted run behind the lighter checkpoint pre-push policy (remote checkpoints). A push carries no pull request, so the draft step never stops that run. Beside a checkpoint's draft pull request it reports the same contexts as the draft run, and the checkpoint planner, which keeps the newest run of each job (_latest_runsin.config/lefthook/scripts/checkpoint.py), reads the pair as draft pending or as the push run's own result (test_review_checkpoint_push_run_beside_draft_runin.config/lefthook/scripts/test_checkpoint.py). A matrix step that runs after a failure (!cancelled()) carries the draft term too. Every context the committed ruleset requires keeps its name and fails a draft in its draft step (TestEngineRequiredContextsFailOnADraftininternal/forge/workflow_draft_contexts_test.go).
On a draft, each required check therefore fails with the annotation the checkpoint planner reads
as draft_pending (checkpoint cadence); marking the pull request ready
starts the run that replaces the failure on the same head commit.
Why findings warn
HISS-18's failure action is a CI optimization gate (internal/hisscatalog/catalog.go), the one
HISS rule whose failure rejects nothing; the HISS specification
describes it as a filter that, when in doubt, runs more rather than blocks. A wasted run costs
minutes, not correctness, so the check prints a [WARN] line per finding and the audit passes.
Failing would also block every commit of an adopted repository whose own workflows predate the
shape, or whose ci.yml is an edited copy of an earlier flavor rendering that adoption does not
refresh, because the pre-commit hook runs praetorctl audit --offline. Such a ci.yml is still
reported (TestAdoptThenAuditReportsNoFlavorWorkflowTriggerFinding in
cmd/standardsctl/audit_workflow_triggers_test.go).
The check does fail when it cannot run: a workflow it cannot parse, more workflows than it reads
(64), or a malformed HISS-18 exceptions entry
(TestAuditWorkflowTriggers_SkipsWithoutWorkflowsAndFailsUnread). A repository without
.github/workflows is skipped with that reason stated.
Declaring a workflow that must run everywhere
A workflow that must run on every branch push or on drafts, such as a branch-preview deployment,
is declared in the top-level exceptions list of .standards.yaml with rule HISS-18:
exceptions:
- rule: "HISS-18"
path: ".github/workflows/preview.yml"
reason: "deploys a preview of every branch for review"
expires: "2026-12-31"
The entry names one workflow file directly in .github/workflows by path; a glob is refused
(TestValidateExceptionsWorkflowTarget in internal/config/exceptions_test.go). The other keys
follow the exceptions list rules: one line of reason and an
expiry within the category interval (A: stated, at most 90 days; B: 3 days, C: 10 days, D: 120 days; uncategorized: 90 days).
While the entry holds, through its expiry day, the audit prints the workflow's findings under a
[PASS] line with the reason and expiry instead of reporting them. From the next day they are
reported again, led by a line naming the expired entry. An entry naming a workflow without
findings is reported as stale until it is removed (TestAuditWorkflowTriggers_Exceptions).
Reading the output
A repository whose workflows all pass gets one line:
[PASS] Workflow triggers (HISS-18): workflows read: 14; no push trigger runs on every branch and no job a pull request starts runs its work on a draft.
A finding is one line, and the last line counts them and names both ways out:
[WARN] Workflow triggers (HISS-18): .github/workflows/ci.yml:24: pull_request job verify runs on a draft pull request: its first step is not the draft step "Stop on a draft pull request".
[WARN] Workflow triggers (HISS-18): 1 of 14 workflows start runs this check counts as wasted (findings: 1); reported, not enforced, because HISS-18's failure action is a CI optimization gate. ...
Limits
- A reusable workflow of another repository cannot be read, so a job calling one is reported.
- The audit reads three things from a job condition: whether a
pull_requestrun can start the job at all, whether it readsgithub.event.pull_request.draft, and whether the job has a condition. It does not read the status functionsalways(),failure(),success()andcancelled(), so a job that needs a job held back on a draft is reported whatever its condition says. A condition can mentionalways(),failure()orcancelled()and still skip the job after a failed need, as!failure() && !cancelled()does, so the audit reports by default instead of guessing. That includes the aggregate merge gate that begins with the draft step and runs withif: always(), which does fail a draft through its failed need. Only the opt-in draft skip passes through the provenalways()aggregate; for any other such workflow, declare it in the exceptions list. - The audit does not read which jobs the ruleset requires, so every job of a need chain is reported, not only the required one.
- A job with a condition of its own that needs a job a
pull_requestrun never starts is judged alone, as if it had no needs:always()runs it on every pull request. - A reusable workflow that a reusable workflow of this repository calls in turn is not read, so the job calling the first one is reported. So is a job calling a reusable workflow of this repository whose jobs' needs form a cycle.
- Steps after the draft step are not read: a later step whose condition calls
always()still runs on a draft. Praetor's own required checks are held to that by a test instead (TestEngineRequiredContextsFailOnADraft). - The run budget part of #817 (runs and run-minutes per workflow from the forge) and the check for a workflow that re-runs on every base-branch push are not implemented.