Skip to content

chore(cla-assistant): harden the reusable and drop the runner input (ENG-2017) - #55

Open
lucas-koontz wants to merge 3 commits into
mainfrom
chore/eng-2017-harden-cla-reusable
Open

chore(cla-assistant): harden the reusable and drop the runner input (ENG-2017)#55
lucas-koontz wants to merge 3 commits into
mainfrom
chore/eng-2017-harden-cla-reusable

Conversation

@lucas-koontz

@lucas-koontz lucas-koontz commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

User story

As the owner of the newdev and newprod clusters
I want the CLA check to be structurally incapable of running on a self-hosted runner
So that no repository can point it at a pod in our clusters again

Why this matters

Ten public repositories ran a hand-rolled copy of this job with runs-on: mdb-dev, which is a pod inside our newdev EKS cluster and not a GitHub VM. Its triggers, pull_request_target and issue_comment, both run in base-repo context, so GitHub's fork-approval gate never applied. An outside account opening a pull request, or leaving a comment on any issue, started that pod with nobody clicking anything. Six accounts already have, and the job records name the runner each landed on.

This reusable already defaults runs-on to ubuntu-latest, and the three repositories that call it are fine. The gap is that the knob exists at all, and that the job it runs was pinned to a mutable third-party tag.

What happens today

flowchart TD
    C["Caller wrapper"] --> I["Optional runs-on input"]
    I --> J["Job runs on whatever the caller named"]
    J --> S{"Step condition filters the event"}
    S -->|"unrelated comment"| N["Runner already claimed, step skipped"]
    S -->|"CLA event"| A["contributor-assistant on a mutable tag"]
Loading

What should happen

flowchart TD
    C["Caller wrapper"] --> J{"Job condition filters the event"}
    J -->|"unrelated comment"| N["Job skipped, no runner claimed"]
    J -->|"CLA event"| R["Job runs on ubuntu-latest, no input to override it"]
    R --> A["contributor-assistant pinned to a commit SHA"]
Loading

Acceptance criteria

  • The reusable accepts no runs-on input, so a caller cannot request a self-hosted runner.
  • An issue_comment that is not a CLA comment leaves the job skipped, with no runner claimed.
  • Every pull_request_target still runs the check, because that is what asks a new contributor to sign.
  • contributor-assistant/github-action is pinned to a commit SHA.
  • The workflow grants actions: read rather than actions: write.
  • A calling repo with no ledger gets the branch AND the signature file created on the first run, rather than a 404 the contributor cannot act on.
  • Negative: anton, cowork and cowork-server keep working unchanged, and none of them passes the removed input.

How to test

  1. Open a pull request from a fork of a calling repository using an account not in the allowlist. The CLA job runs on a GitHub-hosted runner and the bot asks for a signature.
  2. Comment the signature phrase. The re-check passes and the signature lands on that repository's cla branch.
  3. Comment something unrelated on any open issue in a calling repository. The CLA workflow reports the job as skipped and claims no runner.
  4. Confirm ca4a40a7d1004f18d9960b404b97e5f30a505a08 is the commit behind the v2.6.1 tag it replaced.

Notes for the reviewer

Deleting the input is the load-bearing change, not moving the default. The default was already ubuntu-latest. A default is advice; ten repositories showed what happens when a knob exists next to a cluster. With the input gone, no caller can express the mistake.

The event filter moves from the step to the job, and the condition itself is unchanged. Every caller triggers on issue_comment: [created], which fires on a comment on any issue in the repository, from any account. At step level that still started a runner and then did nothing. This is also where four repositories got it wrong in the other direction: they gated on github.event_name == 'pull_request' while triggering on events that are not pull_request, so the clause was never true and their CLA silently never asked anybody to sign. Narrowing pull_request_target here would reproduce that bug, which is why every PR event still runs.

actions becomes read rather than being removed. The upstream README asks for actions: write. I read the source: the only write it buys is pullRerunRunner.ts re-running a previously failed CLA run, and that API refuses a GITHUB_TOKEN, so the call already fails and core.error swallows it. Dropping the scope entirely is not safe, because the same file calls actions.listRepoWorkflows first and main.ts turns any throw into core.setFailed.

It now creates the ledger instead of assuming one, and the ledger is two things. The branch: the action writes through the contents API, and that API answers 404 for a branch: that does not exist rather than creating one. The file: the action does try to handle that itself, and the code is dead. setupClaCheck.ts guards its create path with error.status === "404", a string compared strictly against Octokit's numeric RequestError.status, so the branch never runs and a missing ledger falls through to Could not retrieve repository contents. Status: 404, which main.ts turns into a failed job. I read it out of dist/index.js at the pinned commit, which is what actually executes.

This is not hypothetical. dataprep_ml, mindsdb_evaluator and type_infer each have a cla branch and no ledger file, so all three would have hit it on their first external pull request: a red check with nothing to act on, in the three repos this change set exists to revive. Upstream is archived, so both halves are created here. It is idempotent, two API calls on every run after the first, and the seeded file is byte-identical to what the action would have written.

zizmor still reports one finding on this file, and it is real. contributor-assistant/github-action was archived upstream on 2026-03-23. Pinning the SHA is what makes it safe to sit on rather than urgent. Replacing the action is recorded as a follow-up, not smuggled in here.

Not done here, deliberately: a concurrency group. The action does a read-modify-write on a JSON ledger through the contents API, so two signatures landing in the same minute can conflict. That is a pre-existing correctness question, it is not what this change is about, and widening the diff to cover it would bury the security change.

Verified locally

Check What I observed
actionlint Clean across all 12 workflows, v1.7.12, the version ci.yml pins.
pytest tests/ -q 137 passed in 0.84s.
workflow_graph.py --allow-external-reusables "Checked 12 workflow(s): every local reusable call composes, and every event produces exactly one run tree."
zizmor@1.28.0, this file 3 findings on base, 2 on this branch. The one removed is unpinned-uses on the mutable tag. The one left is archived-uses, which is upstream being archived. zizmor is advisory in ci.yml and does not block.
Nothing else reads the removed input gh search code 'cla-assistant.yml org:mindsdb' returns anton, cowork, cowork-server, this file and the README. None of the three callers passes runs-on.
Ledger logic, dry-run Branch: an existing cla short-circuits, and a missing one resolves the right base, main for mindsdb/engine and staging for mindsdb/type_infer. File: dataprep_ml, mindsdb_evaluator and type_infer have the branch and not the file, so they take the create path; the other eleven skip it.
The seeded ledger matches the action's own printf output is byte-identical to JSON.stringify({signedContributors: []}, null, 3), 31 bytes, which is also the size of lightwood's existing ledger. So the first real signature is a one-line diff.
The dead 404 guard error.status === "404" in dist/index.js at ca4a40a…, against this.status = statusCode in the bundled @octokit/request-error, which is a number. Strict equality, so the create branch is unreachable.
The pinned SHA gh api /repos/contributor-assistant/github-action/git/ref/tags/v2.6.1 resolves to ca4a40a7d1004f18d9960b404b97e5f30a505a08.

Ships with

One behaviour across fifteen repositories, one pull request each. This is the
anchor. There is no Deploys: line and no pull request environment: every
sibling is a library repository with no deploy pipeline, so nothing here brings
an environment up.

Ten public repos replacing a hand-rolled job with this reusable:

Three that already called it, moving their agreement link to the same canonical
repository so nothing in the org depends on an alias, and narrowing actions to
the read this workflow now declares:

And one private repo carrying the eleventh copy of the same hand-rolled job. It
is outside the ticket's public-repo scope and fixed anyway, because leaving one
copy behind is how the next sweep finds eleven again:

  • mindsdb/jaison#2

Merge order: this one first, and that is a requirement rather than a
preference.
Every caller grants actions: read, and until this merges the
reusable still declares actions: write. A called workflow's permissions are
enforced when its file is loaded, so a callee naming a scope its caller has not
granted rejects the whole run as a startup_failure with zero jobs, and there
is no job left to report it. A caller merged first therefore has a red, silent,
dead CLA gate on every pull request and issue comment until this lands. The
reverse is safe: a caller granting more than the reusable declares is a legal
downgrade.

Refs: ENG-2017

…ENG-2017)

Ten public repos reached mdb-dev through a hand-rolled copy of this job. An
outside account opening a pull request or leaving a comment started every one
of those runs with no approval, because pull_request_target and issue_comment
both execute in base-repo context.

Delete the runs-on input so no caller can point the CLA check at a self-hosted
runner. Pin contributor-assistant/github-action to the commit behind v2.6.1.
Move the event filter from the step to the job, so an unrelated comment reports
as skipped instead of claiming a runner. Reduce actions to read: the only write
it buys is a re-run API that refuses a GITHUB_TOKEN, and dropping the scope
entirely fails the job on the workflow listing that precedes it.

Refs: ENG-2017
@lucas-koontz
lucas-koontz requested a review from a team as a code owner August 27, 2026 17:41
…sing (ENG-2017)

The action writes the ledger through the contents API, and that API answers 404
for a branch that does not exist rather than creating one. Nothing upstream
creates it, and upstream is archived, so five repos needed the branch made by
hand before their first signature could land. A repo adopting this workflow
would have hit the same wall, and the symptom is a red check on a contributor's
pull request with nothing they can do about it.

Refs: ENG-2017
The ledger needs a branch and a file, and the action supplies neither. The
branch this workflow already creates. The file it does not, and the action's
own handler for it is dead code: setupClaCheck.ts guards the create path with
`error.status === "404"`, a string compared strictly against Octokit's numeric
RequestError.status, so a missing ledger falls through to "Could not retrieve
repository contents. Status: 404" and main.ts fails the job. Read out of
dist/index.js at the pinned commit, which is what actually executes.

dataprep_ml, mindsdb_evaluator and type_infer each have a cla branch and no
ledger file, so all three would have hit this on their first external pull
request: a red check with nothing to act on, instead of the bot asking for a
signature. That is the case this change set exists to fix.

The seeded content is byte-identical to what the action would have written,
JSON.stringify(content, null, 3) at 31 bytes, so the first real signature
lands as a one-line diff rather than a reformat.

Also points the caller example and the README at mindsdb/mindshub rather than
the mindsdb/mindsdb redirect, matching the thirteen callers.
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