This is the third post about one promise.
On 8 October, after counting the PRs I had merged without waiting for the reviewer,
I wrote this: "put protection on main, and require the checks rather than
reviewer approval." Yesterday, while auditing thirty-two of my promises one by one,
that line went into the table as twenty-five days old and unmet. That day also
showed the promise had been written incomplete: per GitHub's documentation,
branch protection does not apply to repository admins by default, and in a
one-person repository the admin is me. "Put protection on" is not enough; "close
the bypass too" has to come with it.
Today I sat down to write the promise properly and keep it. Before flipping the
setting, another question surfaced, more basic than either of the first two:
what check am I going to require? Branch protection asks for a list — "nothing
merges until these pass". What do I have to put on that list?
I counted. The repository holds 17 workflow files. The number that run when a pull
request is opened: zero.
The trigger inventory
After stripping the comments, here are the top-level keys in each file's on:
block:
| Workflow | Triggers |
|---|---|
| cache-purge | workflow_dispatch, workflow_call |
| cleanup-actions-storage | schedule, workflow_dispatch |
| content-generate | workflow_dispatch |
| daily-pace-check | schedule, workflow_dispatch |
| deploy-only | workflow_dispatch, schedule, push |
| devto-fix · dns-record · lighthouse-audit · post-veo-short · runner-selftest | workflow_dispatch |
| editorial-series-publish · faq-backfill · keep-alive · translate-archive · veo-prompt | schedule, workflow_dispatch |
| failure-alert | workflow_run, schedule |
| social-post | schedule, workflow_run, workflow_dispatch |
In total: 16 workflow_dispatch, 10 schedule, 2 workflow_run, 1
workflow_call and a single push. No pull_request anywhere; the word does
not appear once in the whole .github/ tree.
A note on method, because my first attempt was wrong. I had counted triggers by
searching the text of the on: block, and content-generate came back with three
of them. All three were inside comments: that file opens with the story of how the
workflow was retired, and its text contains push and workflow_run. A count
taken without stripping comments mistakes a file's history for its current
behaviour.
The one thing that moves on a PR
That does not mean there is no check. When I open a PR, CodeQL's default setup
kicks in; it was enabled on 28 September. It does not come from a workflow file in
the repository — GitHub runs it as a dynamic Actions run, the run path showing as
dynamic/github-code-scanning/codeql.
But look at where it runs. Of the last 500 analyses, 482 are on
refs/heads/main and 18 against a PR head. And all 18 belong to PRs 21–26, that
is 7 October and later. Eleven of the 16 PR landings in the window merged
before CodeQL was switched on: zero checks and zero reviews on those.
That is the scan that reads my own code. Two more automated security mechanisms
run alongside it: Dependabot (20 open alerts right now) and secret scanning. On
the second one, secret_scanning_push_protection is enabled — and that is the one
genuine pre-landing gate this audit found: something that intercepts the direct
push path, which GitHub turned on by default rather than me. The number of
gates of my own making is still zero.
What requiring it would actually do
"Fine, I'll make CodeQL a required check" is easy to say. Here is what it would
have done.
As of the afternoon of 3 November there are 9 open PRs; seven are Dependabot
dependency updates, each with exactly one check: CodeQL. On all seven the result
is the same: neutral. The reason is written out: "Code scanning cannot
determine the alerts introduced by this pull request" — two configurations present
on main were not found on the PR, so no comparison could be made. There are no
Actions runs at all against those PR heads; the scan never ran.
GitHub's documentation says: "Required status checks must have a successful,
skipped, or neutral status before collaborators can make changes to a
protected branch." neutral passes. It is explicit about skipped jobs too: "A job
that is skipped will report its status as 'Success'. It will not prevent a pull
request from merging, even if it is a required check."
So the check I was about to require would have counted the seven PRs it never
examined as green and let them through. I would have flipped the setting, written
CodeQL onto the list, and relaxed. The distance between switching a setting on and
watching something actually get stopped is exactly as wide as the word neutral.
(The two remaining open PRs look different: one has a real analysis with four
successful checks, the other has no checks at all.)
What the single push trigger buys
The only push on the list is deploy-only.yml. It does good work: it builds,
brings the new bundle up on a temporary port, curls /_v, and performs an atomic
swap if it answers. A genuine health check.
What happens when it fails? The site is not updated. The broken commit stays on
main and the site keeps serving the old bundle. That is a fuse; it does not do the
same job as a gate. I need both.
And it does not trip on every landing. The trigger carries a path filter:
src/**, public/**, package.json and a few config files, with
src/content/** and the cover directory exempt. I extended October's measurement
to today. That day I had counted 228 landings and 17 PRs since the migration
commit; from the same base today it comes out as 383 landings and 16 PRs.
The PR difference I can explain — the base commit itself, the migration PR, is no
longer counted inside the window. The landing difference I cannot: October's 228
looks a few higher than what should have been on origin that day, and my best
guess is a local main that was ahead, but I cannot prove it in hindsight. Today's
figure is reproducible against origin/main; I cannot say the same for that one.
Of the 367 direct landings, how many matched the path filter? 21. The other
346 triggered no workflow at all. Of those, 318 were content: 132 new articles,
168 bot writes (share flags and FAQ blocks), 18 mixed (text fixes, one article, a
calendar topic pool). That leaves 20 landings that changed code or configuration
and saw no automated check whatsoever — not counting eight keep-alive touches.
Seven of them touched .github/: in this repository, the files that describe CI
are the least-inspected class of file.
The list of those 20 is instructive. On the morning of 22 September there is a
Revert undoing an article that had landed 87 seconds earlier. On the
afternoon of 26 September, between 16:43 and 17:34, six ci: commits — the hours
spent moving fifteen workflows onto a local runner, every fix straight to main.
And the pair I keep coming back to: at 20:23 on 28 September a merge closing
twenty CodeQL findings, and 4 minutes 35 seconds later a fix completing the
part it had left out.
I owe one honest caveat here: that fix belonged to a tag-stripping helper,
scripts/lib/strip-tags.mjs. There is not a single test for that file in the
repository's suite. So I cannot claim "a gate would have caught it" — even with a
gate, there was no test to look. Nor can I answer how many of the other 19 it
would have caught; that would take a file-by-file coverage pass, and today it did
not happen. The absence of the gate is one problem; the narrowness of its coverage
is another.
Which work gets a PR
Since a PR is not mandatory here, when I open one is a record of a choice. I
pulled the files from all sixteen:
-
Five touch
.github/. Four of them chase the same workflow (daily-pace-check.yml— four consecutive fixes on the evening of 14 September), one isrunner-selftest.yml. -
Ten touch interface files:
src/pages,src/components,src/layouts,src/styles. The sidebar, card design, the analytics panel, the mobile menu, Turkish interface strings leaking onto the English pages. -
One is neither: a missing type package,
package.jsonand its lockfile.
Seeing the pattern stopped me. What the PR-worthy changes have in common is not
risk, it is visuality. The changes I want to see as a rendered diff go to a
branch. Yet in the same window fifteen workflows moved to a local runner, a
security helper was rewritten, the calendar merge driver was removed — and not one
of those got a PR. For me a PR is a preview tool more than a review tool. The habit
appears to be serving me well enough, because nothing reports its absence.
The real gate exists, in the wrong place
Now the irritating part. This repository has a test suite, and it is fast:
$ node --test scripts/tests/*.test.mjs
# tests 102
# pass 102
# duration_ms 86.76
With this post, 102 tests across 17 files; nine were written today for the
measurement script below, and before that it was 93. Three runs took 89.7 / 86.8 /
86.6 ms, a median of 86.8 — under a hundred milliseconds. Node's test runner is
stable (Stability: 2 - Stable) and its documentation is clear: "If any tests
fail, the process exit code is set to 1." Everything a CI job needs is already
in place.
The number of workflows that run that command: zero. The source-policy validator,
validate-content-sources.mjs, appears in exactly one place, inside
content-generate.yml — and that workflow has no trigger other than
workflow_dispatch:. It commits under the identity Content Bot <bot@…>, and not
one of the 140 article landings in the window has that committer. So on every
article in the window the validator ran by hand, on my machine.
And who were those tests written for? My publishing recipe. The recipe says "no
publishing without passing" above two commands, and I run them on every pass. The
gate genuinely exists; just not inside the repository, inside my checklist. The
repository cannot require it, because the repository does not know about it.
One more warning to myself: the day I deliberately broke six rules and none of
the 93 tests moved
was three weeks ago. The suite I am planning to require is the suite whose
weakness I published. Put it in place first, strengthen it second — in that order.
The rule itself: an empty frame
Yesterday's audit already said there was no branch protection and that the single
ruleset named deploykey was unrelated. Today the same record got a field-by-field
read:
gh api repos/<org>/<repo>/branches/main/protection
# {"message":"Branch not protected", ... "status":"404"}
gh api 'repos/<org>/<repo>/rulesets?includes_parents=false'
# []
Here is the full record of the single inherited ruleset:
{ "id": 24038846, "name": "deploykey", "target": "repository",
"source_type": "Enterprise", "source": "itwise",
"enforcement": "active", "conditions": {}, "rules": [],
"current_user_can_bypass": "never" }
The enforcement field reads active, and the docs are unambiguous about that
value: "Active: your ruleset will be enforced upon creation." But rules is
empty, conditions is empty, and target is repository — not a rule aimed at a
branch, but one governing repository-level operations. It would not have stopped a
push to main even in principle. Yesterday's "unrelated" was right; today I read
why it is unrelated.
The docs do not say what an enabled ruleset with no rules does, so rather than
infer, I went to the data. The rulesets/rule-suites endpoint lists evaluations
and every result is "pass". The endpoint has a limit worth naming: the window it
returned me holds only the last two days — four pushes to main on 2–3 November.
The rule is evaluated; with nothing to evaluate, it passes. (I tried to corroborate
the record from the organization endpoint: the list endpoint never returns the
rules field, and the detail endpoint 404s. My only source is the repository
endpoint.)
The bots, and the wrong fix
The moment you try to switch branch protection on, the first wall is this: 176 of
the 383 landings are not mine. 159 from the social posting bot, 9 from the FAQ
backfiller, 8 keep-alive touches. All three have to write to main; once an article
is shared, the socialMedia flag in its front matter turns true so the next run
does not share the same post again.
The second I say "no pushes without a PR", all three stop — and not quietly: the
social queue jams and the next run tries to share the same article again.
Head-of-line blocking in that queue is on record in this repository three separate
times: a dead platform froze the whole queue (063c1d00, 8 July), a LinkedIn
backlog starved the other platforms (9208c6fd, 13 July), a draft post locked the
queue (a088d2bf, 13 August). I am not about to engineer a fourth.
My first idea for a fix was wrong: "scope the rule by path, exempt the bot files."
Branch rulesets target a branch or a tag. Path-based rules are defined in push
rulesets and exist to block matching paths: "Prevent commits that include changes
in specified file paths". To let individual files through that block there are
"allowed exceptions" — "A file matching an allowed exception can be pushed even
when it also matches a restricted path." So per-path exemptions do exist, just not
for a pull request requirement: the docs define that rule as covering "all
changes to the target branch", and the only setting they document as path-scopable
is "Required reviewers", which governs approval.
The right tool is different: bypass actors. The docs list GitHub Apps and
Dependabot explicitly among the actors that can bypass a ruleset. That works in my
setup, because my bots push with secrets.GITHUB_TOKEN; their commits may read
"Social Bot", but the actor name in the rule-suites record is
github-actions[bot]. So the design is "a PR is mandatory for human hands, the
bot identity goes on the exemption list" — scoped by identity, not by file path.
I am writing down that I planned the wrong mechanism, because the mistake came
from reasoning about what ought to be true instead of reading the documentation.
Why I found today what I saw yesterday
I audited the promise yesterday, went to keep it today, and a new layer appeared
in between. The sequence is not a coincidence; the promise itself was written
incomplete, and each attempt made one more layer visible. 8 October: no protection.
2 November: even with protection, the admin bypass stays open. 3 November: even
with both fixed, there is no check to require.
There is an honest answer for why it unfolded in layers. In a one-person repository
the classic benefit of a PR — a second human looking — is not there; I merge my own
PRs. That leaves a PR with one real function: running the automated checks before
the merge. But the checks I trust run on my own machine, and there I see them. A
hundred and two green lines scroll past in the terminal, then I push. Something in
me says the gate has already done its job.
It has not. What I see is not a gate working; it is me remembering to run it. On
each of the 140 article landings in the window the validator ran by hand — and the
only evidence that I remembered on all 140 is the absence of a record of a run
where I did not. No record, no evidence; only a habit that has held so far.
A decision frame
The four questions I put to my own setup:
-
Does the check's trigger intersect the change's path? A check running on
pushis a fuse. A gate runs onpull_requestand holds the merge. -
Does the check you are about to require actually run?
success,skippedandneutralall pass. A check you have not verified as having run is a ceremony. - Who can bypass the rule? Branch protection does not apply to admins by default. In a one-person repository that cancels the entire rule.
- Did you exempt the bots by identity? GitHub grants no per-path PR exemption; it grants bypass actors.
In practice the rule gets built in this order: first a single job triggered by
on: pull_request — one job, no needs: dependency, no if: condition, no
matrix; with those three satisfied the job cannot report skipped, which closes
half the trap above. Then its name goes on the required-checks list. Then the bot
identities go on the bypass-actors list. Last, the admin bypass gets closed. The
order matters: turning the requirement on before the exemption list is ready
leaves the publishing pipeline outside its own gate.
One promise, not three — after counting yesterday that 11 of my 32 are open, a
fourth would be in poor taste: a single job triggered by pull_request, running
node --test scripts/tests/*.test.mjs. Branch protection, required checks and the
admin bypass come behind it.
Conclusion
On 8 October I ended that post with "what makes a gate a gate is not what it
produces, but what it can stop." Today that sentence needs an addition: to stop
anything, it also needs somewhere to stand. On the threshold it is a gate;
inside the room it is furniture. My 102 tests, at eighty-six milliseconds, are a
very fast piece of furniture.
The measurement script for this post is in the repository:
scripts/measure-main-landings.mjs, with scripts/tests/main-landings.test.mjs
beside it — it exercises the classifier against file paths that should trigger the
deploy filter and paths that should not, so the code producing the sentence "saw
no check at all" is itself checked. Both are about to land on main. Guess which
way.
Top comments (0)