Skip to content

build: bump python from cea0e60 to a7fb1e6 #1382

build: bump python from cea0e60 to a7fb1e6

build: bump python from cea0e60 to a7fb1e6 #1382

Workflow file for this run

name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# Uses the composite action at .github/actions/test-suite/action.yml for
# the step-level rationale, including why the shared unit is a composite
# action and not a reusable workflow: a job delegating via `uses:` reports
# its check as "<caller job name> / <called job name>", which would rename
# all four required contexts on `main`. Sharing at the step level leaves
# this job's name — and therefore its status-check context — untouched.
# Originally shared with release.yml's own `test` job (issue #336); issue
# #392 removed that second caller (see the action's header comment) —
# `requirements/ci-postgresql.txt` and tree-sitter grammars are now
# installed unconditionally inside the action rather than passed in.
test:
name: Test (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
# A local `uses: ./...` is resolved from the checked-out tree, so the
# checkout cannot itself live inside the shared action.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: ./.github/actions/test-suite
with:
python-version: ${{ matrix.python-version }}
# Coverage, the MCP host contract check, and the advertised-test-count
# check are expensive and gain nothing from running on all four legs —
# ci.yml has always scoped them to Python 3.12 only; the shared action
# makes that scoping an explicit input instead of an inline `if:` per
# step.
run-extended-checks: ${{ matrix.python-version == '3.12' }}
test-sqlite:
name: Test (SQLite backend)
runs-on: ubuntu-latest
# Force the SQLite fallback path — no PostgreSQL installed or started.
# The conftest detects PG-unreachable and sets CORTEX_MEMORY_STORE_BACKEND=sqlite
# automatically; this env var makes the selection explicit and observable in logs.
# source: mcp_server/infrastructure/memory_store.py _construct_store() — the
# 'sqlite' backend branch is always reachable without PG; conftest.py line 99
# mirrors this override when _USE_PG is False.
env:
CORTEX_MEMORY_STORE_BACKEND: sqlite
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Cache pip
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-3.12-sqlite-${{ hashFiles('pyproject.toml') }}
restore-keys: |
${{ runner.os }}-pip-3.12-sqlite-
- name: Cache HuggingFace models
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-hf-all-MiniLM-L6-v2
# The `codebase` extra is included even though this job is about the
# storage backend: without tree-sitter/leidenalg, 9 tests SKIP here that
# the PostgreSQL job runs, so the two gates would not be equal and a
# codebase-analysis regression could reach the default backend unseen.
# Measured 2026-07-28 locally: 8 skips from tree-sitter, 1 from leidenalg.
- name: Install dependencies (no postgresql extra)
# Hash-pinned from uv.lock (scripts/generate_pip_constraints.py).
# --no-deps on the project install because the file above IS the
# complete dependency graph; re-resolving here would be unpinned.
run: |
pip install --require-hashes -r requirements/ci-sqlite.txt
pip install --no-deps -e .
# Retry-with-backoff, fail-loudly: see the `test` job's pre-download step
# for the root-cause rationale (CI run 28495801728, 2026-07-01).
- name: Pre-download embedding model
run: |
for attempt in 1 2 3 4 5; do
python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('all-MiniLM-L6-v2', device='cpu')" && exit 0
echo "HF pre-download attempt ${attempt} failed; retrying in $((attempt * 10))s" >&2
sleep $((attempt * 10))
done
echo "HF pre-download failed after 5 attempts" >&2
exit 1
# Same rationale as the `test` job's identically-named steps (CI run
# 30592244731, 2026-07-31): this job also installs the `codebase` extra
# (comment above), so it is equally exposed to a cold-cache mid-suite
# `DownloadError` without this.
- name: Resolve tree-sitter cache directory
run: echo "TREE_SITTER_CACHE_DIR=$(python -c 'from tree_sitter_language_pack import cache_dir; print(cache_dir())')" >> "$GITHUB_ENV"
- name: Cache tree-sitter grammars
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ env.TREE_SITTER_CACHE_DIR }}
key: ${{ runner.os }}-tree-sitter-grammars-${{ hashFiles('pyproject.toml') }}
- name: Prefetch tree-sitter grammars
run: |
for attempt in 1 2 3 4 5; do
python -c "from mcp_server.core.ast_parser import AST_SUPPORTED; from tree_sitter_language_pack import prefetch; prefetch(sorted(AST_SUPPORTED))" && exit 0
echo "tree-sitter grammar prefetch attempt ${attempt} failed; retrying in $((attempt * 10))s" >&2
sleep $((attempt * 10))
done
echo "tree-sitter grammar prefetch failed after 5 attempts" >&2
exit 1
- name: Run SQLite backend tests
env:
HF_HUB_OFFLINE: "1"
TRANSFORMERS_OFFLINE: "1"
# SQLite is the plugin's DEFAULT backend, so it gets the same gate
# PostgreSQL does. Explicit rather than inferred: this job has no PG
# service container, and conftest would select SQLite on its own, but
# a future runner with PG reachable must not silently turn this into
# a second PostgreSQL run.
CORTEX_MEMORY_STORE_BACKEND: sqlite
# No model download from a CI runner; degrade to first-stage scores.
CORTEX_RERANKER_OFFLINE: "1"
# Scope: THE FULL SUITE. This job previously ran one file
# (test_sqlite_backend.py) and deferred the rest to a "full-parity
# effort" that named no issue. That gap let three real defects ship on
# the default backend — missing acquire_interactive/acquire_batch, the
# PG-only lesson-promotion query, and a bare except that reported the
# resulting failure as an empty backlog (issue #220).
#
# Measured 2026-07-28 on this tree (rebased onto 575f2f1, so it
# includes the tests #229/#230 added), macOS 15 / Python 3.13,
# `CORTEX_MEMORY_STORE_BACKEND=sqlite`: 6103 passed, 96 skipped,
# exit 0 in 247s.
#
# Those 96 skips, counted (not estimated) from a `-rs` run:
# 84 PostgreSQL-only — the test's SUBJECT is the PG implementation
# (pg_store_* dialect modules, PG-only maintenance passes). These
# SHOULD skip here; making them "backend-agnostic" would mean
# testing PG code without PG.
# 12 optional deps absent from that local env (8 tree-sitter,
# 1 leidenalg, 3 sqlite-vec). All three ARE installed on this
# job, so it should report ~84 skips, not 96 — if it reports
# more, an extra is missing and the gate has silently narrowed.
run: pytest --tb=short -q -p no:randomly
# Protocol-level, hook-free proof for representative local client
# identities. This starts the installed production console entry point,
# under a SOCKS-proxy environment with no socks extra, then requires a
# full initialize/discovery round-trip for both full and lean profiles,
# plus a real SQLite memory_stats call. It is intentionally on the
# SQLite job: that is the zero-config backend used by Gemini/Codex
# installs, and all Python dependencies are already present here.
- name: Verify hook-free MCP host contract
env:
HF_HUB_OFFLINE: "1"
TRANSFORMERS_OFFLINE: "1"
CORTEX_RERANKER_OFFLINE: "1"
run: python scripts/verify_mcp_hosts.py -- hypermnesia-mcp
mcp-host-config:
name: Validate MCP host configurations
# The pinned Claude package requires its postinstall to select the native
# validator. Never execute install scripts from a lockfile modified by an
# untrusted fork; same-repository PRs and main/workflow_dispatch remain
# covered by this vendor-parser contract.
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
# @anthropic-ai/claude-code 2.1.220 declares Node >=22; Node 24 is
# the repository's existing release-toolchain pin.
node-version: "24"
- name: Install uv (pinned)
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
version: "0.11.3"
# Vendor parsers catch configuration drift that a generic MCP client
# cannot: Claude's custom plugin component path, Gemini's extension
# schema, and Codex's config.toml transport/timeouts. Versions and bytes
# are pinned in package-lock.json. This isolated, contents-read-only job
# intentionally permits install scripts: Claude Code's package uses its
# postinstall to replace a non-executable error stub with the pinned
# platform-native validator binary. `--ignore-scripts` was verified to
# leave `claude plugin validate` unusable.
- name: Install pinned MCP host CLIs
run: npm ci --prefix tests_js/mcp-host-clis
- name: Validate Claude, Gemini, and Codex configuration parsing
run: |
set -euo pipefail
cli_bin="tests_js/mcp-host-clis/node_modules/.bin"
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \
"$cli_bin/claude" plugin validate .
GEMINI_CLI_HOME="$RUNNER_TEMP/cortex-gemini-home" \
"$cli_bin/gemini" extensions validate .
"$cli_bin/codex" mcp list --json \
-c 'mcp_servers={cortex={command="hypermnesia-mcp",startup_timeout_sec=30,tool_timeout_sec=600,env={CORTEX_MEMORY_STORE_BACKEND="sqlite"}}}' \
| python -c 'import json,sys; servers={s["name"]:s for s in json.load(sys.stdin)}; c=servers["cortex"]; assert c["transport"]["type"]=="stdio"; assert c["transport"]["command"]=="hypermnesia-mcp"; assert c["transport"]["env"]["CORTEX_MEMORY_STORE_BACKEND"]=="sqlite"; assert c["startup_timeout_sec"]==30.0; assert c["tool_timeout_sec"]==600.0'
marketplace_ref="${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}"
"$cli_bin/codex" plugin marketplace add cdeust/Cortex \
--ref "$marketplace_ref" --json
"$cli_bin/codex" plugin list --available --json \
| python -c 'import json,sys; d=json.load(sys.stdin); expected=json.load(open("package.json"))["version"]; ps=d.get("available",[])+d.get("installed",[]); p=[x for x in ps if x.get("pluginId")=="hypermnesia-mcp-codex@cortex-codex-plugins"]; assert len(p)==1, p; assert p[0]["version"]==expected'
"$cli_bin/codex" plugin add hypermnesia-mcp-codex@cortex-codex-plugins --json
"$cli_bin/codex" plugin list --json \
| python -c 'import json,sys; d=json.load(sys.stdin); p=[x for x in d.get("installed",[]) if x.get("pluginId")=="hypermnesia-mcp-codex@cortex-codex-plugins"]; assert len(p)==1, p; assert p[0]["installed"] and p[0]["enabled"]'
"$cli_bin/codex" mcp list --json \
| python -c 'import json,sys; ss=[s for s in json.load(sys.stdin) if s.get("name")=="cortex"]; assert len(ss)==1, ss; s=ss[0]; assert s["startup_timeout_sec"]==180.0, s; assert s["transport"]["command"]=="uvx", s; assert s["transport"]["env"]["CORTEX_RUNTIME"]=="cowork", s'
mapfile -t plugin_command < <(
python -c 'import json; s=json.load(open("plugins/hypermnesia-mcp-codex/.mcp.json"))["mcpServers"]["cortex"]; print(s["command"]); print(*s["args"], sep="\n")'
)
plugin_timeout="$(
python -c 'import json; print(json.load(open("plugins/hypermnesia-mcp-codex/.mcp.json"))["mcpServers"]["cortex"]["startup_timeout_sec"])'
)"
plugin_runtime="$(
python -c 'import json; print(json.load(open("plugins/hypermnesia-mcp-codex/.mcp.json"))["mcpServers"]["cortex"]["env"]["CORTEX_RUNTIME"])'
)"
CORTEX_RUNTIME="$plugin_runtime" \
UV_CACHE_DIR="$RUNNER_TEMP/cortex-codex-cold-uv-cache" \
UV_TOOL_DIR="$RUNNER_TEMP/cortex-codex-cold-uv-tools" \
PYTHONPATH="$GITHUB_WORKSPACE" \
python scripts/verify_mcp_hosts.py \
--timeout "$plugin_timeout" --clients codex-cli --profiles lean \
--command-includes-profile \
--allow-bootstrap-network \
--storage-selection auto \
-- "${plugin_command[@]}"
test-windows:
name: Test (Windows, SQLite backend)
runs-on: windows-latest
# Real NT proof for the cross-platform fixes (fcntl→msvcrt, sys.executable,
# NTFS exec bits, $HOME override, path separators). We use the SQLite
# fallback so no PostgreSQL has to be provisioned on the Windows runner —
# the conftest selects it when PG is unreachable; the env var makes it
# explicit. source: RAPPORT_INSTALLATION_CORTEX_WINDOWS.md §10 (CI Windows)
env:
CORTEX_MEMORY_STORE_BACKEND: sqlite
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Cache HuggingFace models
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-hf-all-MiniLM-L6-v2
- name: Install dependencies (no postgresql extra)
# Hash-pinned from uv.lock (scripts/generate_pip_constraints.py).
# --no-deps on the project install because the file above IS the
# complete dependency graph; re-resolving here would be unpinned.
run: |
pip install --require-hashes -r requirements/ci-sqlite-min.txt
pip install --no-deps -e .
# Import smoke: the modules that previously crashed at load on Windows
# (fcntl import) or silently misbehaved. If any fails to import, the
# platform branches are wrong — fail fast before the suite.
- name: Import smoke (formerly Windows-broken modules)
run: >-
python -c "import mcp_server.shared.platform,
mcp_server.infrastructure.pipeline_install_lock,
mcp_server.infrastructure.pipeline_discovery,
mcp_server.core.staleness, mcp_server.doctor, mcp_server.doctor_mcp;
print('windows import smoke OK')"
# Retry-with-backoff, fail-loudly: see the `test` job's pre-download step
# for the root-cause rationale (CI run 28495801728, 2026-07-01). shell: bash
# so the retry loop runs under Git Bash rather than the Windows default pwsh.
- name: Pre-download embedding model
shell: bash
run: |
for attempt in 1 2 3 4 5; do
python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('all-MiniLM-L6-v2', device='cpu')" && exit 0
echo "HF pre-download attempt ${attempt} failed; retrying in $((attempt * 10))s" >&2
sleep $((attempt * 10))
done
echo "HF pre-download failed after 5 attempts" >&2
exit 1
# Real postInstall proof (issue #113). Before this step, no CI job ever
# ran plugin.json's actual postInstall command
# (`bash scripts/install-plugin.sh`) on any OS — the ubuntu `test` job
# above installs deps directly via `pip install -e`, bypassing it
# entirely. That gap is why scripts/setup.sh's "Unsupported OS" failure
# on native Windows (Git Bash reports uname -s as MINGW64_NT-*/
# MSYS_NT-*/CYGWIN_NT-*, matched by neither its Darwin nor Linux branch)
# went undetected. The job-level CORTEX_MEMORY_STORE_BACKEND: sqlite
# (above) makes install-plugin.sh/scripts/setup.py take the SQLite
# path — now the plugin's production DEFAULT, not a CI-only mode —
# so this runs without provisioning a PostgreSQL server on the
# runner, while still exercising the real backend selection +
# marker write in install-plugin.sh and the real scripts/setup.py
# subprocess calls. The embedding model is deliberately NOT cached
# by this path anymore (lazy first-use download); the HF
# pre-download step above provides the model for the test steps.
- name: Exercise real postInstall path (install-plugin.sh -> setup.py)
shell: bash
env:
CLAUDE_PLUGIN_ROOT: ${{ github.workspace }}
CLAUDE_PLUGIN_DATA: ${{ runner.temp }}/cortex-plugin-data
run: bash scripts/install-plugin.sh
# Scope (explicit, not silent): the portability tests plus the modules
# carrying Windows-specific branches and the SQLite backend suite. The
# full PG suite is not run here — it is covered by the ubuntu `test` job.
- name: Run portability + backend tests
env:
HF_HUB_OFFLINE: "1"
TRANSFORMERS_OFFLINE: "1"
run: >-
pytest --tb=short -q
tests_py/shared/test_platform.py
tests_py/infrastructure/test_pipeline_discovery.py
tests_py/infrastructure/test_pipeline_install_lock.py
tests_py/core/test_staleness.py
tests_py/infrastructure/test_sqlite_backend.py
tests_py/scripts/test_setup_py_backend_skip.py
# Validates `requirements/release.txt` — the dependency set release.yml
# installs before publishing (issue #392). Removing release.yml's own test
# job (see release-gate below) would otherwise leave that narrower
# dependency set validated NOWHERE until a PyPI/uvx install broke on it.
#
# Deliberately NOT the full pytest suite: this job's subject is the
# DEPENDENCY SET, not the suite — ci.yml's `test` matrix already covers the
# suite itself, against a wider dependency set. Re-running pytest here
# would duplicate that coverage at the release-narrowed dependency set's
# cost without adding a new claim.
#
# release.txt deliberately omits tree-sitter, tree-sitter-language-pack,
# igraph, leidenalg and texttable (`.github/actions/test-suite/action.yml`
# header comment) — so nothing this job runs may import from those
# packages, and it must not exercise codebase_analyze/AST paths.
#
# The check itself reuses the cheapest existing mechanism instead of
# inventing a new one: `scripts/verify_mcp_hosts.py`'s stdio initialize +
# tools/list + memory_stats round-trip (the same invocation the
# `test-sqlite` job's "Verify hook-free MCP host contract" step already
# runs) proves the server imports and its full tool surface registers
# under this narrower dependency set, with no PostgreSQL service needed
# (`--storage-selection sqlite` is the default).
release-deps:
name: Release dependency set (requirements/release.txt)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Cache HuggingFace models
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-hf-all-MiniLM-L6-v2
- name: Install the release dependency set (hash-pinned)
run: |
pip install --require-hashes -r requirements/release.txt
pip install --no-deps -e .
# Retry-with-backoff, fail-loudly: see the `test` job's pre-download
# step for the root-cause rationale (CI run 28495801728, 2026-07-01).
- name: Pre-download embedding model
run: |
for attempt in 1 2 3 4 5; do
python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('all-MiniLM-L6-v2', device='cpu')" && exit 0
echo "HF pre-download attempt ${attempt} failed; retrying in $((attempt * 10))s" >&2
sleep $((attempt * 10))
done
echo "HF pre-download failed after 5 attempts" >&2
exit 1
- name: Verify hook-free MCP host contract under the release dependency set
env:
HF_HUB_OFFLINE: "1"
TRANSFORMERS_OFFLINE: "1"
CORTEX_RERANKER_OFFLINE: "1"
run: python scripts/verify_mcp_hosts.py -- hypermnesia-mcp
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Install ruff
# Pinned: ruff's formatter output changes across minor versions
# (0.15.6 vs 0.15.20 divergence broke Lint on PR #83). The repo is
# formatted with 0.15.20; bump this pin and reformat together.
run: pip install --require-hashes -r requirements/lint.txt
- name: Check formatting
run: ruff format --check .
- name: Check linting
run: ruff check .
# Advertised counts (tools, references, mechanisms) must match the
# repository. Runs here — on every push and PR — because doc drift is
# introduced at commit time, not at release time: on 2026-07-27 README,
# CONTRIBUTING, CLAUDE.md and the MCPB manifest each advertised a
# different tool count, and nothing failed. Static only (no imports).
- name: Check documentation claims
run: python scripts/check_doc_claims.py
# [project].version in pyproject.toml against every one of the 14
# version sites across 11 files (manifests, plugin.jsons, the
# marketplace's metadata AND primary-entry version, the MCP registry
# package entry, the four textual occurrences in the version badge,
# and uv.lock's own root-package version). Two of these sites
# (server.json packages[0].version, marketplace.json metadata.version)
# were covered by nothing before this gate existed, and the one gate
# that came closest — marketplace-pins.yml — is path-filtered, so it
# sits outside ci-green and only fires on its weekly cron (issue #392).
# Static only (no imports), same reason as the doc-claim gate above.
- name: Check version surfaces
run: python scripts/check_version_surfaces.py
# The `ci-green` job below is the single status check branch protection
# names; the list of jobs it covers lives in its `needs:`. A job added
# to ci.yml but not to that list would run outside the gate and could
# fail without blocking a merge. Static only (no imports), same reason
# as the doc-claim gate above.
- name: Check the aggregate CI gate covers every job
run: python scripts/check_ci_gate_complete.py
# The README's repo-derived badges are committed SVGs, not hotlinked
# images, so nothing regenerates them on view: a figure that moves
# leaves the badge asserting the old one. Checked here, on every push
# and PR, because that drift is introduced at commit time. Static only
# (no network, no suite) — the tests badge needs a collected count and
# is checked in the test job instead.
- name: Check committed badges
run: python scripts/generate_repo_badges.py --check
# The requirements/ files are the ONLY thing standing between a pip
# install and an unpinned one, and they are generated — so a lock
# change that is not re-exported leaves them describing dependencies
# nobody resolved. Checked here, on every push and PR, because that
# drift is introduced at commit time. Static: reads uv.lock, installs
# nothing.
# uv arrives via the SHA-pinned action, not `pip install uv==0.11.3`:
# Scorecard scores a bare version specifier as "pipCommand not pinned
# by hash" (an `==` resolves to whatever bytes the index serves under
# that version today), so installing the pinning tool with pip would
# have minted the 22nd Pinned-Dependencies alert in the very commit
# that closes the other 21 — and the policy clears the check only at
# score 10. Same form as release.yml's SBOM job.
- name: Install uv (pinned)
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
version: "0.11.3"
# Runs BEFORE the requirements-file check below on purpose: that check
# only compares the committed requirements/*.txt against whatever
# uv.lock CURRENTLY says, so a stale uv.lock and its stale export agree
# with each other and the check stays green. `uv lock --check` instead
# re-resolves pyproject.toml and fails if uv.lock would change — the
# half of "lockfile is current" the other check cannot see.
#
# This exact drift shipped (issue #251): Dependabot's `deps` PR #218
# widened tree-sitter-language-pack's upper bound in pyproject.toml
# (`<1.7` -> `<1.14`) but touched no other file — its "pip" ecosystem
# entry rewrites the version specifier only, it does not re-run `uv
# lock`. uv.lock kept resolving 1.6.2 (still valid under the wider
# range, and `uv lock`'s incremental resolution prefers the existing
# pin over the newest compatible one), so `.venv/bin/python -m pyright
# mcp_server/` reported a real diagnostic for a contributor who ran
# `uv sync --locked` while CI — which back then installed the package
# editable with its extras resolved straight from pyproject.toml's
# ranges, unconstrained by the lock (the exact pattern
# test_job_does_not_pip_install_an_extra_range now forbids below) —
# silently resolved 1.13.5 and stayed green on the same commit.
#
# Reproduced 2026-07-29 by checking out pyproject.toml + uv.lock as of
# 9e293baa (#218, the commit right after the widening) into a scratch
# directory and running `uv lock --check` there:
# Resolved 196 packages in 186ms
# The lockfile at `uv.lock` needs to be updated, but `--check` was
# provided. To update the lockfile, run `uv lock`.
# — i.e. this step would have failed, at commit time, on the PR that
# introduced the drift. Static: reads pyproject.toml + uv.lock,
# resolves, installs nothing (~200ms locally).
- name: Check uv.lock matches pyproject.toml
run: uv lock --check
- name: Check hash-pinned requirements match uv.lock
run: python scripts/generate_pip_constraints.py --check
# actionlint (which shells out to the runner's preinstalled shellcheck for
# each `run:` block) was never wired into any gate — issue #247, found by
# hand after these findings had been latent since before the Node24 action
# bump. Pinned by release tag + checksum (not `go install @latest` /
# curl|bash), matching this repo's supply-chain-hardening stance (release.yml).
# source: https://github.com/rhysd/actionlint/releases/tag/v1.7.12,
# actionlint_1.7.12_checksums.txt, linux_amd64 entry, verified 2026-07-29.
- name: Install actionlint (pinned, checksum-verified)
run: |
set -euxo pipefail
VERSION="1.7.12"
ARCHIVE="actionlint_${VERSION}_linux_amd64.tar.gz"
curl -fsSLO "https://github.com/rhysd/actionlint/releases/download/v${VERSION}/${ARCHIVE}"
echo "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 ${ARCHIVE}" | sha256sum -c -
tar -xzf "${ARCHIVE}" actionlint
sudo install -m 0755 actionlint /usr/local/bin/actionlint
actionlint -version
- name: Check workflow files (actionlint + shellcheck)
run: actionlint -color
# Deterministic enforcement of CLAUDE.md § Code Style — file size, method
# size, layer-boundary imports, and unsourced magic numbers — which that
# section admitted was "enforced by code review today; no automated
# pre-commit hook checks this yet" until this job. Measured on a single
# PR the night before this job was added: a 301-line file reported as
# 280, three of four over-40-line methods unseen, one layer violation
# justified by a fabricated citation — each caught only by a human or
# agent re-reading the diff, never by a machine. See
# scripts/check_craftsmanship.py's module docstring for the rules and
# scripts/craftsmanship_baseline.py for why pre-existing debt (recorded
# in .craftsmanship-baseline.json) does not retroactively block.
craftsmanship:
name: Craftsmanship Gate
runs-on: ubuntu-latest
steps:
# fetch-depth: 0 so `origin/main` — the diff base the gate compares
# the PR's changed files against — is resolvable locally, not just
# the single commit a shallow checkout would leave.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
# Standard library only (scripts/check_craftsmanship.py's module
# docstring) — no dependency install needed, matching the
# doc-claim/version-surface/ci-gate-completeness gates in `lint`
# below, which are static for the same reason.
- name: Run the craftsmanship gate on this PR's changed files
run: python scripts/check_craftsmanship.py
typecheck:
name: Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"
# Pyright resolves third-party imports from ./.venv (pinned in
# pyrightconfig.json: venvPath="."/venv=".venv"). The full stub set MUST
# be installed or every import collapses to Unknown — and Unknown
# SUPPRESSES downstream type errors, silently masking real defects.
# source: measured 2026-06-18 — an unresolved env reports 566 errors, a
# fully-resolved env reports 593 (Unknown was masking 27+ real errors).
# flashrank (core reranker) + sqlite-vec live outside dev/postgresql/
# codebase; [otel] resolves the opentelemetry exporter imports.
- name: Create .venv with the full type-check environment
run: |
python -m venv .venv
.venv/bin/pip install --require-hashes -r requirements/ci-typecheck.txt
.venv/bin/pip install --no-deps -e .
# Pin pyright — diagnostic output drifts between releases, so a
# zero-diagnostic tree is only comparable against the pinned version.
.venv/bin/pip install --require-hashes -r requirements/typecheck-tool.txt
# The gate's verdict is a property of THIS environment, so the log has to
# name it. Issue #253: a contributor and CI read two different
# `tree-sitter-language-pack` type surfaces on the same commit, and
# nothing in either log said which one had been analysed. One grep per
# package, not one alternation: a miss then exits non-zero on the
# package that is actually absent, so an install that silently dropped
# one fails here instead of degrading to Unknown inside pyright.
- name: Record the resolved type surface
run: |
set -euo pipefail
freeze="$(.venv/bin/pip list --format=freeze)"
for pkg in pyright tree-sitter tree-sitter-language-pack; do
printf '%s\n' "${freeze}" | grep -E "^${pkg}=="
done
# pyright IS the gate: the backlog was burned to zero (issue #197,
# 568 baselined diagnostics -> 0 measured 2026-07-28), so ANY
# diagnostic — any rule, any severity-error — fails the build via
# pyright's own exit code. The former per-rule ratchet
# (scripts/check_pyright_ratchet.py + typecheck-baseline.json) is
# retired: a floor file only exists to tolerate a backlog.
- name: Run pyright (zero-diagnostic gate)
run: .venv/bin/python -m pyright mcp_server/
build:
name: Build Package
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Install build tools
run: pip install --require-hashes -r requirements/packaging.txt
- name: Build sdist and wheel
run: python -m build
- name: Upload build artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dist
path: dist/
# The two images below had NO CI build at all until this change, so a
# regression in either was invisible until someone built it by hand. That
# is not hypothetical: docker/Dockerfile copied a python3.12 site-packages
# path against a python:3.14 base and could not build, and nothing said so.
#
# These build only — no smoke run. docker/Dockerfile brings up PostgreSQL
# and pre-caches models, and .devcontainer/Dockerfile is opened by the Dev
# Containers CLI; asserting their runtime behaviour is a separate job with
# separate services. The claim here is narrow and worth making on its own:
# the image still builds, and its hash-pinned installs still resolve.
docker-runtime-build:
name: Docker Build (runtime image)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# This image carries PostgreSQL 17, Node 22, torch and a pre-cached
# embedding model. A stock runner has ~14GB free, which the build can
# exhaust; the preinstalled toolchains below are worth ~10GB and are
# not used by it.
- name: Free runner disk
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
sudo rm -rf /usr/local/share/boost "$AGENT_TOOLSDIRECTORY"
df -h /
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Build docker/Dockerfile
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./docker/Dockerfile
tags: cortex-runtime:ci
load: false
cache-from: type=gha
cache-to: type=gha,mode=max
devcontainer-build:
name: Docker Build (devcontainer image)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Free runner disk
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
sudo rm -rf /usr/local/share/boost "$AGENT_TOOLSDIRECTORY"
df -h /
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Build .devcontainer/Dockerfile
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./.devcontainer/Dockerfile
tags: cortex-devcontainer:ci
load: false
cache-from: type=gha
cache-to: type=gha,mode=max
docker-smoke:
name: Docker Smoke (bare-container DB-less contract)
runs-on: ubuntu-latest
# BLOCKING: this job asserts the exact contract that silently broke for
# two months (fix/bare-container-contract, commit 5d71069c) — third-party
# registry indexers (Glama et al.) `docker build` the bare repo, then
# `docker run` with zero env vars and zero external services, and expect
# `tools/list` to answer. No other CI job exercises this path: `test` and
# `test-sqlite` both install from source with dev extras and never build
# the production image; nothing else runs the image with psycopg absent
# and no DATABASE_URL. continue-on-error is intentionally NOT set.
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
# GitHub Actions cache (type=gha) persists Docker layer cache across
# workflow runs, scoped to this repo. Without it, every run repeats the
# torch CPU-wheel download (~200MB, pinned via the CPU-only index in
# the Dockerfile) and the full pip install layer. With a warm cache,
# only changed layers (COPY mcp_server, pip install of the local
# package) rebuild.
# source: docker/build-push-action README, "GitHub Cache" backend
# (https://github.com/docker/build-push-action#cache-backend-api),
# type=gha is the documented zero-config option for GHA runners.
- name: Build image (buildx, GHA layer cache)
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./Dockerfile
tags: cortex-smoke:local
load: true
cache-from: type=gha
cache-to: type=gha,mode=max
# Reuses the build above (load: true made it visible to the local
# docker daemon) instead of rebuilding — scripts/docker_smoke.sh is the
# single source of truth for the build+run+assert sequence, shared with
# local dev (`scripts/docker_smoke.sh` with no args builds AND smokes).
- name: Run bare-container smoke test
run: scripts/docker_smoke.sh --skip-build
env:
CORTEX_SMOKE_IMAGE: cortex-smoke:local
# The one status check branch protection on `main` names. Everything else
# in this workflow is reached through its `needs:` list, so renaming a job,
# resizing the matrix, or delegating steps to a reusable workflow (which
# rewrites check names to "<caller> / <callee>", issue #336) no longer
# touches the protection settings — the contract is this file, in git,
# visible in review, instead of eleven job-name strings in GitHub settings
# that no diff ever shows.
#
# `if: always()` is load-bearing: without it this job is itself skipped the
# moment any need fails, the required context is never reported, and the PR
# blocks with no explanation instead of a red X naming the culprit.
#
# scripts/check_ci_gate_complete.py (run by `lint`) refuses a merge if any
# job here is missing from `needs`, or if a job carrying a job-level `if:`
# is absent from ALLOWED_SKIPS below.
ci-green:
name: CI Green
if: always()
runs-on: ubuntu-latest
needs:
- test
- test-sqlite
- mcp-host-config
- test-windows
- release-deps
- craftsmanship
- lint
- typecheck
- build
- docker-runtime-build
- devcontainer-build
- docker-smoke
steps:
- name: Require every job above to have succeeded
env:
NEEDS: ${{ toJSON(needs) }}
# Jobs permitted to report `skipped`, with the reason. ONLY jobs
# carrying a job-level `if:` belong here; anything else that skips
# is a gap, not an exemption.
# mcp-host-config — `if: github.event_name != 'pull_request' ||
# github.event.pull_request.head.repo.fork == false`: a fork PR
# has no access to the secrets it needs, so it cannot run there.
ALLOWED_SKIPS: mcp-host-config
run: |
set -euo pipefail
echo "$NEEDS"
failed=$(printf '%s' "$NEEDS" | jq -r --arg allowed "$ALLOWED_SKIPS" '
($allowed | split(",") | map(ltrimstr(" ") | rtrimstr(" "))) as $ok
| to_entries[]
| select(.value.result != "success")
| select(.value.result != "skipped" or ([.key] | inside($ok) | not))
| "\(.key)=\(.value.result)"')
if [ -n "$failed" ]; then
echo "::error::CI Green refuses the merge — $(echo "$failed" | tr '\n' ' ')"
exit 1
fi
echo "every required job succeeded"
# The tag-as-output half of issue #392: a green push to `main` tags the
# exact SHA that just passed CI Green, instead of a human hand-picking
# which (possibly-unvalidated) tree to tag — the v4.17.0 root cause
# (release run 30741657854 tagged a tree release.yml's own test job had
# never actually validated against ci.yml's hardening).
#
# ci-gate-exempt: this job runs ONLY on a push to `main`, i.e. AFTER the PR
# it belongs to has already merged. There is nothing left for it to gate —
# it cannot block a PR that is already closed — so it does not appear in
# ci-green.needs (see scripts/check_ci_gate_complete.py's ci-gate-exempt
# marker convention). Putting it there would make the branch-protection
# context depend on a job that never runs on a PR in the first place,
# permanently blocking every PR.
release-gate:
name: Tag a release
needs: [ci-green]
# ci-gate-exempt: runs only on push to main, after the PR it belongs to
# has already merged — see the job comment above.
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
# `contents: read`, NOT write, and that is deliberate. The tag is pushed
# with the RELEASE_TAG_SSH_KEY deploy key (persisted by the checkout step
# below), never
# with GITHUB_TOKEN — granting GITHUB_TOKEN `contents: write` here would
# be an unused permission (issue #178 least-privilege), and worse, it
# would let a future edit that drops `ssh-key:` from the checkout push the
# tag with GITHUB_TOKEN and SUCCEED — producing a tag that silently never
# starts release.yml. Read-only makes that mistake fail loudly instead.
permissions:
contents: read
# A DEDICATED group — not this workflow's `${{ github.workflow }}-${{
# github.ref }}` group above, which sets `cancel-in-progress: true`. A
# push to main that lands mid-tag-push would be cancelled by the very
# next push to main under that group, leaving a partially-created tag on
# the remote (a tag object pushed but no corresponding release started,
# or the reverse) — exactly the half-state a release process must never
# produce. This group instead serializes tag operations one at a time
# without ever cancelling one already in flight.
concurrency:
group: release-tag
cancel-in-progress: false
steps:
# secrets.RELEASE_TAG_SSH_KEY is the PRIVATE half of a repository deploy
# key (ed25519, read_only=false, created 2026-08-08). A deploy key, and
# not a PAT or a GitHub App token, because neither of those can be
# minted through the API — both require a browser — and the deploy key
# is the tighter credential anyway: scoped to this ONE repository,
# carrying no account access, and with no expiry to silently break
# releases. Per
# GitHub's docs, a ref pushed with the default GITHUB_TOKEN (unlike
# workflow_dispatch/repository_dispatch) does NOT start a new workflow
# run — a GITHUB_TOKEN-pushed tag would therefore never reach
# release.yml's `push: tags` trigger. Same guarded-optional-secret
# shape as sync-ccplugins-fork.yml's `has_pat` step: absence is a
# clean, loud skip, not a red job.
- name: Guard — skip cleanly when RELEASE_TAG_SSH_KEY is missing
id: guard
env:
SSH_KEY: ${{ secrets.RELEASE_TAG_SSH_KEY }}
run: |
if [ -z "$SSH_KEY" ]; then
echo "RELEASE_TAG_SSH_KEY secret not configured — skipping."
echo "Add the private half of a write-enabled repository deploy"
echo "key at Settings -> Secrets and variables -> Actions."
echo "Releases stay manual until it exists."
echo "has_deploy_key=false" >> "$GITHUB_OUTPUT"
else
echo "has_deploy_key=true" >> "$GITHUB_OUTPUT"
fi
# fetch-depth: 0 so `git rev-parse --verify refs/tags/vX` below can see
# every existing tag, not just the tip commit. `ssh-key:` installs the
# deploy key as the credential `git push` uses later in this
# job (actions/checkout persists it by default) — the non-GITHUB_TOKEN
# credential the tag push requires (see the guard step's comment).
- name: Checkout the SHA that just went green
if: steps.guard.outputs.has_deploy_key == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
fetch-depth: 0
ssh-key: ${{ secrets.RELEASE_TAG_SSH_KEY }}
- name: Set up Python
if: steps.guard.outputs.has_deploy_key == 'true'
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
# Reuses doc_claim_sources.canonical_version — the same reader
# check_version_surfaces.py and check_doc_claims.py already trust —
# instead of re-deriving a second pyproject.toml version regex here.
# A push to `main` for a commit that does not bump the version is the
# NOMINAL case (most commits are not releases): tag v$VERSION already
# exists, and this step's job is to detect that and skip silently
# rather than fail or re-tag.
- name: Resolve the version and check for an existing tag
id: tag_check
if: steps.guard.outputs.has_deploy_key == 'true'
run: |
set -euo pipefail
VERSION="$(python3 -c "
import sys
sys.path.insert(0, 'scripts')
import doc_claim_sources
print(doc_claim_sources.canonical_version(lambda p: open(p, encoding='utf-8').read()))
")"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
echo "tag v${VERSION} already exists — this push carries no version bump"
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "no existing tag v${VERSION} — this push is a version bump"
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
# The last line of defence before a tag: `main` should never carry a
# partial bump anyway (the identical gate already runs in `lint` on
# every push and PR — commit 4781c134), so reaching this step with a
# mismatch means something bypassed the PR gate (a direct push, an
# admin-merge override). A failure here is a RED job, not a skip —
# silently tagging a mismatched tree is exactly the v4.17.0 failure
# mode this workflow exists to make structurally impossible.
- name: Check version surfaces
if: steps.guard.outputs.has_deploy_key == 'true' && steps.tag_check.outputs.exists == 'false'
run: python scripts/check_version_surfaces.py
- name: Configure git identity
if: steps.guard.outputs.has_deploy_key == 'true' && steps.tag_check.outputs.exists == 'false'
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# Annotated (not lightweight): carries its own object, author, and
# message, which `git tag -a` computes but `git tag` alone does not —
# the shape release.yml's changelog step and GitHub's own release UI
# expect. Tagged at `github.sha` explicitly (not the checkout's
# working-tree HEAD) so the released commit is provably the one that
# went green, even if a future edit changes what this job checks out.
- name: Create and push the release tag
if: steps.guard.outputs.has_deploy_key == 'true' && steps.tag_check.outputs.exists == 'false'
run: |
set -euo pipefail
VERSION="${{ steps.tag_check.outputs.version }}"
git tag -a "v${VERSION}" -m "Release v${VERSION}" "${{ github.sha }}"
git push origin "refs/tags/v${VERSION}"
# Visible without opening logs: the decision (tagged / no-bump /
# no-token) is exactly the property issue #392's acceptance criteria
# ask to verify ("a green push to main with no version change tags
# nothing" — verifiable in the release-gate logs).
- name: Summarize the release-gate decision
if: always()
run: |
if [ "${{ steps.guard.outputs.has_deploy_key }}" != "true" ]; then
echo "## Release gate: skipped — RELEASE_TAG_SSH_KEY not configured" >> "$GITHUB_STEP_SUMMARY"
elif [ "${{ steps.tag_check.outputs.exists }}" == "true" ]; then
echo "## Release gate: skipped — v${{ steps.tag_check.outputs.version }} already tagged (no version bump on this push)" >> "$GITHUB_STEP_SUMMARY"
else
echo "## Release gate: tagged v${{ steps.tag_check.outputs.version }} at \`${{ github.sha }}\`" >> "$GITHUB_STEP_SUMMARY"
fi