Name the appliance gate for what it proves, and stop passing a boot that did not happen #12
No reviewers
Labels
No labels
accessibility
bug
documentation
duplicate
enhancement
good first issue
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Keel-Linux/.github!12
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "ci/name-the-gate-for-what-it-proves"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #11. Org-wide context: Keel-Linux/tracker#14 (left open; the design
question it records is wider than this repository).
The problem
appliance / build-and-bootreportedsuccessin two states where it saysnothing about the branch it is attached to.
if: steps.layer.outputs.published == 'true', were skipped, and the jobstill concluded success: keel-redis#5, run 36369705307, green in 11 seconds
having pulled, verified and booted nothing. The step summary said "Nothing
was built, assembled or booted", which is honest prose attached to a
dishonest conclusion, because branch protection and
gh pr checksread theconclusion.
mirror and boots that, which on a pull request that changes the recipe is
the code before the change. The branch is never exercised.
Audit of the mirror, re-run before starting: eleven layers answer 200 at
https://mirror.keellinux.org/layers/<name>.manifest(core, apache-php,nodejs-nginx, mariadb, postgresql, nodebb, wordpress, lamp, lamp-client, lapp,
lapp-client) and
redisanswers 404. So one repository is in theunearned-green state and eleven checks are real, which is what makes the
second face the bigger one.
What this changes
The job id is
boot-published-layer, notbuild-and-boot. The check isnow
appliance / boot-published-layer. It keeps the value it has, catching apublished layer that does not boot and catching the parent mismatch it was
written for, and its name no longer reads as a claim about a branch. The job
summary repeats it in prose and prints the
product_commitand pool date thebooted layer was built from, so a reviewer can see how old the thing that
booted is. Building from the branch instead is not attempted: that needs the
build host, which is serialised behind a lock, so it does not fit a
per-pull-request check.
An unpublished layer fails the job, unless the caller declares
allow_unpublished: true.The decision is a tested script,
bin/appliance-gate, which the jobchecks out and runs (decision 0004). The job holds the curl; everything
concluded from the mirror's answer is in the script, where
tests/appliance-gate.batsasks it every question.A decision that cannot be written down is fatal, exit 5. The append of
published=to$GITHUB_OUTPUTwas unchecked in the first revision, so afailed write was swallowed and the job concluded success having skipped all
six boot steps: this same defect by another road. The calling step now also
reads the file back and fails when neither
truenorfalsewas recorded.tooling_ref, so a change to this workflow can be checked before itmerges instead of running against
main's copy of the script. Defaultmain;no ordinary caller sets it, and a run that does not use
maincarries a::warning::naming the ref, on every path, for the same reasonallow_unpublisheddoes.A job summary that cannot be written is fatal too, same exit 5. It was the
same unchecked write as
publish_decision, five lines away, and it drops thecaveats that are the only record that a passing job booted nothing.
Why this mechanism and not one of the other two
The issue offered three. Two of them do not survive contact with GitHub.
A neutral conclusion cannot be produced by a workflow job. Measured
rather than assumed: a probe on this repository, run 36372853847, gave a job
running
exit 78, the old neutral exit code, the conclusionfailure, and ajob skipped by
if:the conclusionskipped. The Actions documentation onusing conditions to control job execution says of the latter, "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
failureis theonly conclusion a workflow job can produce that withholds a merge. Anything
built on
neutralwould have to be a separate check run posted through theChecks API under a name colliding with the job's own check, which I did not
test and am not proposing.
"Fail when the layer has ever been published before" is not knowable here.
The mirror serves the current manifest and no history, so a 404 today is
indistinguishable from a layer that was published and then deleted. There is
nowhere to read "ever" from without inventing state to carry it.
So: fail by default, with an exemption the caller declares. The remaining
choice is where the bootstrap exemption lives, and the answer is the caller's
own workflow file rather than the shared gate, because that is where the
person reviewing that repository will see it. Once the manifest answers 200 it
becomes an error telling you to delete the line. Its two limits are real and
are printed in the summary of every run that uses it: the error only stops
somebody where this check is a required status, and it keys on the name in
appliance:, so a typo answers 404 for ever and never expires. Neither can befixed from inside the gate, which cannot see a protection rule and cannot know
which name you meant.
keel-redis does not get the exemption. It is not bootstrapping. It has a
repository, a recipe, a boot test and a layer whose publication is in
progress, so the honest state of its gate is red with "publish the layer",
which is exactly the signal missing when keel-redis#5 looked ready to merge.
Its branch protection does not require this check, so nothing is blocked by
that red, and it is owned and dated in keel-redis#10. No caller sets
allow_unpublishedtoday, which is the intended population: it exists for arepository whose first layer does not exist yet.
How each behaviour was verified
Not by reasoning. Temporary callers were pointed at this branch and the runs
read back. All of them are closed and their branches deleted.
allow_unpublished: trueallow_unpublished: trueroles: primary replicaexit 78in a jobtooling_refthe gate recorded published=truetooling_ref::warning::is a real annotation on the check run,the gate recorded published=falsetooling_refThe check name
appliance / boot-published-layerwas read off the job listsof those runs, not predicted from the YAML. Runs 36373993854, 36377248376 and
36381555313 are the ones that matter most for regression: the ordinary
published path still boots the core layer end to end with the tooling checkout
reorganised and the gate script in the way, and the two node path still gets
its node library, now through
actions/checkoutof this repository ratherthan a hand written
git clone, checks the declared roles and renders thetopology in the summary.
Locally:
bats tests/is 77 tests green,tests/coverage.shreports 100.00percent total (
bin/require-changelog57/57,lib/boot-test-nodes.sh113/113,
bin/appliance-gate115/115), and the pinned actionlint 1.7.7,sha256
023070a2..., reports no findings on the five workflows. The tests werewritten before the script and failed for the right reason first.
The one thing covered by tests rather than by a run is the unwritable
$GITHUB_OUTPUT: inducing ENOSPC on the shared runner is not something to doin order to verify a message. Two bats cases cover it, one per path that
records a decision.
Callers, verified by grep and not by the list I was given
grep -rln test-applianceover every repository checked out locally gives tencallers and twelve caller jobs: keel-core, keel-apache-php, keel-nodejs-nginx,
keel-nodebb, keel-mariadb, keel-postgresql, keel-redis, keel-wordpress,
keel-lamp (
applianceandappliance-client) and keel-lapp (applianceandappliance-client). Each gets its own pull request updating the check nameand the sentence that claimed an unpublished layer passes. They are
documentation in the caller: the rename takes effect for all of them the
moment this merges, because every one of them calls
@main.Branch protection, which is not changed here
Renaming the reusable job renames the check for every caller at once, and
GitHub fails closed: a required context that is never reported does not
stop applying, it leaves the pull request blocked, waiting for a status that
will never arrive. So merging this without renaming the rules locks the
eight repositories below rather than leaving them unguarded, and
enforce_adminsistrueon keel-core and keel-mariadb, so not even an ownercould merge past it.
Measured, because the first revision of this description asserted the
opposite: two pull requests in keel-mariadb under one rule, every reported
check green on both. Number 16, reporting
appliance / build-and-boot:clean. Number 17, reportingappliance / boot-published-layerand identicalotherwise:
blocked. keel-core#10blockedthe same way; keel-redis#6, inthe one repository that does not require the context,
clean.These eight require it today and need
appliance / build-and-bootreplacedwith
appliance / boot-published-layer, in the same change as this merge:appliance / build-and-bootappliance / build-and-bootappliance / build-and-bootappliance / build-and-bootappliance / build-and-bootappliance / build-and-bootappliance / build-and-boot,appliance-client / build-and-bootappliance / build-and-boot,appliance-client / build-and-bootkeel-redis (master) and keel-nodejs-nginx (no protection) require no rename;
keel-redis protects
tests / coverageandpackage / changelogonly. Therename is a minute of work, but it has to happen before anything else merges
in those eight.
Worth deciding at the same time, and not decided here: whether this check
should be required at all. It is real evidence about the mirror and no
evidence about the diff, so requiring it gates merges on something the pull
request did not change. The rename makes that legible instead of hidden, which
is as far as this pull request goes.
What the review changed
Two HIGH findings, both fixed in the third commit.
publish_decisioncould still hand back an unearned success. Underset -uo pipefailwith no-e, the append to$GITHUB_OUTPUTwas unchecked.A failed write was swallowed,
boot()returned 0, the step passed, and everyboot step, all guarded by
published == 'true', skipped: the original defectreached through an I/O error rather than by design, and with the more
misleading log line of the two. It is now
die 5, with a test for thepublished path and a test for the exemption path, which is the one that
passes. The calling step reads the file back and fails when the recorded value
is neither
truenorfalse, so the guard those steps depend on is checkedwhere it is used rather than assumed.
The branch protection claim was inverted, here and in keel-core#12's
workflow comment. Corrected in both, with the keel-mariadb measurement above.
The action it implied was right; the reason was wrong, and a wrong reason
would make someone rush this merge to close a hole that does not exist.
Three MEDIUM findings, closed as far as they can be:
for the name the caller declares. Both limits are now printed in the summary
of every run that uses it, tested, and in
profile/WORKFLOWS.md, and thenotice became a
::warning::so the run carries an annotation withoutanyone opening the summary (measured: run 36381562472).
verifying the first revision needed throwaway branches pointed at
themselves, which the next person would not think to do.
tooling_refmakesit one input and
profile/WORKFLOWS.mdgives the procedure; it should begithub.job_workflow_shaand no input at all, and cannot be while actionlint1.7.7 rejects that property.
keel-nodejs-nginx#3, red since 2026-09-26 because
tests/boot-test.shdoesnot exist there and nobody noticed for two days, and keel-redis#10, red by
design until the layer is published. Honest red is honest only while it is
short-lived and has a name against it.
Both LOW points are fixed: the 200 line reports what the step found instead of
promising a boot that step does not perform, and the redirect test exercises a
3xx.
Second review pass
tooling_refnow has the visibility its sibling gained. It carries a::warning::and a job summary line naming the ref whenever it is notmain,on every path including the exemption path that passes without booting, and
the checkout step records the ref and commit in the summary unconditionally
rather than only when a layer was published. Five tests. Measured on run
36385954751: the annotation is on the check run, and that run still boots the
corelayer in 43 seconds, so the visibility costs no false failure.summary()'scat >>is fatal, exit 5, reproduced withGITHUB_STEP_SUMMARY=/dev/full. It has toreturnrather thandie, becausesummaryis always the right hand side of a pipeline and an exit there exitsa subshell and the script carries on: every call site now ends
| summary || summary_failed, andpipefailcarries the 5 to the caller,which is what the
/dev/fulltest proves.Taken as offered:
dieemits::error::instead of writing to stderr, soexits 4 and 5 annotate the run rather than leaving "Process completed with
exit code N" as all a reader gets.
Re-measured with
GITHUB_OUTPUT=/dev/full:200and404 + exemption, thetwo paths that record a decision, exit 5.
404without the exemption exits 1,because it records no decision and refuses anyway. No path reaches a pass.
Left out of this pull request, on purpose
The handbook still names the old check in
docs/ci-cd.mdanddocs/releases-host.md, including in the procedure a maintainer follows toapply protection, and
docs/releases-host.mdstill says an unpublished layer"skips with a notice and passes". That is a different repository and it is
recorded as Keel-Linux/handbook#9 rather than changed here.
unit-mariadb,unit-postgresqlandunit-redismention the old name once each in a headercomment; those are prose about a neighbouring repository's gate, not gates,
and are listed in the same issue.
Review
The defect is real, the diagnosis is right, and the verification is unusually honest: I re-ran every claim rather than reading it, and all six runs concluded what the table says. Two things should change before this merges, and one of them is a statement in the description that is the opposite of what GitHub actually does.
What I verified rather than accepted
failure, steps 7–12skipped,ALLOW_UNPUBLISHED: false, exit code 1successwith the::notice::, steps 7–12skipped— nothing pulled or bootedsuccess: keel checkout, parent check, pull, verify, boot test, summaryProcess completed with exit code 2, "allow_unpublished no longer has anything to excuse"exit 78exit-78→ failure; jobplain-failure→ failure; jobskipped-by-if→skippedThe tooling commits those runs used (
1d642bdfor redis/core,cda32e11for mariadb) differ from this branch's head by exactly one line —ref: verify/gate-behaviourinstead ofref: mainat.github/workflows/test-appliance.yml:153. Nothing else. So the evidence is about the code being proposed.actions/checkoutgenuinely replaced the hand-written clone and the two-node run genuinely exercised it: the mariadb log showsSyncing repository: keel-linux/.github,path: .keel-ci, thenbtn_node_countreading the library out of that checkout and both nodes booting from it.Also confirmed: the mirror audit is exact (eleven layers 200,
redis404, re-checked just now); the motivating run keel-redis 36369705307 isappliance / build-and-bootsuccess in 11 seconds with steps 6–10 skipped;bats tests/is 68 green andtests/coverage.shreports 100.00 total,bin/appliance-gate89/89, locally;shellcheck bin/appliance-gateis clean and the file is committed100755;.githubhas nodebian/and its protection requires onlyactionlintandtests / coverage, so no changelog entry is owed.HIGH —
bin/appliance-gate:37-39: the gate can still conclude success having booted nothingpublish_decisionappends to$GITHUB_OUTPUTunderset -uo pipefailwith no-eand no check on the redirection:If that write fails, the failure is swallowed,
boot()stillreturn 0, and the step passes.steps.layer.outputs.publishedis then empty, so every one oftest-appliance.yml:204, 222, 234, 241, 256, 289evaluates false, all six boot steps are skipped, and the job concludes success — the original defect, bit for bit, and with the more misleading summary of the two, because the log line that did print says the layer "will be pulled, verified, assembled and booted".Reproduced against this branch with
GITHUB_OUTPUTpointing at an unwritable file:The scenario is not exotic.
$GITHUB_OUTPUTlives on the runner's own disk, and this is the runner that assembles multi-gigabyte rootfs trees under/var/tmp/keel-ci; the cleanup step at line 362 already warns that a scratch tree can survive. One ENOSPC and the gate reports a boot it did not do. The whole point of the change is that a skipped boot must not be reportable as a pass, and here it still is — reached by an I/O failure instead of by design, which is the harder one to notice.Make the write fatal, e.g.
|| die 5 "cannot record the decision", with a test for it. Cheap belt-and-braces on top: have the step fail whenpublishedcomes back neithertruenorfalse.HIGH — the branch-protection premise is inverted, and it is being written into eight repositories
The description says a protection rule naming the old check "stops matching anything and stops applying, silently", and keel-core#12 puts the same sentence into that repository's workflow comment ("a rule naming the old check would have matched nothing and stopped applying without saying so").
GitHub does the opposite. It fails closed: a required status check that is never reported leaves the pull request blocked, permanently, waiting for a status that will never arrive.
The controlled experiment is already in this organization, produced by the verification runs themselves. Two pull requests in keel-mariadb, same branch, same protection rule (
strict,enforce_admins: true,required_conversation_resolution,required_approving_review_count: 0), neither with a single review thread:tests / coverage✓,package / changelog✓,appliance / build-and-boot✓CLEANtests / coverage✓,package / changelog✓,appliance / boot-published-layer✓BLOCKEDEvery check that reported on #17 is green. The only difference is that the required context
appliance / build-and-bootwas never reported, and that alone is enough to block. keel-core#10 is the same shape and alsoblocked. The control is keel-redis#6, which carries the identical new check name but in the one repository that does not require it:clean. Closing a pull request does not forceBLOCKED— #6 is closed and clean.So the risk of merging this without renaming protection in the same breath is not an open gate. It is a lockout of all eight repositories, and on keel-core and keel-mariadb
enforce_adminsistrue, so nobody can merge past it — protection has to be edited before anything else moves. Recoverable in minutes, but worth knowing which way it falls before you start, and worth not teaching the next person the wrong model. The contexts to rename are exactly as listed; I re-read all ten rules and the table is correct, including the two contexts each on keel-lamp and keel-lapp and the two repositories that need nothing.Please fix the sentence here and in keel-core#12. The instruction that follows from it — rename protection at merge time — is right either way.
MEDIUM —
allow_unpublishedonly self-destructs where the check is requiredtest-appliance.yml:97-108andbin/appliance-gate:118-132. The exemption "cannot quietly outlive the bootstrap" because it turns into an error once the manifest answers 200. That holds only where somebody is stopped by the error. In keel-nodejs-nginx there is no branch protection at all, and in keel-redis the gate is not required; a stale exemption there is a red check that blocks nothing, on a repository whose gate is already expected to be red. It is exactly the population most likely to hold one, since the exemption exists for repositories that have not published yet.Second, narrower hole in the same mechanism: the self-destruct keys on the manifest of the name the caller declares.
appliance: wordpresanswers 404 forever, so an exemption sitting behind a typo never expires and the job is green for the life of the repository.never_publishedcatches the typo today only because the default is to fail; with the exemption set, nothing does.MEDIUM —
test-appliance.yml:149-154: nothing checks a change to the gate before it is mergedThe tooling checkout is
ref: main, so a pull request against this repository that changesbin/appliance-gateor the workflow's wiring runs against main's copy.tests / coverageexercises the script in isolation and nothing exercises it in the job. That is why verifying this change needed two throwaway branches and two temporary pull requests hand-pointed at themselves. The diligence was real; it is also unrepeatable by anyone who does not think to do it. Worth an issue forgithub.job_workflow_shaonce actionlint knows the property, and meanwhile worth saying in the comment that the manual procedure is the only check there is.MEDIUM — two permanently red appliance checks, and docs/traps.md has already written this one down
keel-redis is deliberately red until its layer is published, and I agree with the reasoning: it is not bootstrapping, and the red is the honest state. But keel-nodejs-nginx has been red since 2026-09-26 with
tests/boot-test.sh is missing or not executable(runs 36278621010 and 36376230342, same error both times) and nobody noticed in two days. That is two of the ten callers red by default, which is the trap the project already paid for:The honest red is right only if it is short-lived and owned. Give each a dated issue, or the next real red goes unread. keel-nodejs-nginx is the more urgent of the two — its gate has never proved anything.
LOW
bin/appliance-gate:63promises rather than reports: "the published core layer will be pulled, verified, assembled and booted" is printed before any of that happens, and in the failure mode above it is printed when none of it will. A check may only be named for what it proves; the same goes for the line it logs.tests/appliance-gate.bats:111is named "a redirect or a forbidden answer is not a skip either" and exercises only403. A3xxvalue is never passed to the script.gate 301 core ... falsecosts one line and matches the name.503and403both reachunreachable, a non-numeric status reachesdie 4, and the workflow catches curl's own exit before the script is called. A malformed 200 is caught one step later by the parent check (test-appliance.yml:225): the livecore.manifestsaysparent none, so an empty or garbage body yields an empty parent and fails against the declarednone. That is load-bearing and has no test, because it is inline shell rather than part of the script the tests can reach.published=in the output file, the absence of it on every refusing path, and the remediation text in the summary. "Only the exact word true is the exemption" is the right kind of test. The one thing not asserted is the one that broke above.if:counts as success for a required check is the only one not measured. Run 36372853847 measuredexit 78→failure, which is solid, and measured the skipped job's conclusion asskipped— on a push, with no protection in play. No pull request in this organization has ever had a required context reported with conclusionskipped, so it rests on the documentation. It points the conservative way, so the design does not change either way; it just is not in the same class of evidence as the rest.Coverage claim verified, the script is genuinely tested for behaviour, the callers are comments and documentation only with no functional YAML change in any of the ten, and the two red checks are pre-existing — though keel-wordpress#9's is the intermittent TLS failure to
archive.keellinux.org(certificate verify failed, IP 127.0.1.1) rather than a steady one: the same repository went green on another branch 34 minutes later, so #9 needs a re-run to unblock, not a fix.Warning — no blocking defect, but
publish_decisioncan still hand back an unearned success, and the branch-protection sentence is wrong in a way that will mislead whoever applies the rename. Fix both, then merge and rename the eight rules back to back.Both HIGH findings are fixed in
51aa16d, and the description is rewrittenaround the corrected protection model.
The unchecked write.
publish_decisionnow dies with exit 5 when theappend to
$GITHUB_OUTPUTfails, and the calling step reads the file back andfails when the recorded value is neither
truenorfalse, so the guard thesix boot steps depend on is checked where it is used. Two bats cases cover it,
one per path that records a decision, including the exemption path, which is
the one that passes. That one stays a test rather than a run: inducing ENOSPC
on the shared runner to verify a message is not a trade worth making.
The inverted premise. Corrected here and in keel-core#12, in the workflow
comment as well as the body, and in Keel-Linux/handbook#9 which had inherited
it. I reproduced your experiment before rewriting: keel-mariadb#16
clean,keel-mariadb#17
blocked, keel-core#10blocked, keel-redis#6clean, andenforce_adminstrue on keel-core and keel-mariadb and false on the othersix. The description now says a lockout, not a hole.
The two ways
allow_unpublishedfails to expire are printed in thesummary of every run that uses it and documented; the notice became a
::warning::, so the annotation is on the check run without anyone openingthe summary. Neither limit can be closed from inside the gate, which cannot
read a protection rule and cannot know which name you meant, so they are
stated instead of pretended away.
tooling_refreplaces the throwaway-branch procedure, with the recipe inprofile/WORKFLOWS.md. It should begithub.job_workflow_sha; actionlint1.7.7 still rejects that property.
The two standing reds are owned: keel-nodejs-nginx#3 and keel-redis#10,
both dated.
Re-verified with the new input rather than by hand-pointed branches, both
closed: keel-core run 36381555313, full boot in 43 seconds, log reads
the gate recorded published=true; keel-redis run 36381562472, exemption path,warning annotation present on the check run,
the gate recorded published=false, nothing booted. 71 tests green, coverage 100.00 total withbin/appliance-gateat 100/100, actionlint clean.keel-wordpress#9 was the intermittent TLS failure as you said. It failed a
second time on re-run and passed on the third; that pull request is
cleannow.
Re-review of
51aa16dBoth HIGH findings are closed, and closed with evidence rather than with an argument. I re-ran my own reproduction, tried four further ways to break the new code, and checked the new code where it matters most — in a real job, which it has now had.
The HIGH that could still report an unearned success — closed
bin/appliance-gate:45-51. My original break now fails, and so does the failure mode my repro did not cover. The distinction matters: I broke it with an unwritable file, which fails atopen. ENOSPC is different — the open succeeds and the write fails — so I tested that separately withGITHUB_OUTPUT=/dev/full, which is exactly that shape:51aa16dEXIT=0, nothing recordedEXIT=5, "cannot record the decision …"/dev/full(open succeeds, write returns ENOSPC), 200EXIT=5/dev/full, 404 + exemption (the path that passes)EXIT=5The ordering is also right now:
boot()callspublish_decisionbefore it echoes, so the failure path no longer prints a sentence about a layer that will be booted. Nothing misleading survives it.The read-back — I gave it the scepticism it deserves, and it holds
This is new code on the path that decides whether anything boots, added in response to a review, so I treated it as a suspect rather than as a fix. I transcribed the step body (
test-appliance.yml:205-244) verbatim and drove it.tail -1ordering guess holds, in both directions. With a stalepublished=falsealready in the file and a real 200, the read-back returnstrue. With a stalepublished=trueand the exemption taken, it returnsfalse. The gate's write is always the last append, and last-wins is also how the runner itself resolvessteps.layer.outputs.*, so the guess agrees with the thing it is guessing about. Themanifest=line the step writes first is correctly ignored, becauseawk -F=reads its$1asmanifest.GITHUB_OUTPUTis non-empty, and when it is empty or unset the step dies four lines earlier —GITHUB_OUTPUTempty gives: No such file or directory, unset givesunbound variableunderset -u, and a full filesystem makes the step's ownecho "manifest=…"fail first. All three exit 1.the gate recorded published=trueand went on through pull, verify, assemble, boot and boot test; run 36381562472 printedthe gate recorded published=falseand skipped them.One residual, and it is the gate's CLI contract rather than a hole: with
GITHUB_OUTPUTempty the script still reports a publishable layer and records nothing, by the[[ -n … ]]guard that the "outside Actions" test covers. It is unreachable from the workflow, per the above.The new code was exercised before you asked me to look at it. Runs 36381555313 (keel-core, 200, no exemption) and 36381562472 (keel-redis, 404 with the exemption) both ran with
tooling_ref: ci/name-the-gate-for-what-it-provesandKEEL_CI_COMMIT: 51aa16d0a85a57f72ce3e159bdc895c2484cb605— this head, not main's copy. That is the new input paying for itself on its first day. The::warning::annotation is real: the Checks API returns it on the redis run, so it is visible without opening the summary.The two refusing paths (404 → exit 1, 200 + exemption → exit 2) were not re-run on this head.
never_publishedandstale_exemptionare untouched by this commit and neither callspublish_decision, and a failing gate aborts the step before the read-back, so I accept that gap rather than asking for two more runs.bats tests/is 71 green,tests/coverage.shreports 100.00 total withbin/appliance-gateat 100/100 — I reproduced both locally — and shellcheck is clean.The HIGH on branch protection — accurate, not merely changed
I checked the new wording against what I measured, clause by clause. "GitHub fails closed"; "leaves the pull request blocked, waiting for a status that will never arrive"; "locks the eight repositories below rather than leaving them unguarded"; "
enforce_adminsistrueon keel-core and keel-mariadb, so not even an owner could merge past it" — all four are what the keel-mariadb#16/#17 pair shows, and bothenforce_adminsvalues aretrue. The keel-core#10 and keel-redis#6 corroboration is reported correctly, including why #6 is the control. keel-core#12's comment now says the same and adds "enforce_adminsis true here", which is right for that repository.The precision on "merge past it" is the correct one: an owner can still edit the rule, which is what unblocks it. Nothing to fix.
The three MEDIUMs
bin/appliance-gate:128-139), asserted by a test that greps for them, documented, and the notice became a::warning::. The underlying property is inherent — an error cannot stop somebody where nothing is required — so writing the limit down loudly is the proportionate answer, and it is the pattern this project already uses for a trap it cannot remove.ref: main— partially closed, deliberately, with the reason stated: thetooling_refinput, the manual procedure written out step by step, and a comment (test-appliance.yml:168-177) that says plainly nothing checks the change automatically.github.job_workflow_shais deferred on the actionlint 1.7.7 constraint. Deferral accepted — the constraint is real and the workaround was immediately load-bearing. It does carry one new cost, below.New, from the fix — MEDIUM, and not a blocker
test-appliance.yml:113-127and:183.tooling_refis a caller-declared input that substitutes the gate's own decision logic, and it is the one such input with none of the visibility its sibling just gained.allow_unpublishednow shouts: a::warning::on the run, caveats in the summary, an error when it expires.tooling_refhas no annotation when it is notmain, no expiry, and no error.KEEL_CI_COMMITis recorded only in the Summary step, which is guarded bypublished == 'true'(:332), so on the exemption path — the one path that passes without booting — a non-defaulttooling_refleaves no trace at all outside the raw step environment.The scenario is a copy-paste: the documented procedure's YAML includes
tooling_ref: my-branch, and somebody lifts that block into a real caller, or a throwaway caller gets merged. That repository's gate then runs whatever that branch says, for as long as the branch exists, silently. One line fixes it —::warning::wheninputs.tooling_ref != 'main'— and by this pull request's own standard forallow_unpublishedit should have one. Follow-up, not a condition.LOW
bin/appliance-gate:56-62:summary()'scat >>is still unchecked, five lines from the redirection just made fatal. WithGITHUB_STEP_SUMMARYon a full filesystem the exemption path printscat: write error: No space left on deviceand still returns 0, so the two caveats just added are dropped while the job passes. The::warning::annotation still fires, which is the part a reader actually sees, so this is cosmetic — but it is the same class, in the same family of functions.diewritesappliance-gate: …to stderr rather than an::error::, so exits 4 and 5 annotate the run only as "Process completed with exit code N" while every other refusal gets a workflow annotation. Pre-existing pattern; the message is in the log either way.Callers
All ten still comments and documentation only — zero added or changed YAML keys in any of them — and the inverted claim is gone from every one. Nine are
CLEAN. keel-wordpress#9 has gone fromBLOCKEDtoCLEANon a re-run, which confirms its red was the intermittent TLS failure and not a fault. keel-nodejs-nginx#2 isUNSTABLEon the pre-existing missing boot test, now owned by keel-nodejs-nginx#3, and that repository has no protection so nothing is blocked by it.Approve.
No CRITICAL and no HIGH. Both HIGH findings are fixed and I verified the fixes by breaking them again rather than by reading them; the read-back is sound and has run green in a real job on this head; the protection statement now matches the measurement; the three MEDIUMs are closed or deferred with reasons I accept. The one new MEDIUM and the two LOWs are follow-ups and none of them gates this.
Merge it, and rename the eight rules in the same window — the list in the description is correct, and until it is done every pull request in those eight repositories is blocked rather than unguarded.
Both closed in
62c2563, and the third point taken as offered.tooling_refvisibility. It now emits a::warning::naming the refwhenever it is not
main, frombin/appliance-gateso the case is testable,and writes a summary line saying the logic did not come from
main. It runsbefore the
case, so every path carries it, including the exemption path thatpasses without booting. Separately, the checkout step now writes the ref and
the resolved commit into the summary unconditionally instead of leaving
KEEL_CI_COMMITto thepublished == 'true'Summary step, which was the gapyou named. Five tests: non-default announced on the boot, exemption and
refusal paths, and silent on
mainand on unset.Measured on run 36385954751: the annotation is on the check run with the ref
in it, and the same run pulled, verified, assembled and booted
corein 43seconds. The visibility buys no false failure.
summary()'s uncheckedcat. Fatal now, exit 5, reproduced withGITHUB_STEP_SUMMARY=/dev/full. Worth noting why it is not a one-liner:summaryis always the right hand side of a pipeline, so adieinside itwould exit a subshell and the script would carry on, which is this bug class
again. It returns 5, every call site ends
| summary || summary_failed, andpipefailcarries it to the caller. The/dev/fulltest is what proves the 5arrives rather than being swallowed by the subshell.
dieemits::error::rather than writing to stderr, so exits 4 and 5annotate the run.
Re-ran your
/dev/fullreproduction onGITHUB_OUTPUTafter the change: thetwo paths that record a decision,
200and404with the exemption, bothexit 5.
404without the exemption exits 1, since it records no decision andrefuses regardless. No path reaches a pass.
77 tests green, coverage 100.00 total with
bin/appliance-gateat 115/115,actionlint 1.7.7 and shellcheck clean. Branch protection untouched: I re-read
all ten rules and they are exactly the table in the description.