fix(ci): vendor the archived contributor-assistant/github-action (BACKLOG #1364) - #621
fix(ci): vendor the archived contributor-assistant/github-action (BACKLOG #1364)#621wshallwshall wants to merge 2 commits into
Conversation
…KLOG #1364)
contributor-assistant/github-action, pinned in cla.yml at ca4a40a7d1004f18d9960b404b97e5f30a505a08
(v2.6.1), is archived upstream (maintainer SAP ran out of bandwidth). zizmor's archived-uses rule
flags it; zizmor is advisory-only here (paths-filtered, not in .github/required-contexts.txt), so
nothing is blocked today -- this is planned hardening, not an incident.
CHECKED FIRST. All 138 forks of the archived repo, for one with real adoption or a patch record:
none qualified (the most active, iainmcgin/cla-github-action, is 4 stars and one maintainer; the
rest are 0 stars or stale since 2020). The one non-fork alternative, the hosted cla-assistant.io
service, needs a GitHub App install and stores signatures on its own server -- cla.yml's own
comments call out in-repo signature storage specifically so no extra secret is needed, and a
hosted service gives that up. Neither is a drop-in.
THE FIX. The action is small and self-contained: 15 TypeScript files compiled to one
dist/index.js (~1.2MB), Apache-2.0 licensed, calling only the GitHub API. Vendored as
.github/actions/cla-assistant-lite/ (action.yml, dist/index.js, LICENSE, a short README naming
the upstream commit); cla.yml's uses: now points at the local path. The job key `cla` (the
required-status-check context per docs/CI.md and .github/required-contexts.txt), the on: trigger
set, permissions:, the if: condition, every with: input, and the allowlist are byte-for-byte
unchanged.
VERIFIED, six independent passes:
- job key/triggers/permissions/if: condition unchanged
- with: block byte-for-byte unchanged bar the uses: line
- every input cla.yml sets exists as a declared input in the vendored action.yml
- vendored action.yml + dist/index.js checksum-match an independent fresh fetch of the same
pinned commit
- Apache-2.0 LICENSE and attribution preserved
- zizmor itself wasn't installed in this sandbox so it could not be re-run locally (CI will
exercise it on push); the no-new-glyphs / comments-explain-why check it also carried did pass
A follow-up pattern scan of the vendored bundle (every http(s):// literal, child_process, eval(,
new Function(, exec() found nothing outside api.github.com/github.com/uploads.github.com calls and
doc-comment URLs from bundled open-source libraries; the one eval( is the known
eval("require")("encoding") idiom node-fetch uses to dodge bundler static analysis, not dynamic
execution; every exec( hit is RegExp.exec(), not a shell call. That is a pattern scan, not a full
line-by-line audit -- before the header described below was added, the vendored bytes were
checksum-identical to what has already been running in this exact workflow under the pinned SHA,
so this relocates an already-trusted dependency rather than introducing a new one.
TWO GATES CAUGHT REAL PROBLEMS ON THE FIRST COMMIT ATTEMPT, before anything landed:
- .gitignore's blanket `dist/` rule (a Python build-artifact convention) was silently dropping
dist/index.js from every git add. Fixed with a narrow negation in .gitignore plus a `-text`
.gitattributes entry scoped to this one path -- `text eol=lf` was tried first and re-encoded
~1,300 bytes on staging, which would have committed a file that no longer matched its own
recorded checksum.
- The licence-header gate (scripts/quality/licence_header_check.py) had no way to accept a file
that legitimately isn't AGPL-3.0. Forcing this project's licence onto someone else's Apache-2.0
code is exactly the "affirmative misstatement of licence" that gate's own docstring says is
worse than an omission. Fixed with a narrow, named VENDORED_LICENCES override (one file, one
entry, still asserting an exact value -- not a tree exemption) plus a two-line
`// SPDX-License-Identifier: Apache-2.0` + provenance comment at the top of the vendored file.
That comment is the only byte difference from the pure upstream copy: stripping the first 176
bytes reproduces the original verified checksum
(a44111084c0d4782206c04b4276292f7fec6d1f7a33525512fbeef3242079dfb) exactly; the as-committed
file's own checksum is 2f8271c83b98e86b8b203205c0dda08415fa49c8c300921e481aabf5c9b989f8.
tests/test_licence_header_gate.py gained four cases covering the override mechanism itself
(unregistered path still uses the project licence, registered path uses its own, the override
still rejects a wrong value rather than skipping the file outright, and the one real production
entry is checked end to end).
VERIFIED (this worktree has no venv, so full pytest/ruff/mypy did not run):
- scripts/quality/licence_header_check.py run directly against the real tracked tree: OK, 1353
files checked, 0 violations (the vendored file included)
- the new VENDORED_LICENCES logic manually exercised standalone (no pytest) against all four
cases the added tests cover: unregistered-path, registered-and-compliant,
registered-but-wrong-value, and the real production entry -- all behaved as intended
- NOT run: the full pytest suite, ruff, mypy. CI will run all three on push.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…efda3e # Conflicts: # docs/BACKLOG.md
Lander: hold this. Three defects found by an adversarial review, none of them the CodeQL alert.I verified the supply chain on this PR thoroughly and signed off. That verification was real but it answered the wrong question: I checked whether the vendored bytes were trustworthy and never checked whether the workflow still runs. An adversarial pass found three things, and I have since confirmed each by direct measurement. 1. The
|
contributor-assistant/github-action, pinned in.github/workflows/cla.yml, is archived upstream. zizmor'sarchived-usesrule flags it. zizmor is advisory-only here, so nothing is blocked today; this is planned hardening, not an incident.No maintained fork or successor exists. All 138 forks were checked. The hosted cla-assistant.io service is not a drop-in either, because it would drop this project's in-repo signature storage on the
cla-signaturesbranch.So the action's own code is vendored into
.github/actions/cla-assistant-lite/andcla.ymlnow points at the local path.Lander verification of the supply-chain claim
The safety argument is that vendoring relocates an already-trusted dependency rather than adding a new one. I tested that against GitHub rather than accepting it:
Byte-identical to the pinned upstream commit. The 176 bytes are the two lines the author disclosed adding: an SPDX identifier and a provenance comment naming the upstream commit.
Recording a wrong turn of my own, because it is the kind that reads as a finding: my first comparison tested raw byte equality and reported a mismatch. That test was mis-specified, not the claim. The author never said the file was byte-identical after adding a header; they said the payload matches. It does.
Job key and config are unchanged
cla.yml's diff is one line. The job keyclais the required status check context onmain, and it is untouched, as are the triggers, permissions,with:block and allowlist.Two gate gaps this exposed, fixed rather than worked around
.gitignorewas silently dropping the vendored bundle. A blanketdist/rule removed.github/actions/cla-assistant-lite/dist/index.jsfrom everygit add, which would have shipped a workflow pointing at a file that was never committed. Fixed with a negation scoped to/.github/actions/**/dist/. The blanket rule stands everywhere else.The licence-header gate had no way to accept a legitimately non-AGPL vendored file. Stamping this project's AGPL identifier on someone else's Apache-2.0 file would be an affirmative misstatement, which is the thing that gate exists to catch. Fixed with
VENDORED_LICENCES, a file list keyed by exact tracked path. It is not a tree exemption and not a glob: each entry still asserts an exact expected value, just not this project's own. One entry today, with tests, and the OK message now discloses the count.A
.gitattributesentry marks the bundle-textso line-ending conversion cannot drift the bytes away from the verified checksum. Aneol=lfattempt was tried first and silently re-encoded about 1,300 bytes; the author caught that by re-verifying rather than assuming.What did not run
The authoring worktree has no venv, so the full pytest and mypy suites did not run locally. Pre-commit hooks did run and pass: ruff, ledger gate, forbidden-content, licence-header, secrets, actionlint, bandit. The suites are CI's job on this PR.
Not armed
The CLA workflow gates every external contributor merge, and this changes where its code comes from. Required approvals on this repo are 0, so arming would land it unread. It should get a human read first.
BACKLOG #1364is currently claimed by the authoring worktree. It can be released once this lands.Generated with Claude Code