Skip to content

Live Actions checks

praetorctl audit judges a repository on its files, and four GitHub facts are not in any file: the repository's workflow permissions setting, whether its workflows actually run and pass, the repository's visibility, and the branch protection GitHub enforces on the default branch. When the audit can reach the forge, it reads all four and compares them with what the manifest declares. Nothing is written: praetorctl sync --remote reconciles the branch protection (Branch protection) and repository metadata, while repository visibility stays the operator's decision.

Quick start

Declare the workflow permissions the repository needs in .standards.yaml:

overrides:
  actions:
    default_workflow_permissions: read
    allow_create_and_approve_pull_requests: false

Then run the audit in a checkout whose origin remote names that repository on github.com, with a token available:

praetorctl audit

The last lines of the audit report the four checks:

[PASS] Actions workflow permissions compared with the forge: live workflow permissions match the declaration.
[PASS] Repository visibility compared with the forge: GitHub reports public.
[PASS] Workflow runs on main read from the forge: 12 workflows checked, none failing or never run (adopt.yml, adopt-comment.yml, ci.yml, ...).
[PASS] Live branch protection of main compared with the forge: GitHub enforces every declared property (protected by ruleset "praetor-main-protection" #7).

When the audit asks the forge

The audit asks only when every condition holds (openActionsForge in cmd/standardsctl/audit_forge.go):

  • --offline was not passed.
  • The manifest sets repository.owner and repository.name.
  • The origin remote names that repository on github.com, the check sync --remote uses, so a report never describes another repository's settings.
  • A token is found: GITHUB_TOKEN, then GH_TOKEN, then the gh CLI session.

Otherwise each check prints [SKIP] with the reason, and the audit goes on. A check the forge refused (401, 403 or 404, a missing scope, a rate limit) or could not answer is reported the same way. A check that was not made is never printed as a pass (TestAuditLiveActions_Boundary_ChecksNotMadeAreNamed, TestAuditLiveBranchProtection_Boundary_NotComparedIsNamed). This repository's CI runs the audit without a token (.github/workflows/ci.yml, .github/workflows/compliance.yml), so there every check prints [SKIP].

praetorctl plan previews the permission comparison under the same conditions and never fails on it, as it never fails on file drift. plan --offline skips it.

In Git hooks and CI

The lefthook.yml adoption writes runs audit --offline before a commit and audit before a push (Local Git hooks):

  • A commit never asks the forge, so it never waits on the network and never fails on a forge setting. The fallback pre-commit hook, written when lefthook cannot install, is offline too.
  • A push asks the forge under the conditions above, since it needs the network anyway. A drifted permission verdict or drifted branch protection refuses the push until the setting the verdict names is fixed.
  • make verify-all and CI run the online audit. Whether they reach the forge depends on the token they provide.

One online audit makes up to 130 requests for the Actions checks: two for the workflow permissions, then one or two for each workflow, at most 64. The branch protection comparison adds three, and one more page for each further 100 active rules or rulesets. Each request is bounded at 15 seconds and all of them at three minutes. Without GITHUB_TOKEN or GH_TOKEN it also runs gh auth token once. A forge that does not answer costs about 45 seconds, one timed-out request for each check, and every check prints [SKIP]; an unreachable forge never refuses a push. A lefthook.yml an earlier release wrote, whose pre-commit audit still read the forge, is migrated to the offline one on a plain praetorctl adopt, without --force.

Workflow permissions

With overrides.actions declared, the audit reads GET /repos/{owner}/{repo}/actions/permissions/workflow and the organisation's GET /orgs/{owner}/actions/permissions/workflow (GitHubDriver.WorkflowPermissions in internal/forge/actions_state.go). Reading the repository value needs a token that can read its administration settings; the organisation value is read when the token allows it.

The comparison (EvaluateLiveActionsPermissions in internal/forge/actions_permissions.go) has one passing verdict and four failing ones. Each failing verdict fails the audit:

Verdict Meaning Where to fix it
compliant The live value matches the declaration. Nothing to fix.
drifted-by-organisation The repository differs from its declaration and matches its organisation, so it inherits the organisation value. Pin the declared value on the repository.
drifted-at-repository The repository differs from both its declaration and its organisation. The repository setting.
blocked-by-organisation The repository declares allow_create_and_approve_pull_requests: true and the organisation denies it. The organisation setting; the repository cannot raise it.
drifted The repository differs from its declaration and the organisation value was not read: the owner is a user account, or the token cannot read the organisation. The source is not named.

A manifest without overrides.actions is not failed, and the audit does not ask the forge about it. A declared block must set default_workflow_permissions to read or write; any other value is a manifest error (internal/config/actions_policy.go, TestActionsPolicy_Negative_MalformedDeclarationsFail).

Repository visibility

With repository.visibility declared in .standards.yaml, the audit reads the repository object from GitHub (GitHubDriver.RepositoryVisibility in internal/forge/repo_metadata.go) and compares it with the declared value (forge.VisibilityDrift). Drift fails the audit:

Outcome Line
Live visibility matches declared [PASS] Repository visibility compared with the forge: GitHub reports <visibility>.
Live visibility differs from declared [FAIL] Repository visibility compared with the forge: manifest declares <declared>, GitHub reports <live>; changing visibility is the operator's decision
No repository.visibility declared [INFO] Repository visibility not compared with the forge: repository.visibility declares none.
Refused or unreadable [SKIP] Repository visibility not compared with the forge: <reason>

A manifest without repository.visibility is not failed, and reports as not compared (TestAuditRepositoryVisibility_Boundary_UndeclaredIsInfo). A case-insensitive match (such as Public matching public) passes (TestAuditRepositoryVisibility_Boundary_CaseInsensitivePasses). When declared visibility differs from what GitHub reports, the audit fails (TestAuditRepositoryVisibility_Negative_DriftFails). praetorctl sync --remote reconciles other repository metadata (description, homepage, topics) but never changes repository visibility: making a repository public or private remains the operator's decision.

Workflow runs

For each workflow under .github/workflows, the audit reads its latest 20 completed runs on the default branch (forge.RepositoryDefaultBranch), and, when there is none, whether it has run on any branch at all (GitHubDriver.WorkflowRunHistory, AuditWorkflowRunHealth in internal/forge/workflow_run_health.go). Pull request runs are skipped, and a cancelled, skipped or neutral run neither extends nor ends a failure streak.

Finding Line
Latest decisive run succeeded Listed in the summary only.
Latest decisive run failed [WARN] with the number of failed runs in a row and the latest failed run; at least 20 when the forge lists a full window of 20 and no success ends the streak inside it. A shorter history is the whole one, so its streak is exact (TestAuditWorkflowRunHealth_Boundary_ShortFailureHistoryIsExact). For an undeclared failing workflow, the line ends with the declaration route: ; declare it in workflow_runs.expected with state failing, a reason and an expiry while the fix lands.
No run on any branch [WARN] naming the triggers from the workflow's on: key.
No completed run on the default branch, runs elsewhere [INFO] with the newest run, such as a tag-triggered release.
Only cancelled, skipped or neutral runs in the window [INFO] with the newest completed run.
Only workflow_call triggers [INFO]; not read, since it runs inside its callers' runs.
The workflow this check runs inside (GITHUB_WORKFLOW_REF) [INFO]; not read. Its latest run on the branch can be the run that failed on this check's own finding, so judging it from inside would keep every later run red; audits outside it judge it (TestAuditWorkflowRunHealth_Positive_OwnWorkflowNotJudgedFromInside).
Unknown to the forge [INFO]; the file is not on the default branch yet.
Stale declaration (workflow healthy again) [WARN] naming the declared state and that the declaration no longer applies, so strict zero-warning gates count it.
Expired declaration [WARN] naming the failure streak and the date the declaration expired on; behaves like no declaration.

These findings are reported and never fail the audit: the summary line ends with reported, not enforced (TestAuditLiveActions_WorkflowRunsAreReportedNotEnforced). The first read the forge refuses ends the run check with [SKIP], so a report never looks complete over workflows it could not read.

Declaring a known state

A workflow that fails on purpose, or has not had a reason to run yet, is declared with a reason so it stays visible without being counted:

workflow_runs:
  expected:
    - workflow: nightly-build.yml
      state: failing
      reason: the compile step refuses until its toolchain is packaged
      expires: "2026-11-15"
    - workflow: release.yml
      state: unexercised
      reason: no release tag has been pushed yet
Field Type Description
workflow string Bare .yml or .yaml file name directly under .github/workflows.
state string Expected state: failing or unexercised.
reason string One nonempty line of at most 500 bytes.
expires string Date after which the declaration stops holding (YYYY-MM-DD), at most 90 days ahead (config.MaxExceptionDays). Mandatory for failing, optional for unexercised.

A declaration covers only the state it names. A workflow declared unexercised that runs and fails, or one declared failing that has never run, is still warned about. An expired declaration behaves exactly like no declaration: the finding warns, and its line says the declaration expired on <date>. A declared workflow that is healthy again prints a [WARN] stale-declaration line (the declaration no longer applies) so strict zero-warning gates count it, and a declaration naming a file that is not under .github/workflows is warned about (TestAuditWorkflowRunHealth_Boundary_DeclarationsCoverOnlyTheirState).

workflow is a bare .yml or .yaml file name, state is failing or unexercised, reason is one nonempty line of at most 500 bytes, and expires is a YYYY-MM-DD date at most 90 days ahead (config.MaxExceptionDays); expires is required for state failing and optional for unexercised. A missing expires on a failing declaration is a manifest validation error naming the field. At most 64 workflows can be declared, and each only once (TestActionsPolicy_Boundary_BoundsAndAbsence). The section is repository-only: no profile, facet or fleet file can set it.

Branch protection

The committed ruleset gate compares .github/rulesets/main.json with the ruleset the declared policy renders (Branch protection rulesets). A file says nothing about what GitHub enforces: a ruleset that was never applied, one left in evaluate mode, or legacy branch protection that requires other checks all passed that gate (#159). So the audit also reads the branch protection of the default branch (forge.RepositoryDefaultBranch) and compares it with the declared policy (auditLiveBranchProtection in cmd/standardsctl/audit_forge.go). The read and the comparison are the ones praetorctl plan --remote prints (compareLiveProtection in cmd/standardsctl/protection_report.go):

  • Both mechanisms are read: the active rules of every ruleset that targets the branch, the repository's and its organisation's, and the legacy protection object (GitHubDriver.ReadBranchProtection in internal/forge/branch_protection_live.go). GitHub enforces their union, so a branch protected by legacy protection alone is compared on what that object requires.
  • Each declared property is compared (EvaluateBranchProtection in internal/forge/branch_protection_eval.go): pull requests, the approving review count, code-owner review, stale review dismissal, signed commits, linear history, deletion and force pushes blocked, and the required status checks: the jobs that report on every pull request in that repository, read from the workflows of origin/<default branch> as the checkout last fetched it (forge.RequiredStatusContextsAt, auditProtectionTarget).
  • When the repository has the ruleset praetor writes, praetor-main-protection, its enforcement must be active. GitHub applies no rule of a ruleset in evaluate or disabled mode and leaves its rules out of the branch's rule list, so the audit reads the mode from the ruleset listing.

A property the branch does not enforce fails the audit, and the failure names each one with its declared and live value. A branch whose legacy protection requires other checks than the declared one, and no signed commits, reads:

[FAIL] Live branch protection of main on GitHub does not match the declared policy (protected by branch protection):
  Signed commits: declared required, live not enforced
  Required status checks: declared 1, live 0 of 1 required; missing: CI Gate
To reconcile it, run 'praetorctl plan --remote' and then 'praetorctl sync --remote' from an up-to-date checkout of main, not from another branch:
  both require the status checks of the workflows of the checkout they run in, and sync keeps every check it finds required, so a sync from another branch requires its jobs that main does not run, and they block every pull request until removed by hand.
  sync --remote also writes the labels in .config/labels.yaml and the repository description, homepage and topics; plan --remote previews only the branch protection, so review those first.
  Both read the token from --token, GITHUB_TOKEN or GH_TOKEN, never from the gh session.

The remedy names the default branch because sync --remote merges the checks it requires into the live ruleset and never drops one (unionStatusChecks in internal/forge/ruleset_merge.go): a check only a feature branch runs, once required, blocks every pull request that lacks it.

A live setting stricter than declared passes; plan --remote lists it as [STRICTER] (TestAuditLiveBranchProtection_Positive_MatchingProtectionPasses, TestAuditLiveBranchProtection_Negative_DriftFailsNamingEachProperty).

Every live check runs whatever an earlier one found: drifted workflow permissions and drifted branch protection fail one audit together, each with its own [FAIL] (TestAuditLiveForge_Negative_EveryDriftIsReported). A comparison that cannot be evaluated, such as a negative approving review count or more required checks than a ruleset holds, is a local defect and fails the audit too; only a read the forge did not answer is reported as not made (TestAuditLiveBranchProtection_Negative_UnevaluableComparisonFails).

Nothing is compared, and the audit says why, in four cases:

Case Line
adoption.decline lists branch-ruleset [INFO] ... not compared with the forge: branch-ruleset declined by adoption.decline. The committed ruleset gate keeps its own decline line.
The policy requires neither linear history nor signed commits [INFO]; the policy declares no ruleset (adopt.RulesetRequired, TestAuditLiveBranchProtection_Boundary_PolicyWithoutRulesetIsNotCompared).
--offline, no token, no matching origin, or a read the forge refused [SKIP] with the reason. Reading the legacy protection object needs read access to the repository's Administration permission, which a workflow's GITHUB_TOKEN cannot be granted.
The default branch does not exist on GitHub yet [SKIP] Live branch protection of main not compared with the forge: main does not exist on GitHub yet, so nothing is enforced on it. The first push of the branch is not refused; the audit compares it once it is pushed (TestAuditLiveBranchProtection_Boundary_UnpushedDefaultBranchIsNotCompared).
The default branch carries no .standards.yaml yet, as on the first push of an adoption [SKIP] Live branch protection of main not compared with the forge: main carries no .standards.yaml yet, so the adoption that adds it has not reached it. The comparison starts with the first push after the manifest reaches main. The presence is read from the commit origin/<default branch> points at in the checkout, the ref used to prove a pending context rename, so no second forge read is made (TestAuditLiveBranchProtection_Boundary_DefaultBranchWithoutManifestIsNotCompared). A default branch that carries the manifest and no protection still fails.
The manifest presence cannot be read, such as origin/<default branch> absent from the checkout [INFO] naming the error, and the comparison runs: an unreadable presence never skips it (TestAuditLiveBranchProtection_Negative_ManifestPresenceErrorDoesNotSkip).

The declared status checks come from the checkout's workflows, using the same rendering as plan --remote and ruleset apply (auditProtectionTarget, liveProtectionTarget). A new required check missing on the forge fails the audit, even before it reaches the default branch (TestAuditLiveBranchProtection_Negative_NewCheckWithoutRenameFails).

One pending context rename may pass while the forge still requires its old name. The fetched default branch must prove that the same workflow path and job ID changed one required context into another, or that the same workflow wrapped its sole changed context in a reusable caller name, such as REUSE lint becoming Licence / REUSE lint. The old name must remain required live and must no longer be declared. Audit names both contexts and writes nothing:

[INFO] Live branch protection of main: Required status checks: declared 2, live 2 of 2 required; pending required status check rename: "REUSE lint" -> "Licence / REUSE lint" by ruleset "praetor-main-protection" #1

Rename evidence is measured from HEAD's common ancestor with the fetched default branch, so a stale checkout cannot claim a rename made only on the default branch (TestAuditLiveBranchProtection_StaleHeadDoesNotProveRename). The window ends once the fetched default branch contains the new name. An unrelated live check, an unrelated replacement job, another missing requirement, or several renames never excuses drift (TestAuditLiveBranchProtection_PendingContextRename, TestAuditLiveBranchProtection_RenameWindowBoundaries). Reconcile the forge once the renamed job reaches the default branch. Run plan --remote and sync --remote from that updated branch.

The base workflows are read only from the checkout's object store; nothing is fetched. Without the base ref, or when a partial clone lacks its workflow blobs, no rename window is granted. The declared checks remain the checkout's, and an absent workflow is named (TestAuditLiveBranchProtection_Boundary_AbsentDefaultBranchWorkflowIsNamed).

praetorctl plan reads local files only unless --remote is passed. Its status then ends with [INFO] Live branch protection not compared with the forge: this plan read local files only. instead of saying that no change is required, and the MCP standards_plan tool prints the same line, from the same function (adopt.FormatPlanStatus in internal/adopt/plan_drift.go, TestFormatPlanStatus_LiveNotCompared).

Limits

  • Only GitHub is read. The GitLab and Gitea drivers return ErrNotImplemented for both Actions reads and have no branch protection reader.
  • Neither Actions setting is written. Reconciling the permissions through sync --remote, and letting archetypes and facets declare them, are separate follow-ups.
  • The run check reads the default branch. A workflow that only runs off it, such as a release on tag push, is listed with its newest run but its failures are not counted.
  • The audit bounds all forge reads to three minutes and each request to 15 seconds.