0014. Continuous integration and supply-chain security¶
Status: accepted
Date: 2026-11-07
Context¶
Housecairn is public from its first commit and is meant to be installed by people who will not read its code. Every pull request, from a contributor, a bot or the AI agent that develops it, must pass the same automatic checks before the maintainer reads it, and the build chain itself must not be the weak point: a workflow that runs untrusted code with a write token, an action moved to another commit, or a dependency release compromised a day ago.
Decision¶
Workflows¶
All workflows are in .github/workflows/. Their job names are the status
checks of the pull requests; they are stable and only renamed together with
the main ruleset.
Workflow |
Trigger |
What it checks |
|---|---|---|
|
pull request, push to |
|
|
pull request, push to |
Static analysis of the workflows with zizmor. One job fails on any finding of medium severity or more; another uploads all findings (SARIF) to code scanning. |
|
pull request |
New dependencies: fails on a known vulnerability of moderate severity or more, or on a license outside the allowed list below. |
|
pull request opened, edited, synchronized, reopened |
The title is a Conventional Commit ( |
|
pull request |
Every non-merge commit has a |
|
pull request target, issue comment |
Every commit author and the pull request author have signed |
|
push to |
OpenSSF Scorecard; results published to the Scorecard API (README badge) and to code scanning. |
CodeQL runs through GitHub’s default setup, enabled in the repository settings. There is no CodeQL workflow in the repository: an advanced configuration would conflict with the default setup.
The repository scripts in scripts/ use the standard library only, so the
title and sign-off checks need neither uv nor the dependencies. They have their
own tests in tests/test_ci_scripts.py.
Rules every workflow follows¶
Every action is pinned to a full commit SHA, with its tag in a comment; container images are pinned by digest. The repository setting that requires SHA pinning enforces it.
The workflow-level token has
contents: reador no permission at all. Write permissions are granted per job, each with a comment saying why.Every
actions/checkoutsetspersist-credentials: false.Values from the event (titles, commit SHAs) reach a
run:step throughenv:, never through${{ }}inside the script.concurrencycancels outdated runs of the same pull request (except the CLA workflow, which does not cancel a running check; what protects its signatures from concurrent writes is described under “Record” below).No
pull_request_targetworkflow checks out or runs the code of a pull request. The onlypull_request_targetworkflow iscla.yml, see below.
Exceptions¶
cla.ymlusespull_request_targetandissue_comment. The CLA check must comment on pull requests from forks, set a commit status and record the signature, which the read-only token of apull_requestrun cannot do. Its only checkout is the default branch (.github/scripts/cla.jsandCLA.md,persist-credentials: false); it never checks out, builds or runs the code of the pull request, and reads the pull request through the API only (the authors of its commits, the signature comment). No event value is interpolated in its script:cla.jsreadscontext. zizmor’sdangerous-triggersfinding is ignored by inline comments in this file only; it is the only zizmor exception in the repository.Merge commits are not checked for a sign-off. They only bring in commits that are checked on their own; the sign-off check would otherwise fail on the merge commit created by GitHub’s “Update branch” button. Since ADR 0040,
mainis merged through the merge queue and branches no longer need updating; such merge commits can still appear in a pull request.Bot commits are not checked for a sign-off in their own pull requests. Dependabot signs off as
dependabot[bot] <support@github.com>, not with its author address, so its pull requests could never pass. Commits authored bydependabot[bot]orgithub-actions[bot](their noreply addresses) are skipped only when that same bot opened the pull request: an author address is free text, and a commit claiming a bot address in anyone else’s pull request is still checked.
Third-party actions¶
The organization only allows actions published by GitHub and an explicit list of other actions. Each one below is on that list for a reason:
Action |
Pinned version |
Why |
|---|---|---|
|
v10.3.3 |
Installs uv, the tool that locks and installs the dependencies (ADR 0001), with its cache. Published by uv’s authors. |
|
v0.8.1 |
Runs zizmor, the workflow security analyser, and uploads its SARIF. Published by zizmor’s authors. |
|
v2.5.0 |
The only way to publish OpenSSF Scorecard results for the badge. Published by the OpenSSF. |
Actions published by GitHub: actions/checkout v7.0.1,
actions/dependency-review-action v5.0.0, github/codeql-action/upload-sarif
v4.39.3, actions/github-script v9.0.0.
CLA check¶
The CLA check is a script of this repository, .github/scripts/cla.js, run by
actions/github-script in cla.yml. It replaces the CLA Assistant Lite
action, first chosen for this lot: its
repository is archived upstream (read-only, no more fixes, node20 runtime),
and a third-party action given a write token on pull_request_target is the
riskiest place to depend on unmaintained code. The script is about 300 lines,
uses only GitHub’s own action and Node’s built-in modules, and is tested
without network by .github/scripts/cla.test.js (node --test, run in the
Django and repository checks job).
Who must sign: the GitHub account of the author of every commit of the pull request, and the account that opened it. The opener is added because a commit author address is free text: commits that only claim the address of an exempt account would otherwise need no signature at all. A commit whose author address is not linked to any GitHub account fails the check, with a comment that explains how to fix it. The commit list comes from the pull requests API, which returns at most 250 commits.
Exempt:
Robvred(the maintainer),dependabot[bot],github-actions[bot].Signing: a comment on the pull request whose whole body, without leading and trailing white space, is exactly:
I have read the CLA Document and I hereby sign the CLA
The comment signs for its own author only, never for anyone else. Edited comments are not read (
issue_commentcreatedonly). Commentingrecheckruns the check again.Record:
signatures/v1/cla.jsonon thecla-signaturesbranch, never onmain, one entry per signer: login, numeric account id (signatures are matched by id, which survives a renaming), pull request number, comment id and date, and the SHA-256 of theCLA.mdsigned (from the default branch). Concurrent signatures. The only protection against two runs writing at once is optimistic concurrency on the file: each run reads it, adds its entry, and writes it through the contents API with the blob SHA it read. If another run wrote in between, GitHub refuses the write (409, or 422 if the file appeared meanwhile); the run reads the new version, which keeps the other signature, and tries again, up to five times, then fails and sets theCLAstatus tofailure. Two pull requests signed at the same time are therefore both recorded, unless one run is refused five times in a row.concurrencydoes not serialize them: its group is per pull request. Within one pull request it lets one run work and one wait (cancel-in-progress: false), but a third event replaces the waiting run, which is cancelled: if that run was a signature, it is not recorded (each run records only the comment that triggered it, andrecheckrecords nothing), and the signer has to post the phrase again. The branch must exist and stay outside themainruleset; the workflow does not create it and fails with an explicit message if it is missing. A new CLA text that must be signed again goes to a newsignatures/v2/file.Result: a commit status named
CLAon the head commit of the pull request (successorfailure) and a single bot comment, updated rather than repeated, that lists the missing signers and the phrase to copy. The job itself is namedCLA signaturesso that it is not confused with the status; it fails only when the check cannot run (missing branch, API error), after setting theCLAstatus tofailure.Permissions of the job:
contents: write(signatures file),pull-requests: write(read the pull request and its commits, write the comment),statuses: write(theCLAstatus). No other secret than the workflow token.Not kept from CLA Assistant Lite: locking the conversation after a merge. Signatures do not depend on the comment staying in place: the record holds the comment id and date and the account id.
Licenses allowed for dependencies¶
Housecairn is distributed under AGPL-3.0-only. A dependency may be combined with it if its license is permissive or a compatible copyleft:
permissive:
0BSD,Apache-2.0,BlueOak-1.0.0,BSD-2-Clause,BSD-3-Clause,CC0-1.0,ISC,MIT,MIT-0,MIT-CMU(Pillow, accepted by ADR 0030),PSF-2.0,Python-2.0,Unlicense,Zlib;weak copyleft:
LGPL-2.1-or-later,LGPL-3.0-only,LGPL-3.0-or-later,MPL-2.0(its section 3.3 allows combining with the GNU licenses);strong copyleft compatible with AGPL-3.0:
GPL-3.0-only,GPL-3.0-or-later,AGPL-3.0-only,AGPL-3.0-or-later.
An allow list, rather than a deny list, makes every other license, including
GPL-2.0-only (incompatible with the GPL-3.0 family), proprietary licenses and
licenses the dependency graph cannot identify, a decision recorded in an ADR
before the dependency is accepted. Every package locked when this decision was
taken has a license in this list.
Dependency updates¶
Dependabot (.github/dependabot.yml) checks weekly the uv, github-actions
and docker ecosystems. Minor and patch updates are grouped per ecosystem;
commits are prefixed chore(deps). A cooldown of 7 days (cooldown: default-days: 7, supported for these three ecosystems by GitHub’s Dependabot
options reference) delays version updates until a release has been public for
a week, which leaves time for a compromised release to be noticed and pulled.
Security updates are not delayed. The docker entry prepares lot J1-D and has
nothing to update until a Dockerfile exists. The project stays on the Django 5.2
LTS line: the uv entry ignores version-update:semver-major for django,
django-stubs and django-stubs-ext until the migration to the next LTS. The
PostgreSQL service image of ci.yml is not seen by Dependabot: its tag and
digest are updated by hand, together.
Checks required on main¶
These exact names are to be set as required status checks in the main
ruleset, once each has passed once on GitHub (a check that has never run blocks
every merge):
Lint and typesTests (SQLite)Tests (PostgreSQL)Django and repository checkszizmorDependency reviewConventional Commits titleSigned-off-byCLA(a commit status set bycla.js, not a job; add it last, oncecla.ymlis onmainand thecla-signaturesbranch exists)
Each of their workflows also runs on the merge queue, where the same names are required on every queue entry (ADR 0040).
Not required: CLA signatures (the job that sets the CLA status; it runs on
the default branch, not on the pull request head), zizmor (code scanning)
(skipped for pull requests from forks and for runs triggered by Dependabot,
whose token is read-only),
Scorecard analysis (runs on main only). CodeQL findings are enforced, if
wanted, with the ruleset’s code scanning merge protection rather than a status
check.
Consequences¶
A pull request cannot be merged until its code, its workflows, its new dependencies, its title, its sign-offs and its author’s CLA pass.
A new third-party action needs an update of this record and of the organization’s allowed list before its workflow can run.
cla.ymltakes effect once it is onmain:pull_request_targetworkflows always run in the version of the base branch.The PostgreSQL tests need the
postgresextra (psycopg, ADR 0001).