Audit: Moving ref source protection¶
What we check¶
A repository the rest of the fleet consumes at a moving ref has no
private default branch. shakenfist/actions serves the shared reusable
workflows and composite actions at @main, so what a caller runs is
whatever that branch holds at the moment its job starts, and the caller
reviewed none of it. Local clones of the fleet's default branches held
93 such references across 14 repositories when they were counted on
2026-10-02.
The moving ref is kept rather than pinned, deliberately. Pinning every caller to a sha moves the trust problem instead of solving it: each of those references would need updating on every change to the shared workflows, by a bot with write access to 14 repositories, and a fleet that updates pins automatically is trusting the updater rather than the branch. Protecting the one branch every caller reads is the smaller surface, and it is the surface this criterion measures.
A composite action is the stronger case. A reusable workflow runs as its own job with the secrets its caller named, while a composite action runs inside the caller's job, with that job's environment, token and checkout. Either way the review that protects the caller is the one that happened on the default branch of the repository it called, which is why that branch has to be a gate rather than a place anyone with write access can push.
Two rules make it one, and both have to be in the default branch's effective rules:
- a
pull_requestrule, so nothing reaches the branch except through a pull request; and - a
required_status_checksrule, so that pull request waits for CI.
Either on its own leaves a way through. Without the first, a push lands without a pull request ever existing. Without the second, a pull request merges with nothing having run, so the pull request is a formality rather than a review.
A rule that is reported is not necessarily a rule that refuses
anything. GET /repos/shakenfist/<repo>/rules/branches/<branch> is
what the check reads, because it is the only endpoint that resolves
organisation and repository rulesets together. So the check collects
the ruleset_id of each of the two rules it matched, reads GET
/repos/shakenfist/<repo>/rulesets/<id> once per distinct id, and
fails unless every one of them is enforcement: active.
What that second read rests on, stated as measurement and inference
rather than as one claim. Measured: the effective-rules endpoint
already leaves out a disabled ruleset. instar's ruleset 19389569
is enforcement: disabled, targets ~DEFAULT_BRANCH and carries a
copilot_code_review rule, and
repos/shakenfist/instar/rules/branches/develop returns only ruleset
20783686's five rules. Not settled: whether the endpoint also
omits the rules of a ruleset set to evaluate, where a rule is
reported on and nothing is refused. The endpoint's documentation has
at times said that rules from evaluate and disabled rulesets are
not returned; nothing in this organisation is in evaluate mode, and
setting one there to find out would mean changing a live ruleset, so
it has not been measured here.
The read is therefore the conservative choice rather than a known
necessity. If the endpoint does omit evaluate rules, the read costs
one API call per contributing ruleset and can never fail; if it does
not, the read is the only thing standing between a rule that merely
reports and a pass. The same unsettled question is why an
organisation-sourced rule is an error below rather than a pass: the
argument that reaching the endpoint at all implies the rule is active
depends on exactly the behaviour that has not been established.
One more shape is unmeasured, and this page says which way it is
resolved. Whether GitHub returns {"required_status_checks": []} for a
rule whose contexts have been cleared, or omits the key, was not
established -- doing so means clearing the contexts on a live ruleset.
The check reads an absent key as the empty list, so the verdict below
is reached under either behaviour. The alternative, treating an absent
key as unreadable, would report a check error for exactly the
configuration the verdict exists to catch, and would make that verdict
unreachable outside the tests. A required_status_checks value of the
wrong type is still unreadable, because no reading of it is a cleared
list.
It also fails a required_status_checks rule whose context list is
empty. That is the shape an operator leaves behind by clearing the
contexts instead of removing the rule: it satisfies "the rule is
there" while requiring nothing, and a pull request merges as soon as
it is opened. The finding names the checks that belong there -- Check
paths, Lint, Unit tests and gitleaks -- so that the issue says
what to put back.
It also fails when some of those contexts are missing rather than all of them. An operator who deletes two of the four leaves a rule that still exists and a branch where every pull request still offers a merge, so nothing but this criterion would report it. The comparison is against the table on the check, which is the same set the finding tells an operator to restore: holding the expected contexts and declining to compare against them would be trusting them exactly as far as it is convenient.
What is not compared is those names against the workflow's job
names. That needs ci.yml read out of another repository, which this
criterion does not do, so a context renamed in both the workflow and
the ruleset leaves the table stale and this criterion reporting a
finding until the table is updated. That is why the finding asks an
operator to confirm each context still names a job rather than
asserting that it does. The symptom of getting it wrong in the other
direction is loud: a required context that never reports stops every
pull request offering a merge at once.
Everywhere else the criterion reports not_applicable, naming the
repository, and asks GitHub nothing. The table of moving-ref sources
is MOVING_REF_REPOS on the check, keyed org/name -- an actions
repository in somebody else's organisation is not this one -- and each
entry carries that repository's own check-run names, so a repository
joining the table brings its own rather than inheriting these four
into a finding filed against it.
not_applicable here means "not in the table", which is not the same
as "not a moving-ref source". The table is a decision about what is
measured, and the gap below is the part of the definition it does not
yet cover.
Known gap¶
shakenfist/development meets the definition at the top of this page
and is not in the table. templates/review-tracking/ci-prune-reviews.sh
clones it with git clone https://github.com/shakenfist/development
and no ref pin, exports the result as SHAKENFIST_DEVELOPMENT, and
templates/review-tracking/review-tracking.sh then execs
scripts/review-tracking.py out of that clone. That runs in the
prune-reviews job of every adopted repository -- eight of them,
listed in templates/review-tracking/README.md -- so whatever
development's default branch holds when one of those jobs starts
executes inside it, which is the same exposure this criterion exists
to measure on actions.
It is a gap rather than an omission because closing it is a change to
a second repository's settings, not to this check: development's
main carries only deletion and non_fast_forward rules today, so
adding it to the table would report a fail and file an issue that no
code change here could close. Requiring a pull request and status
checks on development is the fix, and adding it to the table is what
should follow; that order is
#393. Until
then the table under-reports, and says so here rather than reading as
the complete set.
Anything the audit could not ask is a check error rather than a
verdict. That covers the branch-rules read and each ruleset read
failing or answering something unparseable, a ruleset that reports no
enforcement, and a matched rule that names no ruleset_id -- with no
id there is nothing to read the enforcement from. It also covers the
cases where every read answered but the question could not be put: a
matched rule whose ruleset_source_type is anything other than
Repository, or which names no source at all. Repository is the
only source whose enforcement is at
repos/<org>/<repo>/rulesets/<id>; an Enterprise ruleset is not
reachable by this audit at all, a rule naming no source would be read
from this repository on an assumption, and an Organization ruleset's
enforcement is only on GET /orgs/shakenfist/rulesets/<id>, which the
audit's read-only token is refused. The rule may be refusing
pushes or merely reporting on them, and the audit cannot tell which, so
it says so instead of guessing. Nothing in the fleet is configured that
way today; it becomes measurable when the audit has a read identity
that can reach the organisation endpoint
(#352).
Required, but confirmed by a reviewer¶
The bypass list holds exactly one actor. On actions' ruleset that
is the review-marks App, shakenfist-review-marks, actor_id
5538020, actor_type Integration, bypass_mode always -- read
back on 2026-11-11 with a credential that can edit the ruleset, with
current_user_can_bypass: "never" for the operator's own credential,
so the App's per-run token is the only identity that can push to the
branch. The bypass is what lets the prune-reviews workflow land
review marks on a branch that otherwise requires a pull request.
This is confirmed by a reviewer rather than measured, and for a
measured reason: GitHub omits bypass_actors for a caller that cannot
edit the ruleset, and the audit's read-only token cannot, which was
read twice -- the second time under the organisation's security
manager role -- before this criterion was written. Widening that token
to make the check fit would hand the audit the ability to edit every
ruleset in the fleet, which is a worse trade than a reviewer reading a
list. How a reviewer judges an actor by its type is the table in
bypass-actor-identity.md; when the audit
gains a read identity that can see the key, a missing key becomes an
error there and here, never an empty set.
Required approvals are zero, deliberately. "Requires a pull request" is not "requires an approval": with one maintainer, an approval count of one cannot be met by the author of the change. What the rule buys is that every change is visible as a pull request, with CI reported on it, before it lands. The fleet's other pull-request rulesets are configured the same way.
One accepted residual. The runner-controller App
shakenfist-k3s-ci-arc holds administration: write on hunkydory,
where the review-marks App's key is also held, so whoever holds the
runner-controller App's key can widen hunkydory's review-marks
deployment branch policy, read the review-marks key from a branch
workflow, and mint push tokens for every repository that App is
installed on -- actions among them since 2026-11-11. The chain ends
in a bypass of the very ruleset this criterion measures. It is accepted
knowingly rather than treated as a finding here, because narrowing
another App's permission is its own piece of work;
bypass-actor-identity.md records it as the
residual it is.
Template¶
No template -- this is a one-time ruleset configuration change.
To read what is currently enforced:
gh api repos/shakenfist/<repo> --jq '.default_branch'
gh api repos/shakenfist/<repo>/rules/branches/<default branch> --jq \
'.[] | {type, ruleset_id, ruleset_source_type}'
gh api repos/shakenfist/<repo>/rulesets/<id> --jq \
'{enforcement, bypass_actors}'
To add the two rules, fetch the ruleset, append them preserving every
other rule and the ~DEFAULT_BRANCH condition, and PUT it back. A PUT
replaces the ruleset rather than merging into it, so anything left out
of the body is removed -- including bypass_actors, which GitHub omits
from the GET for a credential that cannot edit the ruleset, so a body
built from such a read silently empties the list. The GET-then-PUT
shape that guards against that is under Template in
bypass-actor-identity.md; add these two
entries to its rules array:
{"type": "pull_request",
"parameters": {"required_approving_review_count": 0}}
{"type": "required_status_checks",
"parameters": {"required_status_checks": [
{"context": "Check paths", "integration_id": 15368},
{"context": "Lint", "integration_id": 15368},
{"context": "Unit tests", "integration_id": 15368},
{"context": "gitleaks", "integration_id": 15368}]}}
integration_id 15368 is GitHub Actions; a context without it matches
a check run of that name from any app. The contexts are job names from
the repository's own ci.yml, so a repository joining
MOVING_REF_REPOS brings its own.
Before trusting the rule, open a throwaway pull request and confirm every required context resolves to success or skipped and that the merge button is offered. A required context that never reports wedges every pull request, and the only way out is an admin edit to the ruleset. A job that skips satisfies its required check, so a path filter that skips a lane is not a problem; a job that never runs at all on the pull requests the repository actually sees is.
Projects¶
Per-project compliance for this criterion is regenerated on every run of the consistency audit: see the compliance page.