Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ Implements: BACKLOG #
- [ ] **No real PHI or customer data** anywhere in the diff, tests, fixtures, screenshots, or commit
messages — synthetic HL7 only (`python -m messagefoundry generate`).
- [ ] Tests added/updated for new behavior.
- [ ] **Any new dependency was verified before adding it** — real, reputable, and the *exact* intended
distribution — then re-locked (`uv lock` / `uv export`), with a dated vet note beside it in
`pyproject.toml`. AI-suggested package names are frequently hallucinated or typosquatted.
CI (`scripts/security/new_dependency_check.py`, in the required `pip-audit` job) rejects a name
that does not exist, publishes nothing, was registered in the last 90 days, or is served under
another project's canonical name — but it **cannot** tell that a real package is the wrong one
(see the `[webauthn]` extra's note in `pyproject.toml`). That judgement is this checkbox.
- [ ] Gates pass locally: `ruff check .`, `ruff format --check .`, `mypy messagefoundry`, and
`pytest -q` (`QT_QPA_PLATFORM=offscreen` for console tests). `python -m messagefoundry check` is green.
- [ ] Uses **Connection / Router / Handler** vocabulary; no new declarative "channel" element; no
Expand Down
15 changes: 15 additions & 0 deletions .github/actionlint.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# actionlint configuration.
#
# actionlint validates `runs-on:` against GitHub's HOSTED runner labels, so a custom self-hosted label
# is reported as unknown. Declaring the label here is actionlint's own documented remedy and keeps the
# runner-label check LIVE for genuine typos ("windows-2O25", "ubunutu-latest") — the alternative,
# disabling the `runner-label` rule, would trade one real finding for the whole class.
self-hosted-runner:
labels:
# .github/workflows/selfhosted-win2025-sql.yml — SQL Server 2025 suites on real hardware.
# NOTE: the NucBox self-hosted runners are RETIRED (ci.yml runs every leg on hosted runners now),
# so this label may currently resolve to no registered runner. Confirm with
# `gh api repos/MEFORORG/MessageFoundry/actions/runners` before relying on that workflow; if the
# runner is gone for good, delete the workflow and this label together rather than leaving a
# declaration that outlives its subject.
- mefor-win2025-sql
73 changes: 73 additions & 0 deletions .github/required-contexts.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# The status-check contexts REQUIRED to merge into `main`.
#
# WHY THIS FILE EXISTS. Branch protection lives on the server, so "is this check blocking?" was
# unanswerable from a clone -- and five places in this repo answered it differently: docs/CI.md
# listed 8 contexts, .github/workflows/manifest-lint.yml said 4, docs/design/freethread.md said 7,
# tests/test_push_guard.py said 12. The live API says 12. With `required_approving_review_count: 0`
# and auto-merge armed, required-set membership is the ONLY thing separating "reviewed" from
# "merged unread", so a wrong answer here mis-ranks which gates a change can safely touch.
#
# This file is not the enforcement -- the server is. It is the CHECKED-IN CLAIM that every in-repo
# statement must agree with, which is what `tests/test_required_contexts.py` asserts. When branch
# protection changes, change this file in the same PR; the test will name every prose claim that
# then needs updating.
#
# FORMAT. One context string per line, `#` starts a comment, blank lines ignored. These are the
# exact strings branch protection matches -- the JOB NAME (`name:`), not the workflow name and not
# the workflow file. Two ways that bites:
# * The CLA context is `cla` (the job key, because cla.yml's job declares no `name:`), NOT
# "CLA Assistant" (the workflow name). docs/CI.md claimed the latter for months.
# * A job name containing " #" must be QUOTED in YAML or it truncates at the comment marker --
# see .github/workflows/backlog-hygiene.yml, whose name is deliberately quoted for this reason.
#
# ADDING ONE: see the required-but-absent trap in docs/CI.md. A required check that never reports
# blocks every PR forever, so confirm the job reports on a real PR (no `paths:` filter that can skip
# it, no `if:` gated on a non-PR event) BEFORE it lands here or in branch protection.

# --- ci.yml ---------------------------------------------------------------------------------------
CI gate
test (ubuntu-latest, py3.14)
test (windows-2022, py3.14)
test (windows-2025, py3.14)

# --- security.yml (every non-advisory job in that file; the advisory `sbom` and `trivy` jobs are
# --- deliberately absent -- they declare `continue-on-error: true`) -------------------------------
bandit (Python SAST)
pip-audit (dependency vulnerabilities)
npm-audit (ide dependency vulnerabilities)
gitleaks (secret scan)
semgrep (project SAST rules)
crypto-inventory (ASVS 11.1.3 discovery gate)
forbidden-content (customer/PHI leak guard)

# --- cla.yml --------------------------------------------------------------------------------------
cla

# PENDING PROMOTION -- owner's decision, NOT yet applied. This file mirrors the LIVE server, so the
# context below is deliberately absent until branch protection actually changes; adding it here first
# would make the file lie in the one direction it exists to prevent.
#
# "a PR that implements BACKLOG #N must update BACKLOG.md" (backlog-hygiene.yml)
#
# WHY PROMOTE: it hard-fails today and nothing waits for it, so it is decoration -- auto-merge only
# blocks on REQUIRED contexts. It exists because #60 shipped while its banner still read
# "PRE-RESERVED", and that stale banner was then repeated as fact in a merged PR. In a repo where the
# ledger is the only memory shared across isolated sessions, a stale banner makes another session
# rebuild work that is already merged.
# SAFE TO PROMOTE: it triggers on `pull_request: branches: [main]` with NO paths filter and no `if:`,
# so it always reports -- no required-but-absent trap. (Its STRUCTURAL half, "every item declares
# exactly one status", is already enforced inside the required test legs by
# tests/test_backlog_status_check.py; only the behavioural half is unguarded.)
# TO APPLY: add the context to branch protection, then move the line above into the ci/security list
# and update the count pinned in tests/test_required_contexts.py.

# DELIBERATELY NOT REQUIRED (do not add without the owner's decision; each has a recorded reason):
# codeql.yml, scorecard.yml -- their SARIF upload needs `security-events: write`, which fork-PR
# tokens lack, so requiring them would block every fork PR.
# zizmor.yml -- paths-filtered to `.github/**`, so it does not report on most PRs.
# quality-advisory.yml -- advisory BY DESIGN and must never be promoted; the invariant is
# pinned by tests/test_quality_advisory_invariants.py.
# freethread-smoke.yml -- a 3.14t canary; informational only.
# manifest-lint.yml -- paths-filtered to the k8s manifests.
# nightly / path-gated legs -- server-DB store, load/throughput, service-smoke, DICOM/FHIR
# breadth: they do not run on every PR (required-but-absent trap).
7 changes: 5 additions & 2 deletions .github/workflows/cla.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ name: CLA Assistant
# needed — the default GITHUB_TOKEN (with contents: write below) is sufficient.
#
# NOTE: pull_request_target workflows only run from the DEFAULT branch, so this takes effect
# once it is merged to main. To make signing mandatory, add the "CLA Assistant" status check
# to main's branch-protection required checks.
# once it is merged to main. Signing IS already mandatory: the required status-check context is
# `cla` -- the JOB key below, because that job declares no `name:`. It is NOT "CLA Assistant",
# which is this WORKFLOW's name and matches no status check; adding that string to branch
# protection would wedge every PR forever (docs/CI.md, "the required-but-absent trap").
# See .github/required-contexts.txt.

on:
issue_comment:
Expand Down
7 changes: 5 additions & 2 deletions .github/workflows/manifest-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@
# * It runs ONLY when a manifest, this workflow, or the docker/ docs change (paths filter), so docs/code
# PRs that don't touch manifests skip it entirely.
# * It is a SEPARATE workflow, deliberately not part of ci.yml's required `ci-gate`. The required set
# stays: the `test` matrix + bandit + pip-audit + cla (see docs/.. main required-checks). Do NOT add
# "manifest lint" to branch-protection required checks unless the owner decides to promote it.
# is recorded in .github/required-contexts.txt — read it there rather than trusting a copy in a
# comment; the copy that used to live on this line named 4 of the 12 and went stale. Do NOT add
# "manifest lint" to branch-protection required checks unless the owner decides to promote it: it is
# paths-filtered, so on a PR that touches no manifest it would never report and would block the PR
# forever (the required-but-absent trap, docs/CI.md).
# * It is fast + dependency-light (a single static binary + grep assertions), so it is cheap to run.

name: manifest lint
Expand Down
64 changes: 57 additions & 7 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ name: Security
# All four are BLOCKING — their first CI run was clean (bandit: 0; gitleaks: 0; semgrep: 0 across
# 5 rules; pip-audit: no known CVEs), so each now fails the build on a regression rather than
# merging unnoticed. To temporarily downgrade one, add `continue-on-error: true` back to its job.
#
# READ THAT LAST SENTENCE AS A TRAP, not a procedure. Every job here except `sbom` and `trivy` is a
# REQUIRED context, and GitHub reports a continue-on-error job as SUCCESS — so that one line leaves
# branch protection green while the scanner's findings are discarded, and with auto-merge armed and
# zero required approvals the PR merges unread. `tests/test_security_posture.py` now REFUSES it (and
# `|| true`, `--exit-zero`, and a skippable job-level `if:`) for any job in the required set. To take
# a gate off the merge path, remove its context from branch protection AND
# `.github/required-contexts.txt` — deliberately, in a reviewed diff. Never neuter it in place.
# CodeQL and native push-protection secret scanning need GitHub Advanced Security on this private
# repo; gitleaks below is the no-GHAS secret scanner. See docs/SECURITY.md.
#
Expand Down Expand Up @@ -52,7 +60,10 @@ jobs:
python-version: "3.14"
- name: Check the lockfile is in sync with pyproject (DEP-1)
run: |
python -m pip install --upgrade pip uv
# uv is PINNED: it is the resolver that produces every lockfile this job then audits, so an
# unpinned upgrade lets a new resolver silently change the exported set mid-PR and reds the
# `git diff --exit-code` below for a reason unrelated to the change. Bump deliberately.
python -m pip install --upgrade pip "uv==0.12.0"
# Fails if pyproject changed without re-running `uv lock` (drift guard).
uv lock --check
# Re-export to the same path and fail if it differs from what's committed (keeps the
Expand All @@ -78,9 +89,31 @@ jobs:
/tmp/lockcheck/bin/pip install --require-hashes -r requirements.lock
- name: Audit the locked dependencies (DEP-1)
run: |
pip install pip-audit
# PINNED. This job exists to prove nothing unpinned enters the tree, and it was installing
# its own auditor with a bare `pip install pip-audit` — dependency intake that none of the
# lockfiles it checks actually covers. `==` also makes the audit reproducible: an unpinned
# auditor can change its advisory database handling between two runs of the same commit.
python -m pip install --upgrade pip "pip-audit==2.10.1"
# Audit the committed, pinned set — reproducible (vs auditing a fresh latest-resolve).
pip-audit -r requirements.lock --desc
# ANTI-SLOPSQUAT. Deliberately a STEP in this already-REQUIRED job rather than a new context: it
# blocks today with no branch-protection change, the same reasoning as the ledger-gate backstop
# in ci.yml. (A hard-failing job that is NOT a required context does not stop auto-merge — it
# only looks like it does.)
#
# This answers the question pip-audit above structurally cannot. pip-audit asks "does this pinned
# version have a known CVE"; a freshly registered hallucinated name has no advisory, so it
# resolves, locks, hashes and installs through every DEP-1 control clean. This project's
# dependencies are chosen by an AI, and docs/Secure_AI_Development_Standards.md has carried the
# check as the "highest-priority deferred gate" while verify-before-add stayed "enforced only by
# the human remembering".
#
# Stdlib-only (no install). Fails CLOSED: if PyPI is unreachable it exits 2 rather than passing,
# and it exits 2 if it examined zero distributions, so a schema change cannot read as a clean
# sweep. Its known blind spot — a real package that is not the INTENDED one — is documented in
# the script and pinned by tests/test_new_dependency_check.py.
- name: Verify every declared dependency is a real, established distribution
run: python scripts/security/new_dependency_check.py

npm-audit:
name: npm-audit (ide dependency vulnerabilities)
Expand Down Expand Up @@ -305,13 +338,25 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0 # full history so the scan also covers earlier commits, not just the tip
- name: Install gitleaks (pinned)
- name: Install gitleaks (pinned + checksum-verified)
run: |
# Pin the release; bump deliberately. Verify the tag exists if this step 404s.
#
# VERIFIED, not just version-pinned. This was `curl ... | tar -xz` straight into a pipe: a
# tag pin says WHICH artifact to fetch, not that the bytes received are that artifact, and
# this runs inside a REQUIRED gate. The sbomqs step in this same file already had the answer
# (download the release's own checksums file and sha256sum -c it), so this was unfinished
# scope rather than an accepted risk. Mirroring that shape exactly.
VER=8.18.4
curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${VER}/gitleaks_${VER}_linux_x64.tar.gz" \
| tar -xz -C /tmp gitleaks
sudo install -m 0755 /tmp/gitleaks /usr/local/bin/gitleaks
asset="gitleaks_${VER}_linux_x64.tar.gz"
base="https://github.com/gitleaks/gitleaks/releases/download/v${VER}"
curl -sSfL "${base}/${asset}" -o "$asset"
curl -sSfL "${base}/gitleaks_${VER}_checksums.txt" -o gitleaks_checksums.txt
# Keep the asset's canonical name: sha256sum -c verifies BY the filename in the line, so a
# renamed download would silently verify nothing.
grep " ${asset}$" gitleaks_checksums.txt | sha256sum -c -
tar -xzf "$asset" gitleaks
sudo install -m 0755 gitleaks /usr/local/bin/gitleaks
gitleaks version
- name: Scan repository for secrets
run: gitleaks detect --config .gitleaks.toml --redact --verbose --no-banner
Expand All @@ -327,7 +372,12 @@ jobs:
python-version: "3.14"
- name: Run the MessageFoundry rules
run: |
python -m pip install --upgrade pip "semgrep~=1.90"
# PINNED to an exact version, not `~=1.90`. A compatible-release range silently adopts every
# new 1.x, and a new semgrep release can add rules or change taint propagation — which reds a
# green PR for a reason that has nothing to do with its diff. Same posture as bandit==1.9.4
# above and zizmor==1.5.2: bump deliberately, in a PR that also clears any new findings.
# (1.172.0 is what `~=1.90` resolved to on 2026-07-29, so this pin is a no-op today.)
python -m pip install --upgrade pip "semgrep==1.172.0"
# --error: any finding fails the step — and the job, now that this gate is blocking.
# Includes `tee/` (in-tree vendored SOUP) so the standalone relay is held to the same
# dangerous-sink rules as the engine.
Expand Down
45 changes: 45 additions & 0 deletions .github/workflows/zizmor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,51 @@ jobs:
run: |
# Pin deliberately; verify the tag at https://github.com/zizmorcore/zizmor/releases if it 404s.
python -m pip install --upgrade pip "zizmor==1.5.2"
# actionlint FIRST: it catches the class zizmor structurally cannot. An invalid `${{ }}` anywhere
# in a `run:` body -- comments included -- aborts workflow COMPILATION, so no jobs are created and
# every required context silently never appears (the PR looks stuck, not red). zizmor parses the
# file it is given and has nothing to say about a file GitHub will refuse to compile.
#
# Keep the version in step with .pre-commit-config.yaml's actionlint `rev`. This step is the
# backstop for `git commit --no-verify`; the pre-commit hook is the load-bearing half, because
# this workflow is deliberately NOT a required check (see the header).
- name: Lint workflow syntax (actionlint, pinned)
run: |
# Pin the release; bump deliberately. Verify the tag exists at
# https://github.com/rhysd/actionlint/releases if this step 404s. Verified against the
# release's own checksum file -- the same posture as the sbomqs download in security.yml,
# because a curl|tar of a release asset is dependency intake that no lockfile covers.
VER=1.7.12
asset="actionlint_${VER}_linux_amd64.tar.gz"
base="https://github.com/rhysd/actionlint/releases/download/v${VER}"
curl -sSfL "${base}/${asset}" -o "$asset"
curl -sSfL "${base}/actionlint_${VER}_checksums.txt" -o actionlint_checksums.txt
grep " ${asset}$" actionlint_checksums.txt | sha256sum -c -
tar -xzf "$asset" actionlint
sudo install -m 0755 actionlint /usr/local/bin/actionlint
actionlint --version
# `-shellcheck=` DISABLES actionlint's shellcheck integration, deliberately.
#
# actionlint shells out to shellcheck for every `run:` body IF shellcheck is on PATH. GitHub
# runners ship it; a local actionlint binary generally does not. So the same command is a
# different linter in the two places -- this step passed locally and failed in CI on ~30
# PRE-EXISTING findings in ci.yml and release.yml that have nothing to do with the change that
# added it. That asymmetry is the exact defect class this repo keeps hitting: a gate whose
# reach depends on its environment.
#
# Scope is the reason, not the noise. This step exists for the workflow-syntax class in
# docs/CI.md -- an invalid GitHub expression interpolation that aborts workflow COMPILATION so
# required contexts never appear. Adopting shellcheck's whole ruleset over every `run:` body is
# a separate decision, and several of its findings here are it misparsing a GitHub
# interpolation inside a shell script (release.yml:392 "'(' is invalid here"). Turning it on
# repo-wide belongs in its own PR that also clears or annotates the backlog.
#
# NB -- do NOT write a literal empty interpolation (dollar-brace-brace) in this comment to
# illustrate the point: actionlint parses expressions in `run:` bodies INCLUDING comments, so
# the illustration is itself the defect. Writing one here is how this very step first went red.
#
# Now identical locally and in CI, which is what makes a green here mean something.
actionlint -shellcheck=
- name: Analyze the workflows
env:
# Lets zizmor run its online audits (e.g. unpinned / known-problematic action refs).
Expand Down
Loading
Loading