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
49 changes: 44 additions & 5 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Dependabot: surface vulnerable / outdated dependencies and CI actions as PRs (CI-1 / DEP-1).
# Until a committed lockfile lands, this is the primary signal for a known-CVE dependency.
# Dependabot's job here is REMEDIATION — it opens the PR that moves the pin. Detection does not rest
# on it: security.yml's pip-audit audits the committed locks directly (see the DEP-1 note below).
version: 2
updates:
# Python deps via the native "uv" ecosystem (was "pip"). The uv ecosystem resolves against
Expand All @@ -10,10 +11,13 @@ updates:
# .github/workflows/dependabot-lock-resync.yml re-exports those on the Dependabot branch so the
# gate stays green.
#
# UNVERIFIED, and it cannot be verified before the next weekly run: whether this ecosystem enumerates
# PEP 735 `[dependency-groups]` (the hash-pinned CI toolchain, ADR 0034 §3) at all. If it does not,
# those pins go stale silently — which is why security.yml's pip-audit step audits ci/locks/*.lock
# directly, turning "silently stale" into a red gate within ~24h via the daily cron.
# VERIFIED by the weekly run of 2026-07-30 (PR #66, Dependabot commit f066c2b8): this ecosystem DOES
# reach PEP 735 `[dependency-groups]` (the hash-pinned CI toolchain, ADR 0034 §3) — it bumped
# `zizmor==1.5.2` -> `1.28.0` inside `ci-scanners`, touching pyproject.toml + uv.lock only; that
# group's exported lock came from the resync workflow named above, exactly the split described. So
# at least `ci-scanners` does not go stale silently (`ci-quality` had no candidate in that run, so it
# is unobserved, not disproven), and only on the ROUTINE version track. security.yml's pip-audit
# still audits ci/locks/*.lock directly, for the reason recorded at that step.
- package-ecosystem: "uv"
directory: "/"
schedule:
Expand All @@ -29,6 +33,31 @@ updates:
# design), now backstopped by the published-GHSA gate in dependabot-auto-merge.yml (SEC-007 #2).
default-days: 5
semver-major-days: 7
# Dependabot WIDENS a declared cap instead of respecting it, so a load-bearing upper bound in
# pyproject.toml must be restated here as a version range or the cap is decorative. PR #66 rewrote
# `annotated-types<0.8` -> `<0.9` (BOTH declaration sites) and `ruff>=0.4,<0.16` -> `<0.17`; the
# resync then propagated 0.8.0/0.16.0 into constraints.lock, so ci.yml's `--constraint` pinned CI
# *to* the broken versions. `python-deps` groups on `*`, so those two red the WHOLE batch and hold
# every benign bump in it hostage. WHY each is capped lives beside the cap in pyproject.toml
# (annotated-types in [project.dependencies] and in the [fhir] extra; ruff in [dev]) — do not
# restate it here; lift a cap and delete its entry in the SAME PR. ONE entry covers BOTH
# annotated-types declarations: `ignore` filters a candidate VERSION by package name within this
# manifest, not by declaration site, so a second entry would describe nothing.
# TRADE-OFF, accepted: `ignore` suppresses the SECURITY track for the named RANGE as well as the
# routine one (`update-types` is the version-only knob and cannot express a range). A 0.7.x /
# 0.15.x advisory fix still flows. Detection is untouched — security.yml's REQUIRED
# `pip-audit -r requirements.lock` covers both on every PR and on the daily cron, so a withheld fix
# reds a merge-blocking gate within ~24h and a human lifts the cap. Note the ceiling is the
# pyproject CAP, not this entry: a human would have to lift it either way.
# NOT IGNORED, deliberately: the exact `==` pins in `[dependency-groups]`, and no blanket
# `versioning-strategy: lockfile-only`. Both would freeze the hash-pinned CI toolchain, which ADR
# 0034 §3 wants moving THROUGH this machinery — a pinned toolchain outside it rots into
# "pinned, stale, unpatched — worse posture than floating".
ignore:
- dependency-name: "annotated-types"
versions: [">=0.8.0"]
- dependency-name: "ruff"
versions: [">=0.16.0"]
groups:
# Version-update grouping (applies-to defaults to version-updates).
python-deps:
Expand All @@ -43,6 +72,16 @@ updates:
directory: "/"
schedule:
interval: "weekly"
# The uv/npm blocks' fresh-publish dodge, for the ecosystem where auto-merge actually fires: #75
# took two of its five bumps to main under 24h from upstream publish (codeql-action v4.37.4 was 7h
# old), and a compromised action runs INSIDE CI with whatever token the job holds. SHA-pinning
# stops tag mutation, not a malicious new release — Dependabot just bumps the pin to the new SHA.
# Only default-days is honored here (this ecosystem does not support the semver-*-days knobs) and
# it ages off the tag's commit date rather than the release date, so treat 5 as approximate.
# Security updates bypass cooldown, so an advisory fix is unaffected. Watch codeql-action — a
# frequent releaser — for stalls after this lands.
cooldown:
default-days: 5
groups:
# One weekly PR for all action bumps instead of one PR per action (CI cost: every extra PR
# buys a full required-check pass + a push-to-main run when it auto-merges). Mirrors the
Expand Down
2 changes: 1 addition & 1 deletion .github/required-contexts.txt
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ a PR that implements BACKLOG #N must update BACKLOG.md
# 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.
# zizmor.yml -- paths-filtered, 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.
# dast.yml -- nightly/tag/dispatch only, with no pull_request trigger, so it can
Expand Down
11 changes: 8 additions & 3 deletions .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,15 @@ jobs:
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
# --constraint constraints.lock for the reason spelled out in ci.yml's "Set up uv" step: a bare
# `-e ".[extras]"` re-resolves from pyproject's `>=` floors and adopts whatever upstream shipped
# since. A benchmark leg is the worst place for that — a silent dependency change reads as a
# performance regression. Every install in EVERY workflow is constrained except freethread-smoke,
# which is deliberately unconstrained (see the note at its install step).
- name: Install project (dev)
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
pip install --constraint constraints.lock -e ".[dev]"
- name: Environment stamp
run: |
mkdir -p out/load
Expand Down Expand Up @@ -96,7 +101,7 @@ jobs:
- name: Install project (dev + postgres)
run: |
python -m pip install --upgrade pip
pip install -e ".[dev,postgres]"
pip install --constraint constraints.lock -e ".[dev,postgres]"
- name: Environment stamp
run: |
mkdir -p out/load
Expand Down Expand Up @@ -180,7 +185,7 @@ jobs:
- name: Install project (dev + sqlserver)
run: |
python -m pip install --upgrade pip
pip install -e ".[dev,sqlserver]"
pip install --constraint constraints.lock -e ".[dev,sqlserver]"
- name: Environment stamp
run: |
mkdir -p out/load
Expand Down
9 changes: 5 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,11 @@ jobs:
#
# EVERY install below passes `--constraint constraints.lock` — the HASHLESS export of uv.lock, kept
# in sync by the DEP-1 gate. Without it, `uv pip install -e ".[extras]"` RE-RESOLVES from
# pyproject's `>=` floors and silently adopts whatever upstream published since: ruff 0.16.0 (626
# lint errors) and annotated-types 0.8.0 (dropped `SLOTS`, broke fhir-core) each reddened EVERY
# open PR on the same day. The constraint pins every version to the lock while each job still
# installs only ITS OWN extras (so the narrow image-smoke set stays narrow).
# pyproject's `>=` floors and silently adopts whatever upstream published since: ruff 0.16.0 and
# annotated-types 0.8.0 (dropped `SLOTS`, broke fhir-core) each reddened EVERY open PR on the same
# day — see each cap's rationale in pyproject.toml. The constraint pins every version to the lock
# while each job still installs only ITS OWN extras (so windows-service-smoke's deliberately
# narrow set stays narrow; the docker-smoke job builds from docker/locks/* and installs nothing).
- name: Set up uv
if: needs.changes.outputs.code == 'true' || github.event_name == 'push' || github.event_name == 'workflow_dispatch'
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
Expand Down
43 changes: 34 additions & 9 deletions .github/workflows/dependabot-auto-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ name: Dependabot auto-merge
# - MINOR updates of DEV-only dependencies
# OUT OF SCOPE (left for human review, surfaced same-day by the daily security cron + alerts):
# - MINOR/MAJOR updates of runtime deps, and ALL MAJOR updates
# NOTE the uv ecosystem is review-by-default in practice, not by exception: `python-deps` is
# `patterns: ["*"]`, so one weekly batch carries everything, and (a) a grouped update-type is the
# MAX across the batch (fetch-metadata's UPDATE_TYPES_PRIORITY) — one minor demotes every patch
# riding with it — while (b) the deny-list below names at least eight of this project's directly
# declared runtime deps and denies the WHOLE group on any hit. #66 was denied on `fastapi`.
# Auto-merge does fire for github-actions (#75, all-patch batch) and npm (#64).
#
# Fresh-release supply-chain poisoning is handled upstream by the dependabot.yml `cooldown`
# (routine updates age before a PR opens); SECURITY updates bypass cooldown by design, so a real
Expand Down Expand Up @@ -100,9 +106,8 @@ jobs:
- name: Verify a published advisory backs the security track
id: ghsa
env:
DEP_NAMES: ${{ steps.meta.outputs.dependency-names }}
DEP_GROUP: ${{ steps.meta.outputs.dependency-group }}
PREV_VERSION: ${{ steps.meta.outputs.previous-version }}
DEPS_JSON: ${{ steps.meta.outputs.updated-dependencies-json }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -u
Expand All @@ -124,27 +129,47 @@ jobs:
# Require at least one matching published (non-withdrawn) advisory for EVERY named dep at
# the previous version. Query the global advisories endpoint (ecosystem pip — Python
# advisories are pip-keyed). Fail closed on any error.
# fetch-metadata's scalar `previous-version` is ONE dependency's even on a grouped PR (#66:
# five deps, previous-version=0.7.0 — annotated-types'). Keying every name to it asks about
# versions the PR does not touch, and because most advisory ranges are open at the bottom, a
# low first-dep version makes almost every other name match: false confirmation in the
# PERMISSIVE direction (measured against the live endpoint at @0.7.0: aiohttp 30, pillow 29,
# urllib3 9, requests 6). `updated-dependencies-json` carries a per-dependency prevVersion.
pairs="$(printf '%s' "$DEPS_JSON" | jq -r '.[] | [.dependencyName, .prevVersion] | @tsv' 2>/dev/null || echo "ERR")"
if [ "$pairs" = "ERR" ] || [ -z "$pairs" ]; then
echo "::warning::no per-dependency metadata — failing closed (manual review)."
echo "advisory_ok=false" >> "$GITHUB_OUTPUT"
exit 0
fi
advisory_ok=true
IFS=',' read -ra names <<< "$DEP_NAMES"
for raw in "${names[@]}"; do
while IFS="$(printf '\t')" read -r raw prev; do
name="$(echo "$raw" | tr '[:upper:]' '[:lower:]' | xargs)"
[ -z "$name" ] && continue
if [ -z "$name" ]; then continue; fi
# A non-first dependency can carry an EMPTY prevVersion — update_metadata.ts:101 falls back
# to '' for index > 0 when the PR body yields no "from" version. Unknown previous version
# means the advisory question cannot be asked, so DENY; skipping it would wave the dep
# through on a gate whose whole contract is to fail closed.
if [ -z "$prev" ]; then
echo "::warning::no previous version for '$name' — failing closed (manual review)."
advisory_ok=false
break
fi
count="$(gh api -X GET /advisories \
-f ecosystem=pip \
-f affects="${name}@${PREV_VERSION}" \
-f affects="${name}@${prev}" \
--jq '[.[] | select(.withdrawn_at == null)] | length' 2>/dev/null || echo "ERR")"
if [ "$count" = "ERR" ] || [ -z "$count" ]; then
echo "::warning::advisory lookup failed for '$name' — failing closed (manual review)."
advisory_ok=false
break
fi
if [ "$count" -lt 1 ]; then
echo "::warning::no published advisory covers '${name}@${PREV_VERSION}' — failing closed (manual review)."
echo "::warning::no published advisory covers '${name}@${prev}' — failing closed (manual review)."
advisory_ok=false
break
fi
echo "::notice::published advisory confirmed for '${name}@${PREV_VERSION}'."
done
echo "::notice::published advisory confirmed for '${name}@${prev}'."
done <<< "$pairs"
echo "advisory_ok=$advisory_ok" >> "$GITHUB_OUTPUT"

- name: Enable auto-merge for in-scope updates
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/freethread-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@ jobs:
# free-threaded-relevant compiled surface: pydantic-core, cryptography, and the argon2-cffi + cffi
# chain. --prerelease handling: pip resolves the cp314t wheels; the cffi 2.0 chain may need a
# prerelease allowance depending on the index state at run time.
#
# DELIBERATELY UNCONSTRAINED — the ONE install in any workflow without `--constraint
# constraints.lock`. The lock records what resolved for the GIL build; pinning those versions here
# can leave a dependency with no cp314t wheel at that exact version and send pip to a source
# build, which either fails or quietly produces a differently-built extension. Either way the
# canary stops measuring free-threading and starts measuring the lock. It soft-fails
# (continue-on-error) precisely because this leg is allowed to be ahead of the pinned world.
- name: Install the core engine (dev extra; no compiled-heavy extras)
id: install
if: steps.setup.outcome == 'success'
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/selfhosted-win2025-sql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ jobs:
shell: powershell
run: |
python -m pip install --upgrade pip
python -m pip install -e ".[dev,sqlserver]"
python -m pip install --constraint constraints.lock -e ".[dev,sqlserver]"

- name: SQL Server store / coordinator / connector suites (against the real DB)
shell: powershell
Expand Down
18 changes: 14 additions & 4 deletions .github/workflows/zizmor.yml
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
name: zizmor

# GitHub Actions static analysis (Trail of Bits), split out of security.yml so it runs only when the
# surface it lints actually changes: zizmor reads .github/** (workflows + its config), so a Python /
# docs / test PR cannot change its result and running it there was pure cost (~1 billed min on every
# one of ~900 monthly PR/push scans). The daily cron re-runs it against the UNCHANGED tree so a
# newly-added zizmor rule or newly-flagged action ref still surfaces within ~24h, and
# surface it lints actually changes. Its result moves with at least two inputs — the .github/** tree
# it lints, and its own pinned version, which arrives from ci/locks/ci-scanners.lock (see the install
# step) — so both are in the paths filter, and a PR touching neither was pure cost (~1 billed min on
# every one of ~900 monthly PR/push scans). The daily cron re-runs it against the UNCHANGED tree so a
# newly-added zizmor rule, or a newly-flagged action ref from the ONLINE audits that no paths filter
# can see, still surfaces within ~24h, and
# workflow_dispatch covers on-demand runs. NOT a branch-protection required check (verified against
# the live protection rules), so a paths-filtered skip can never wedge a PR or auto-merge.
on:
pull_request:
paths:
- ".github/**"
# zizmor's own VERSION lives OUTSIDE .github/ — pyproject.toml's [dependency-groups].ci-scanners,
# hash-pinned through this export, which the install step below reads. A bump PR touching only
# those two never ran this gate (PR #66, zizmor 1.5.2 -> 1.28.0: 33 contexts, not one of them
# zizmor), so a jump against a deliberately CLEAN baseline was first adjudicated by the 06:00
# cron, against main, after merge. Unlike bandit and pip-audit — whose gates sit in security.yml
# on an unfiltered pull_request trigger and so DO re-run at the bumped version on the bump PR —
# this workflow is the only place the scanner it pins is exercised pre-merge.
- "ci/locks/ci-scanners.lock"
schedule:
- cron: "0 6 * * *"
workflow_dispatch:
Expand Down
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ repos:
# docs/CI.md carried this as an instruction ("run actionlint on every ci.yml edit") aimed at human
# memory. That is the wrong mechanism for a failure whose only symptom is silence, so it is a hook.
# The hook is the LOAD-BEARING half: the matching CI step lives in zizmor.yml, which is deliberately
# NOT a required check (it is paths-filtered to `.github/**`, so requiring it would wedge every PR
# NOT a required check (it is paths-filtered, so requiring it would wedge every PR
# that touches no workflow). Keep the `rev` in step with the version zizmor.yml installs.
#
# The upstream hook already scopes itself to `types: [yaml]` + `files: ^\.github/workflows/`, so no
Expand Down
Loading
Loading