Skip to content

Ci: keep the action pinning a pure pin — walk back two unauthorised major bumps (stacked on #505) - #613

Open
AmaadMartin wants to merge 2 commits into
fix/pin-github-actions-to-commit-shasfrom
feat/pin-github-actions-commit-shas
Open

Ci: keep the action pinning a pure pin — walk back two unauthorised major bumps (stacked on #505)#613
AmaadMartin wants to merge 2 commits into
fix/pin-github-actions-to-commit-shasfrom
feat/pin-github-actions-commit-shas

Conversation

@AmaadMartin

@AmaadMartin AmaadMartin commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Please ensure you have read the contribution guide before creating a pull request.

Link to Issue or Description of Change

  1. Link to an existing issue (if applicable):
    Closes: #issue_number
    Related: #issue_number
  2. Or, if no issue exists, describe the change:

Stacked PR — please read first. A collision check found that
#505 (fix/pin-github-actions-to-commit-shas) is already open and
already pins all 12 uses: references. Rather than open a competing implementation of
the same six files, this PR is stacked on that branch and contributes the two lines
it got wrong. Its base is fix/pin-github-actions-to-commit-shas, not main.
This PR's own diff is two lines; the 12-line total below is what the stack lands
against main.

Both lines fix the same defect: #505 folded unauthorised major-version upgrades
into a change that otherwise reads as a purely mechanical pin. This PR walks both back,
so the stack now follows one rule across all 12 references — pin at the major the
reference runs today; upgrade only where the task named it.
See
One rule, and the one exception below.

Collision check performed:
gh pr list --repo AmaadMartin/adk-js --state open --limit 1000 --json number,title,headRefName
followed by gh pr diff <n> --name-only on every plausibly adjacent PR. Result:
#505 collides directly (stacked on, as above). Nine further open PRs touch the same
workflow files but land unrelated changes and do not conflict with this one —
#403 (job timeout-minutes), #467/#428/#416/#406 (Node version pinning), #296/#306
(npm caching), #393 (Go cache path), #427 (npm ci).

Problem: Every GitHub Action in .github/workflows/ is referenced by a floating
major tag (@v6, @v5, @v4, @v3). Git tags are mutable. Whoever controls an action
repository — or anyone who compromises it — can retag v6 onto arbitrary code, and the
next adk-js CI run executes that code with no diff, no review and no notification. These
jobs run with a GITHUB_TOKEN, and release-please.yml additionally runs with
contents: write and a release token.

This is not a theoretical hazard, and there is direct evidence in the sibling repo.
adk-python pinned actions/checkout at df4cb1c069e1874edd31b4311f1884172cec0e10
(release v6.0.3). The v6 tag today resolves to
d23441a48e516b6c34aea4fa41551a30e30af803 (release v6.1.0) — the tag moved out from
under the version that was reviewed. That movement is exactly what a SHA pin makes
visible instead of silent.

GitHub's own hardening guidance is to
"Pin actions to a full length commit SHA",
because a bad actor "would need to generate a SHA-1 collision for a valid Git object
payload". OpenSSF Scorecard enforces the same rule as its Pinned-Dependencies check.

Solution: Reference every action by its full 40-character commit SHA with a trailing
# vN comment, so the SHA is what GitHub executes and the version stays readable. The
stack rewrites all 12 uses: references across the six workflow files — 12 modified
lines, zero added or deleted lines, no file outside .github/workflows/ touched.

Distinct pins after the stack, with the concrete release each floating tag resolved to:

Action Tag pinned Commit SHA Concrete release Refs
actions/checkout v6 d23441a48e516b6c34aea4fa41551a30e30af803 v6.1.0 4
actions/checkout v4 11d5960a326750d5838078e36cf38b85af677262 v4.4.0 1
actions/setup-node v6 249970729cb0ef3589644e2896645e5dc5ba9c38 v6.5.0 2
actions/setup-python v5 a26af69be951a213d495a4c3e4e4022e16d87065 v5.6.0 1
actions/setup-go v5 40f1582b2485089dde7abd97c1529aa768e1baff v5.6.0 1
actions/github-script v7 f28e40c7f34bde8b3046d885e986cb6290c5673b v7.1.0 1
actions/github-script v6 d7906e4ad0b1822421a7e6a35d5ca353c962f410 v6.4.1 1
googleapis/release-please-action v4 5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 v4.4.1 1

One rule, and the one exception. A pin should freeze what CI already runs. #505
broke that in two places by folding major-version upgrades into the pin, which makes a
diff that looks mechanical quietly change which action executes. This PR walks both back
so a single rule now holds across all 12 references — pin at the major the reference
runs today; upgrade only where the task named it.
The result: 11 of 12 pins are exact
no-ops
, and the stack contains exactly one upgrade.

The two lines this PR changes:

  1. csat.yml actions/github-script: v7 → pinned at v6. Chore(ci): pin every GitHub Actions uses: to a full commit SHA #505 converged it on the
    v7 commit used by auto-assignment.yml. Those are different revisions of the action,
    not two names for one (v6's action.yml declares using: node16, v7's declares
    using: node20). csat.yml runs v6 today, so the behaviour-preserving pin is
    d7906e4ad0b1822421a7e6a35d5ca353c962f410 (v6.4.1). Nothing is lost by waiting:
    since 2024-06-30 the runner already executes node16 actions on Node 20 by
    default
    .
  2. auto-assignment.yml actions/checkout: v6 → pinned at v4. Same defect: the
    workflow runs checkout@v4 today (v4 declares using: node20, v6 declares
    using: node24), so Chore(ci): pin every GitHub Actions uses: to a full commit SHA #505's v6 SHA silently moved its runtime. Pinned at
    11d5960a326750d5838078e36cf38b85af677262 (v4.4.0). Converging every checkout on
    one SHA is a maintenance nicety, not a reason to change a runtime inside a pin-only
    change — and this workflow reacts to every opened issue and PR.

The one upgrade that stays — csat.yml actions/checkout v3 → v6. This one is
deliberate and is called out rather than buried: the task asked for it by name ("bump the
stale actions/checkout@v3 up to the same v6 the other workflows use"). It is also the
only reference where pinning in place would be actively harmful — checkout@v3 is three
majors behind and still declares using: node16, so freezing it would cement exactly the
staleness this work exists to remove. It is a no-input checkout whose only job is to make
./.github/scripts/csat.cjs readable by the next step, and v6 is already what four other
references run on ubuntu-latest, windows-latest and macos-latest.

Trade-off accepted: actions/checkout therefore resolves to two SHAs across the repo
(v6 ×4, v4 ×1) instead of one. That is the deliberate cost of keeping this a pin, not an
upgrade PR. Bumping auto-assignment.yml to v6 is a one-line follow-up whenever the
maintainers want it.

Annotated-tag trap (why these SHAs and not the obvious ones). For
actions/github-script@v6 and googleapis/release-please-action@v4 the tag is
annotated, so gh api repos/OWNER/REPO/git/ref/tags/vN --jq '.object.sha' returns a
tag object SHA — 00f12e3e20659f42342b1c0226afda7f7c042325 and
8b8fd2cc23b2e18957157a9d923d75aa0c6f6ad5 respectively — not a commit. Pinning either
value yields a reference that does not resolve to a runnable action. Every SHA here was
resolved with the tags REST endpoint, which dereferences to the commit, then confirmed
to be a real commit in that repository (commands and output below).

Deviations from the original task text, called out for review:

  1. The task's inventory was incomplete. It omitted actions/checkout@v4 and
    actions/github-script@v7 in auto-assignment.yml. The task says pin every
    action, so the stack covers 12 references, not 10.
  2. actions/checkout@v3 in csat.yml is bumped to v6 — the one upgrade in the stack,
    authorised by the task text and justified above.
  3. actions/checkout@v4 in auto-assignment.yml is pinned at v4, not bumped to v6.
    The task did not ask for this one, so it is not taken; the earlier plan's
    "every checkout on one SHA" invariant is knowingly given up to keep the change a
    pure pin.
  4. actions/github-script in csat.yml is pinned at v6, not bumped to v7. Also not
    asked for, and a real runtime delta.

Out of scope, deliberately: adding .github/dependabot.yml to maintain these pins, a CI
guard rejecting future mutable-tag references, and least-privilege permissions: blocks.
Each is queued separately.

Testing Plan

Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes.
Unit Tests:
[x] I have added or updated unit tests for my change. — Not applicable: zero new lines
of executable code.
This is declarative CI configuration. A spec asserting the
contents of a YAML file that sits next to it is review noise with no regression
value; a genuine repo-wide "no mutable tag" guard is a separate queued task. No
vitest spec, fixture or script was added.
[x] All unit tests pass locally. — The repo suite cannot be affected by a workflow-file
edit, so it was not run. What was run is the verification below, including the one
repo gate that actually reads YAML (secretlint).

Verification run locally on the pushed commit, from the repo root:

$ grep -rn "uses:" .github/workflows/
.github/workflows/csat.yml:17:      - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
.github/workflows/csat.yml:18:      - uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
.github/workflows/release-please.yml:17:      - uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4
.github/workflows/validation.yaml:21:        uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
.github/workflows/validation.yaml:24:        uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
.github/workflows/validation.yaml:27:        uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
.github/workflows/auto-assignment.yml:20:        uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
.github/workflows/auto-assignment.yml:23:        uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
.github/workflows/cross-language-integration.yml:15:        uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
.github/workflows/cross-language-integration.yml:18:        uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
.github/workflows/cross-language-integration.yml:21:        uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
.github/workflows/license-check.yml:15:        uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6

# every reference pinned, nothing missed
$ grep -rn  "uses:" .github/workflows/ | wc -l                                    # 12
$ grep -rEn "uses: .*@[0-9a-f]{40} # v[0-9]+$" .github/workflows/ | wc -l         # 12
$ grep -rEn "uses: .*@(v[0-9]|main|master)" .github/workflows/ | wc -l            # 0

# every SHA exactly 40 lowercase hex, none truncated
$ grep -rhoE "@[0-9a-f]{40} " .github/workflows/ | wc -l                          # 12
$ grep -rnE  "uses: .*@[0-9a-fA-F]{7,39}( |$)" .github/workflows/                 # (no output)

# no uses: anywhere else in the repo (no composite actions, no reusable workflows)
$ grep -rn --exclude-dir=node_modules --exclude-dir=.git "uses:" . | grep -v '^./.github/workflows/'
(no output)

# all six files still parse as YAML
$ python3 -c "import sys,yaml; [yaml.safe_load(open(f)) for f in sys.argv[1:]]; print('yaml ok')" \
    .github/workflows/*.yml .github/workflows/*.yaml
yaml ok

# the diff is exactly the specified shape, and nothing else
$ git diff <merge-base> --stat
 .github/workflows/auto-assignment.yml            | 4 ++--
 .github/workflows/cross-language-integration.yml | 6 +++---
 .github/workflows/csat.yml                       | 4 ++--
 .github/workflows/license-check.yml              | 2 +-
 .github/workflows/release-please.yml             | 2 +-
 .github/workflows/validation.yaml                | 6 +++---
 6 files changed, 12 insertions(+), 12 deletions(-)
$ git diff --check                                                                # clean

# secretlint -- the one repo gate that reads YAML -- tolerates bare 40-hex SHAs
$ npm ci && npx secretlint ".github/workflows/**"
exit=0   (no findings; package-lock.json left untouched)

Every SHA was independently re-resolved from its tag at implementation time and confirmed
to be a commit, rather than trusted from the plan:

$ gh api "repos/OWNER/REPO/tags?per_page=100" --jq '.[] | select(.name=="vN") | .commit.sha'
$ gh api "repos/OWNER/REPO/commits/<sha>" --jq '.sha'      # confirms it is a real commit

actions/checkout@v6                 -> d23441a48e516b6c34aea4fa41551a30e30af803  (commit ✓, v6.1.0)
actions/checkout@v4                 -> 11d5960a326750d5838078e36cf38b85af677262  (commit ✓, v4.4.0)
actions/setup-node@v6               -> 249970729cb0ef3589644e2896645e5dc5ba9c38  (commit ✓, v6.5.0)
actions/setup-python@v5             -> a26af69be951a213d495a4c3e4e4022e16d87065  (commit ✓, v5.6.0)
actions/setup-go@v5                 -> 40f1582b2485089dde7abd97c1529aa768e1baff  (commit ✓, v5.6.0)
actions/github-script@v7            -> f28e40c7f34bde8b3046d885e986cb6290c5673b  (commit ✓, v7.1.0)
actions/github-script@v6            -> d7906e4ad0b1822421a7e6a35d5ca353c962f410  (commit ✓, v6.4.1)
googleapis/release-please-action@v4 -> 5c625bfb5d1ff62eadeeb3772007f7f66fdcf071  (commit ✓, v4.4.1)

All eight matched the values in the stack, so no upstream tag moved between planning and
implementation. The runtimes each action declares were read from action.yml at the
pinned commits, and are the basis for the "real runtime delta" claims above:
checkout v3 node16 / v4 node20 / v6 node24; github-script v6 node16 /
v7 node20.

Manual End-to-End (E2E) Tests:

These files execute on GitHub's runners, so CI is the only true end-to-end proof — but it
covers only part of the change, and the split is worth stating plainly.

Exercisable by CI on a PR — pins #3, #4, #5, #8, #10, #11, #12: the validation
workflow (checkout, setup-node, setup-python) across the ubuntu/windows/macos
matrix, License Header Check (checkout), and Cross-Language Tests (checkout,
setup-node, setup-go). To confirm the pin is what actually ran, open a run's first
step and check that the "Download action repository" line shows the SHA, not a tag.

Not exercisable on a PR — pins #1, #2, #6, #7, #9: auto-assignment.yml and csat.yml
trigger on issue/PR opened/closed events in the base repo, and release-please.yml
triggers on push to main. None can be fired from a pull request and no attempt was made
to fire them artificially. They are covered by the static verification above (SHA↔tag
resolution plus the commit check). This is also the group with the least CI coverage, and
it is the reason the two walk-backs in this PR matter: after them, four of these five
pins are exact no-ops, and the single remaining behavioural change is csat.yml's
no-input checkout v3→v6.

Note on CI for this PR specifically: validation.yaml triggers on
pull_request: branches: [main]. Because this PR is stacked and targets
fix/pin-github-actions-to-commit-shas, those workflows do not trigger here — only
auto-assign ran, which is not validation. They run on #505 and will run again once the
stack lands on main. CI for this PR is therefore absent, not green, and the
following was run locally instead, on the exact pushed commit e3fa5874:

$ npm ci                                    # exit 0, package-lock.json untouched
$ npx secretlint ".github/workflows/**"     # exit 0, no findings
$ npm run build                             # exit 0
$ npm run lint      (eslint "**/*.ts")      # exit 0

No unit tests were run, for the reason given above: the change contains no executable
code, and a workflow-file edit cannot affect the TypeScript suite. build and lint are
reported here as evidence that the pushed commit is sound, not because they exercise the
change.

Checklist

[x] I have read the CONTRIBUTING.md document.
[x] I have performed a self-review of my own code.
[x] I have commented my code, particularly in hard-to-understand areas. — The # vN
comment on each pin is the entire documentation surface; the reasoning lives here and
in the commit message rather than in the workflow files.
[x] I have added tests that prove my fix is effective or that my feature works. — No
executable code, so no unit test; proven instead by the pin/format/YAML/secretlint
verification above and by CI on the workflows a PR can trigger.
[x] New and existing unit tests pass locally with my changes.

Amaad Martin added 2 commits August 3, 2026 21:05
The pinning pass converged csat.yml's actions/github-script on the v7 commit
already used by auto-assignment.yml. That is a major-version bump smuggled
into a pin-only change: the two are different revisions of the action, not
two names for the same one (v6's action.yml declares "using: node16", v7's
declares "using: node20").

Pinning exists to freeze what CI already runs so a moved tag cannot swap it
out. csat.yml runs github-script v6 today, so the correct pin is the commit
its v6 tag resolves to -- d7906e4ad0b1822421a7e6a35d5ca353c962f410, release
v6.4.1 -- which keeps the step byte-identical to what it executes now while
making it immutable. Upgrading the action is a separate, reviewable change.
Nothing is lost by waiting: since 2024-06-30 the runner already executes
node16 actions on Node 20 by default.

Resolved via the tags REST endpoint, which dereferences to the commit. The
v6 tag here is annotated, so repos/actions/github-script/git/ref/tags/v6
returns the tag object 00f12e3e20659f42342b1c0226afda7f7c042325 instead --
pinning that value would not resolve to a runnable action.

The checkout bumps in this branch are unaffected: those were explicitly
authorised to converge the stale v3/v4 references on the v6 the other four
workflows already use.
Second of the two unauthorised upgrades the pinning pass folded into this
stack. auto-assignment.yml runs actions/checkout@v4 today; pinning it at the
v6 commit freezes it onto a different major (v4 declares "using: node20", v6
declares "using: node24") under a diff that otherwise reads as purely
mechanical. Convergence on one checkout SHA is a maintenance nicety, not a
reason to move a workflow's runtime inside a pin-only change.

Pin at the commit its own v4 tag resolves to,
11d5960a326750d5838078e36cf38b85af677262 (release v4.4.0), so the step keeps
executing exactly what it executes now.

This leaves the stack with exactly one upgrade, and it is the one the task
asked for by name: the stale actions/checkout@v3 in csat.yml, which is kept
at v6. That reference is three majors behind and still declares node16;
pinning it in place would cement precisely the staleness this work exists to
remove, so the rule applied across all 12 references is "pin at the major the
reference runs today, upgrade only where the task named it".
@AmaadMartin AmaadMartin changed the title Ci: pin csat.yml's github-script at v6 instead of bumping it to v7 (stacked on #505) Ci: keep the action pinning a pure pin — walk back two unauthorised major bumps (stacked on #505) Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant