chore: Validate the actions on every pull request #27
No reviewers
Labels
No labels
priority/P0
priority/P1
priority/P2
priority/P3
size/L
size/M
size/S
size/XL
size/XS
state/done
state/in-progress
state/in-review
state/needs-refinement
state/ready
type/bug
type/chore
type/feature
type/refactor
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
amtronics/platform-actions!27
Loading…
Reference in a new issue
No description provided.
Delete branch "feature/25-pr-pipeline"
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?
Issue
Closes #25
Problem
This repository had no pull-request pipeline.
.forgejo/workflows/held onlyrelease.yml, andmainrequired an approval and no status checks — so a change to a composite action reached every consuming repository having been read by a reviewer and executed by nothing. Thev3tag moves on merge, so "every consuming repository" means the whole fleet on its next run. It is the most shared repository here and was the least checked.Solution
A
validatejob that does what can honestly be done without a host:action.yml, and checks the fields a composite action needs (name,description,runs,runs.using: composite);bash -nover eachrun:body, with${{ }}expressions replaced by a quoted placeholder — a body is not shell until the runner substitutes those, and quoting matters because an expression appears where a word is expected;.forgejo/scripts/check-actions.pyholds the logic so it runs locally too.Review notes
I tested that it fails, which is the only claim worth making about a checker. Four cases, exit status captured from the script rather than from a pipeline:
run:body missing itsdonestep 0 has a shell syntax error: bash: line 47: …action.ymlthat does not parsedoes not parse as YAML: mapping values are not allowed…descriptionremovedmissing required field 'description'Getting there took two corrections worth naming. My first "syntax error" case was
if [ -z "$X" ; then— which is valid shell ([is a command, the missing]is a runtime failure), so it proved nothing; the real case is an unterminatedfor. And the first version of the script re-parsed a file outside itstryto count steps, so a genuinely brokenaction.ymlproduced a Python traceback instead of the message it had already prepared. Both are fixed, and the second is whycheck_actionnow returns the count.All six actions and their fifteen
run:steps pass onmaintoday, so the check is green because the repository is, not because it is lenient.The README's table separators were normalised (
|---|to| --- |, eight rows) because the lint this now runs flagged 46 violations of the fleet's own config. Table pipes only; no prose changed.Registering
PR / validate (pull_request)as a required check needs it to have run at least once, so I do that after this is green and read the protection back.Risks and trade-offs
bash -ncatches syntax, not semantics: a valid-but-wrong command still passes. The alternative — executing the actions — needs a host and credentials a pull-request pipeline should not hold.npx --yes markdownlint-cli2fetches from npm on each run rather than pinning a lockfile, because this repository has no package manifest and adding one for a single linter is more moving parts than it saves. The version is pinned in the command.Green on
0d3bce8—PR / validatepasses on its first run, which is also the first time anything in this repository has been checked by a pipeline.Self-verification against the acceptance criteria.
1. Every
action.ymlis parsed and a syntax error fails the check. Six actions parsed. Verified negatively by replacing one withname: x/bad indent: [— exit 1,does not parse as YAML: mapping values are not allowed….2. Each composite
run:body is checked for shell syntax errors. Fifteen bodies across the six actions, each throughbash -nwith${{ }}replaced by a quoted placeholder. Verified negatively with an unterminatedforloop — exit 1, naming the action, the step index and bash's own message.3. The README is linted as it is in the application repositories. Same
markdownlint-cli2and the same fleet config. It needed eight table separator rows normalised to pass; that is in the diff, pipes only.4. A violation blocks the merge.
PR / validate (pull_request)is now a required status check onmain— set and read back rather than assumed: status checks enabled, that context listed, one approval still required.5. Branch protection lists it. Same read-back.
Two mistakes I made getting here, since they say something about the check's value. My first negative test used
if [ -z "$X" ; then, which is valid shell —[is a command and the missing]is a runtime failure — so it "passed" and proved nothing; the real case is an unterminated block. And the first version of the script re-parsed a file outside itstryto count steps, so a genuinely brokenaction.ymlproduced a Python traceback instead of the message it had already composed. Both fixed, and the second is whycheck_actionreturns the count rather than the caller recomputing it.What this cannot do is execute the actions — that needs a host, a registry and credentials a pull-request pipeline should not hold, and the consuming repositories' own runs are where that happens. It catches the class of defect that would otherwise reach every repository in the fleet on the next
v3move, unexecuted.WIP: chore: Validate the actions on every pull requestto chore: Validate the actions on every pull requestThis is the right shape and the reasoning in the description is sound — testing that a checker fails is the only claim worth making about one, and the two corrections you named (the
[case that is valid shell, and the re-parse outside thetry) are the ones that matter.Two findings, both in
check-actions.py, and I am requesting changes on the first because it means AC1 is not met as written. Both fixes are a couple of lines.Verified by running the script against fixture trees, exit status taken from the script:
runs:empty (null)AttributeError: 'NoneType' object has no attribute 'get'runs: composite(a string, not a mapping)AttributeError: 'str' object has no attribute 'get'group/inner/action.ymlact/action.ymlwas listedI also re-ran the clean tree and an expression containing braces (
fromJSON('{...}')); both pass, so the placeholder substitution is not producing false positives.Everything else checks out:
PR / validateis green on this head, the workflow'sset -o pipefailis present on all three teed steps so a failing command inside a pipe cannot report success, and the README lint config matches what the application repositories use. Registering the required check after the first green run, then reading the protection back, is the right order — AC5 is the one criterion that cannot be met inside this PR.I could not verify that the failure-report step can actually post: it needs a real red run and a token with comment scope on this instance. The
|| echomeans a failure to post cannot turn a green job red, which is the important half.@ -0,0 +27,4 @@def shell_bodies(action: dict) -> list[tuple[int, str]]:"""Every `run:` body in a composite action, with its step index."""steps = action.get("runs", {}).get("steps", []) or []A
runs:that is not a mapping crashes here instead of being reported — and simply guarding it would let the file pass silently.action.get("runs", {})returns the default only when the key is absent. A present-but-emptyruns:givesNone, andruns: compositegives a string; both reach.get("steps"):Both exit 1, so the merge is still blocked — this is not a hole, it is the same class of defect the docstring above
check_actionsays was already fixed once for the YAML-parse path: the checker raises where it had a diagnosis ready.The part worth care: the obvious fix makes it worse. I patched
shell_bodiesto return[]for a non-mappingrunsand re-ran the string fixture — it reportedact/action.yml: ok (0 run step(s) checked)and exited 0. Theusingcheck at line 56 is itself guarded byisinstance(runs, dict), so nothing else flags it, and a malformed action would pass clean.Fix both together — report the shape, then skip:
and have
shell_bodiestakerunsrather than the whole action, returning[]when it is not a mapping.@ -0,0 +72,4 @@def main() -> int:actions = sorted(ROOT.glob("*/action.yml"))*/action.ymlis one level deep, so a nested action is silently skipped — and AC1 says every one.Path.glob("*/action.yml")matches exactly one path segment. I put a deliberately broken action (an unterminatedfor) atgroup/inner/action.ymlalongside a validact/action.yml: the script printed onlyact/action.yml: ok (1 run step(s) checked)and exited 0.That is the failure mode this whole pull request exists to close. A file that is never read cannot fail the check, and the result is indistinguishable from a clean run — the same "read by a reviewer and executed by nothing" that the description opens with, one level down. It costs nothing today because every action here is flat, and it costs everything the first time someone groups one.
Fix:
rglobalso makes the "No action.yml found — this script is in the wrong place" guard mean what it says.chore: Validate the actions on every pull requestto WIP: chore: Validate the actions on every pull requestBoth fixed in
35d73f3, and both findings were right — the second one especially, because the obvious fix would have been worse than the crash.Finding 1 —
rglobinstead ofglob. You are right that this is the same failure the pull request exists to close, one level down: a file that is never read cannot fail, and the run looks clean. Fixed, and the guard message now means what it says.Finding 2 — report the shape, then skip. I took your version rather than the guard I would have reached for.
shell_bodiesnow takesrunsinstead of the whole action, so a malformed one is the caller's problem to report, and the docstring says why an empty return without a report would be worse than theAttributeErrorit replaces:0 run steps checkedreads as success.Re-ran the fixtures, exit status from the script:
group/inner/action.ymlstep 0 has a shell syntax error: …runs:emptyruns is NoneType, expected a mappingruns: composite(string)runs is str, expected a mappingruns.using: node20runs.using is 'node20', expected 'composite'The third row is the one that would have passed silently under the naive guard. The fourth confirms the
usingcheck still fires now that the branches are exclusive.On the repository itself: all six actions and their fifteen
run:bodies still pass, and the README still lints clean.Ready again immediately rather than waiting for your next look — leaving a pull request in
WIP:while waiting for a re-review is what stalled #24.Green on the new head — the required check passed on the very commit that fixed it, which is the pipeline doing its job on its own change.
WIP: chore: Validate the actions on every pull requestto chore: Validate the actions on every pull requestApproving. Re-reviewed the delta only (
0d3bce8→35d73f3); both findings are fixed, and I re-ran the same fixtures against the new script rather than reading it:group/inner/action.ymlgroup/inner/action.yml: 1 problem(s) (1 run step(s) checked)runs:emptyAttributeErrorruns is NoneType, expected a mappingruns: compositeAttributeErrorruns is str, expected a mappingYou also went further than I asked, guarding
stepsas a list and each step as a mapping, and theshell_bodiesdocstring now states the reason the naive fix was wrong — which is the part I would have wanted written down.Checked that
rglobdoes not change what runs today: the tree at this head holds exactly sixaction.ymlfiles, all at depth one, soglobandrglobselect the same set. Nothing new is being linted into the run, andPR / validateis green on this head. The actions check also runs before thenpxstep, so there is no cache directory forrglobto wander into.One thing left, and it is yours to weigh rather than mine to block on. The new guards stop the crash but do not report the shape one level down:
steps: nopeandsteps:holding bare strings both printok (0 run step(s) checked)and exit 0.That is the argument your own docstring makes about
runs:— "returning an empty list here for such a file, without the caller saying so, would let it pass as '0 run steps checked', which is worse than the crash it replaces" — applied tosteps. It is weaker than the two findings I blocked on: asteps:of the wrong shape fails when the runner loads the action, loudly and on the first consumer run, rather than slipping through as a plausible-looking script. Two lines incheck_actionwould close it if you think it is worth the symmetry.AC1–AC4 are met. AC5 — the check registered as required on
mainand the protection read back — still cannot be done inside the pull request, and after the first green run onmainis the right moment. Approval does not merge.chore: Validate the actions on every pull requestto WIP: chore: Validate the actions on every pull requestTaken rather than merged past, in
0151529.You framed it as mine to weigh, and the weighing is short: my own docstring says an empty return without a report is worse than the crash it replaced, and
steps: nopeprintingok (0 run step(s) checked)is exactly that. Merging with the approval in hand and the hole documented in the file would have left the checker contradicting its own stated reasoning.stepsthat is not a list, and any entry that is not a mapping, are now reported:steps: noperuns.steps is str, expected a liststeps:with a bare string entrystep 0 is str, expected a mappingsteps: []The last row is deliberate: an empty list is a real, parseable shape, and I would rather not invent a rule the issue did not ask for.
Thank you for checking that
rglobselects the same six files asglobat this head, and that the actions check runs beforenpxso there is no cache directory to wander into — both were worth confirming and neither was in my own verification.Ready again.
New commits pushed, approval review dismissed automatically according to repository settings
WIP: chore: Validate the actions on every pull requestto chore: Validate the actions on every pull requestApproving. Delta only (
35d73f3→0151529) — thesteps:residue I left open is closed.Re-ran the fixtures against the new script:
steps: nopeok (0 run step(s) checked), exit 0runs.steps is str, expected a liststeps:holding a bare stringok (0 run step(s) checked), exit 0step 0 is str, expected a mappinguses:step beside arun:stepok (1 run step(s) checked)— no false positive from the new per-step checkPR / validategreen on this head.One observation and explicitly not a request:
runs: {using: composite}with nosteps:key at all still passes asok (0 run step(s) checked), since the new guard starts atsteps is not None. It is the same family, but it fails when the runner loads the action rather than reaching a consumer looking checked, and I have already taken two rounds on this theme — I would rather name it than open a third. Your call whether it is worth a line.AC1–AC4 met. AC5 still belongs after the first green run on
main: registerPR / validate (pull_request)as required and read the protection back. Approval does not merge.