feat(ci): add a manual workflow_dispatch trigger to the hash sync - #45
Conversation
Without this there is NO way to re-drive renovate-hash-sync.yml after fixing a
bug in it. Three things block every other route, and all three held at once on
run 29669946883, which executed the same broken kernel-URL code three times
across two separate fixes:
* pushing the fix to the branch triggers nothing — the paths: filter does
not list the workflow file itself;
* a maintainer merging the fix in is skipped by the actor == renovate[bot]
gate;
* and a re-run replays the workflow definition from the commit the ORIGINAL
run was created against, so it faithfully re-executes the old code. Pulling
attempt 3's log confirmed it ran the pre-#42 unanchored grep.
Only Renovate moving the branch head, or a manual trigger, gets new code to run.
The `branch` input is required and is NOT the ref you dispatch from: GitHub
runs the workflow definition from the dispatch ref while this workflow checks
out and pushes to inputs.branch. Dispatch from the default branch (fixed
workflow), name the broken branch in the input. Dispatching *on* the stale
branch would just re-run its stale copy — the exact trap this fixes.
Manual runs refuse to target the default branch: this workflow commits and
pushes, and an auto-generated hash commit must go through a PR.
workflow_dispatch is already restricted to collaborators with write access, so
the job gate does not widen who can drive this.
Also resolves the pre-existing actionlint finding it was adjacent to: the push
step interpolated github.event.pull_request.head.ref straight into the script
body, a command-injection vector via an attacker-chosen branch name on a PR
from an untrusted contributor. Both use sites now go through a job-level
TARGET_BRANCH env var, and the push uses `--` so a branch named like an option
is not parsed as one. actionlint's untrusted-expression warning is gone.
Corrected two stale header claims while in here: the workflow HAS now run
against a real Renovate PR (#41, 6.18.38 -> 6.18.39), and its warn-and-skip
paths can report SUCCESS while doing nothing — which is exactly how the stale
linux.hash went unnoticed. The github-package loop and the lzma-sdk and
sdcard-payload steps remain unproven.
actionlint and shellcheck clean. Guard logic tested: a renovate branch passes;
the default branch and an empty input are both rejected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UFdgJXQKvrucXeKDami499
There was a problem hiding this comment.
Pull request overview
Adds a manual workflow_dispatch escape hatch to the Renovate hash-sync workflow so maintainers can re-drive the hash refresh after workflow fixes land on the default branch, and updates the accompanying Renovate documentation to explain the “dispatch from vs. target branch” distinction.
Changes:
- Add
workflow_dispatchwith requiredbranchinput to.github/workflows/renovate-hash-sync.yml, plus branch-keyed concurrency for dispatch runs. - Refactor target-branch handling into a single
TARGET_BRANCHenv var and usegit push ... -- "HEAD:$TARGET_BRANCH"to avoid branch-name injection/option parsing. - Document the manual dispatch behavior and rationale in
docs/renovate.md.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| docs/renovate.md | Documents the new manual workflow_dispatch escape hatch and how to use the branch input safely. |
| .github/workflows/renovate-hash-sync.yml | Adds manual dispatch trigger, dispatch-safe concurrency grouping, target-branch resolution, and safer push behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if [ -z "$TARGET_BRANCH" ]; then | ||
| echo "::error::branch input resolved to empty -- nothing to check out." >&2 | ||
| exit 1 | ||
| fi | ||
| if [ "$TARGET_BRANCH" = "$DEFAULT_BRANCH" ]; then | ||
| echo "::error::refusing to run against the default branch ('$TARGET_BRANCH') -- this workflow commits and pushes hash fixes, which must go through a PR." >&2 | ||
| exit 1 | ||
| fi | ||
| echo "target branch: $TARGET_BRANCH" |
From Copilot's review on #45. The guard compared $TARGET_BRANCH to the default branch as a plain string, so dispatching `refs/heads/master` slipped past it while actions/checkout and `git push` still resolved it to master. Verified with `git push --dry-run`, and the hole is wider than reported -- THREE spellings all reach master, two of which bypassed the check: master -> master (caught) refs/heads/master -> master (bypassed) heads/master -> master (bypassed; not in the review) So normalising away a `refs/heads/` prefix, as suggested, would still have left `heads/master` open. Rejecting all ref-namespace forms instead of normalising them, deliberately: TARGET_BRANCH is consumed by BOTH actions/checkout and the final git push, so the value validated here must be the exact value those steps use. Normalising would mean re-exporting through $GITHUB_ENV and depending on it overriding the job-level env: for subsequent steps -- a precedence subtlety not worth betting a push to the default branch on. Also added `git check-ref-format --branch` as a syntactic gate (rejects embedded spaces, "..", a leading "-", empty), and dropped the event_name == workflow_dispatch condition so the automatic path is validated too. That costs nothing and means a PR head branch is checked as well. Verified, 12 cases: legitimate names (renovate/..., feature/x) pass; master, refs/heads/master, heads/master, remotes/..., tags/..., -x, "a..b", "x y" and empty are all rejected, each with the message that explains which rule fired. shellcheck clean; actionlint clean apart from its stale runner-label list. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UFdgJXQKvrucXeKDami499
|
Valid finding, fixed in Verified with
|
| input | pushes to master |
old guard |
|---|---|---|
master |
yes | caught |
refs/heads/master |
yes | bypassed — the finding |
heads/master |
yes | bypassed — not in the review |
So the suggested fix of normalising away a refs/heads/ prefix would still have left heads/master open. (remotes/... and tags/... don't reach master, but do create junk refs, so they're rejected too.)
Rejecting, not normalising — deliberately
TARGET_BRANCH is consumed by both actions/checkout and the final git push. The value validated has to be the exact value those steps use. Normalising in the guard step would mean re-exporting through $GITHUB_ENV and depending on that overriding the job-level env: for subsequent steps — a precedence subtlety I don't want to bet a push to the default branch on. Rejecting keeps one value end to end.
Also added git check-ref-format --branch as a syntactic gate, and dropped the event_name == 'workflow_dispatch' condition so the automatic path gets validated too. Costs nothing, and means a PR head branch is checked as well.
All 12 cases
'renovate/kernel-longterm-6.18-6.x' ALLOW
'feature/x' ALLOW
'master' REJECT (default branch)
'refs/heads/master' REJECT (not a plain branch name)
'heads/master' REJECT (not a plain branch name)
'refs/heads/other' REJECT (not a plain branch name)
'remotes/origin/master' REJECT (not a plain branch name)
'tags/v1' REJECT (not a plain branch name)
'-x' REJECT (invalid branch name)
'a..b' REJECT (invalid branch name)
'x y' REJECT (invalid branch name)
'' REJECT (empty)
Each rejection reports which rule fired. shellcheck clean; actionlint clean apart from its stale runner-label list.
Adds a
workflow_dispatchtrigger with a requiredbranchinput torenovate-hash-sync.yml.Why this is necessary, not convenient
There is currently no way at all to re-drive this workflow after fixing a bug in it. Three things block every route, and all three held simultaneously on run 29669946883:
paths:filter doesn't list the workflow file, so nothing triggersactor == renovate[bot]gate skips the jobThat run went through three attempts across two separate fixes and executed the same broken kernel-URL code every time. Confirmed by pulling attempt 3's log — it ran the pre-#42 unanchored grep, with none of #42's new comments present:
Only Renovate moving the branch head, or a manual trigger, gets new code to run.
The input is the branch to repair, not the branch to run from
This distinction is the whole trap, so it's called out in the input description, the workflow header, and
docs/renovate.md:So: dispatch from the default branch (which has the fixed workflow) and name the broken branch — e.g.
renovate/kernel-longterm-6.18-6.x— in the input. Dispatching on the stale branch would just run its stale copy again, reproducing the original problem.Safety
workflow_dispatchis already restricted to collaborators with write access — the same bar therenovate[bot]gate enforces on the automatic path.Fixes the adjacent pre-existing injection finding
I flagged this on #43 as out of scope, but this PR touches exactly those expressions, so it's fixed here. The push step interpolated
github.event.pull_request.head.refstraight into the script body — a command-injection vector, since a branch name is attacker-chosen text on a PR from an untrusted contributor. Both use sites now route through a job-levelTARGET_BRANCHenv var, and the push uses--so a branch named like an option isn't parsed as one. actionlint'spotentially untrustedwarning is gone (was 1, now 0).Two stale header claims corrected
linux.hashwent unnoticed through three green runs. The header now says to read every warn-and-skip path that way.The github-package loop and the lzma-sdk / sdcard-payload steps remain genuinely unproven, and still say so.
Verification
ubuntu-26.04, which every workflow in this repo trips).run:blocks.run:comment, which it parses as an empty expression. Fixed before commit.Note I could not execute a real dispatch run, since the trigger only exists once this is on the default branch. First live use will be its own proof.
🤖 Generated with Claude Code
https://claude.ai/code/session_01UFdgJXQKvrucXeKDami499