chore(marketplace): move hypermnesia-mcp-viz pin to v3.1.1 #1397
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |