CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 13s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 27s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 12s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 13s
CI / Catalog signature (pull_request) Successful in 7s
- paths-ignore '**/*.md' on push/pull_request so a CHANGELOG/README-only PR doesn't spin up the (self-hosted, sometimes-offline) Windows test job. - timeout-minutes on lint (10) / test (15) / catalog-signature (10) so a job that hangs mid-run fails instead of hanging forever. Note: timeout-minutes counts from job start, so it does not rescue a job stuck 'Waiting to run' when the Windows runner is offline — paths-ignore covers the docs case; code PRs still need the runner up.
186 lines
7.7 KiB
YAML
186 lines
7.7 KiB
YAML
name: CI
|
|
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
paths-ignore: ["**/*.md"]
|
|
pull_request:
|
|
paths-ignore: ["**/*.md"]
|
|
workflow_dispatch:
|
|
|
|
jobs:
|
|
lint:
|
|
runs-on: ubuntu-latest
|
|
name: Lint (ruff)
|
|
timeout-minutes: 10
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Set up Python 3.12
|
|
uses: actions/setup-python@v5
|
|
with:
|
|
python-version: "3.12"
|
|
|
|
- name: Install ruff
|
|
run: pip install ruff
|
|
|
|
- name: ruff check
|
|
run: ruff check .
|
|
|
|
- name: ruff format --check
|
|
run: ruff format --check .
|
|
|
|
test:
|
|
runs-on: ${{ matrix.os }}
|
|
name: Tests (py${{ matrix.python }} / ${{ matrix.os }})
|
|
# Guards against a job that hangs mid-run (e.g. a wedged test). Note: this
|
|
# counts from when a runner PICKS UP the job, so it does not rescue a job
|
|
# stuck "Waiting to run" because the self-hosted Windows runner is offline —
|
|
# for that, bring the runner back or skip via paths-ignore (docs).
|
|
timeout-minutes: 15
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
os: [ubuntu-latest]
|
|
python: ["3.10", "3.12", "3.13"]
|
|
include:
|
|
# Windows tests on 3.12 only — the version the release binaries ship
|
|
# with. The self-hosted Windows runner blocks setup-python's install
|
|
# script (PowerShell execution policy), so it uses the host's `py`
|
|
# launcher + venv, same as release.yml.
|
|
- os: windows-latest
|
|
python: "3.12"
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Set up Python ${{ matrix.python }} (Linux)
|
|
if: runner.os == 'Linux'
|
|
uses: actions/setup-python@v5
|
|
with:
|
|
python-version: ${{ matrix.python }}
|
|
|
|
- name: Set up Python venv (Windows)
|
|
if: runner.os == 'Windows'
|
|
shell: pwsh
|
|
run: |
|
|
py -${{ matrix.python }} -m venv .venv
|
|
Add-Content -Path $env:GITHUB_PATH -Value "$env:GITHUB_WORKSPACE\.venv\Scripts"
|
|
|
|
# bcc_core has no GUI imports, so the test suite needs no PySide6 —
|
|
# keeps CI fast and avoids Qt system-library headaches on the runner.
|
|
# cryptography is for tests/test_checksums.py (release signing helper).
|
|
- name: Install test dependencies
|
|
run: pip install pytest cryptography
|
|
|
|
- name: Run tests
|
|
run: python -m pytest -v
|
|
|
|
# ── Catalog signature gate (#61) ─────────────────────────────────────────
|
|
#
|
|
# data/catalog.json is a list of command+args entries that BCC writes into
|
|
# the user's Claude config, which Claude then EXECUTES. The catalog is only
|
|
# trusted if it carries a valid Ed25519 signature from the maintainer key.
|
|
#
|
|
# The threat this gate exists for is NOT an outsider pushing to the repo —
|
|
# it is the maintainer merging a friendly-looking PR without really reading
|
|
# it. A contributor can change catalog.json but cannot produce a matching
|
|
# signature, so a blindly-merged PR lands here as a RED BUILD within a
|
|
# minute, instead of quietly riding into the next release.
|
|
#
|
|
# Public-key verification only. No secret is used or needed.
|
|
catalog-signature:
|
|
name: Catalog signature
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 10
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- uses: actions/setup-python@v5
|
|
with:
|
|
python-version: "3.12"
|
|
|
|
- name: Install dependencies
|
|
run: pip install cryptography
|
|
|
|
# 🔴 TRUST ANCHOR — issue #68 finding 4.
|
|
#
|
|
# This step used to do `import bcc_core as c` FROM THE CHECKED-OUT PR
|
|
# BRANCH and verify the catalog against c.CATALOG_PUBKEYS — i.e. it
|
|
# trusted the public key shipped in the very diff it was reviewing. A
|
|
# PR that changed data/catalog.json AND bcc_core.CATALOG_PUBKEYS (to
|
|
# an attacker key, with a matching signature produced by the attacker's
|
|
# matching private key) went green, because there was nothing outside
|
|
# the PR's own content to check the key against. The gate's whole
|
|
# point is catching a friendly-looking PR the maintainer merges
|
|
# without really reading it — and that hole made it a two-file diff.
|
|
#
|
|
# EXPECTED_CATALOG_PUBKEY_B64 below is hardcoded HERE, in the workflow
|
|
# file, independent of whatever bcc_core.py says on the PR branch. It
|
|
# is intentionally the only line in this step that matters for
|
|
# security review: changing it changes what this gate is willing to
|
|
# trust. THIS CONSTANT IS A TRUST ANCHOR. A PR that changes this line
|
|
# in the same diff as a catalog change is exactly the attack this gate
|
|
# exists to prevent — review a change to this line on its own,
|
|
# never bundled with a catalog update.
|
|
#
|
|
# NOTE for the next key rotation: update EXPECTED_CATALOG_PUBKEY_B64
|
|
# below to the new key's base64 form, as its own reviewed change.
|
|
- name: Verify data/catalog.json.sig
|
|
env:
|
|
EXPECTED_CATALOG_PUBKEY_B64: "082NOwVB7uURkvfyS3+knJ+40Fk6C9unsF47+2uPKo4="
|
|
run: |
|
|
python - <<'PY'
|
|
import base64, os, pathlib, sys
|
|
import bcc_core as c
|
|
|
|
expected_pubkey_b64 = os.environ["EXPECTED_CATALOG_PUBKEY_B64"]
|
|
|
|
raw = pathlib.Path("data/catalog.json").read_bytes()
|
|
sig_path = pathlib.Path("data/catalog.json.sig")
|
|
|
|
if not sig_path.exists():
|
|
sys.exit("FAIL: data/catalog.json.sig is missing. The catalog must be "
|
|
"signed via the Catalog Console (#62) before it can land.")
|
|
|
|
if b"\x00" * 32 in c.CATALOG_PUBKEYS:
|
|
sys.exit("FAIL: CATALOG_PUBKEYS still holds the placeholder key.")
|
|
|
|
# Trust anchor check FIRST, before verifying anything against
|
|
# bcc_core.CATALOG_PUBKEYS: a PR is not allowed to bring its own
|
|
# key. CATALOG_PUBKEYS on the checked-out branch must be EXACTLY
|
|
# the key(s) this workflow file itself expects -- no more, no
|
|
# fewer, no substitutions.
|
|
actual_pubkeys_b64 = [base64.b64encode(k).decode() for k in c.CATALOG_PUBKEYS]
|
|
if actual_pubkeys_b64 != [expected_pubkey_b64]:
|
|
sys.exit(
|
|
"FAIL: bcc_core.CATALOG_PUBKEYS on this branch does not match the "
|
|
"trust anchor hardcoded in .github/workflows/ci.yml.\n"
|
|
f" expected: {[expected_pubkey_b64]}\n"
|
|
f" actual: {actual_pubkeys_b64}\n"
|
|
"\n"
|
|
"This PR is changing (or has changed) the catalog signing key. That "
|
|
"change must be reviewed on its own, separately from any catalog "
|
|
"content change, and the workflow's EXPECTED_CATALOG_PUBKEY_B64 "
|
|
"updated deliberately -- not accepted because it happened to match "
|
|
"whatever bcc_core.py says on this branch."
|
|
)
|
|
|
|
if not c.verify_catalog_signature(raw, sig_path.read_bytes(), c.CATALOG_PUBKEYS):
|
|
sys.exit(
|
|
"FAIL: data/catalog.json does NOT match its signature.\n"
|
|
"\n"
|
|
"The catalog changed without being re-signed. Either someone edited\n"
|
|
"it directly (a PR you merged?), or a signing pass was forgotten.\n"
|
|
"Re-review and re-sign with the Catalog Console — do not bypass this."
|
|
)
|
|
|
|
problems = c.validate_catalog(c.load_catalog(raw))
|
|
if problems:
|
|
sys.exit("FAIL: catalog failed validation:\n " + "\n ".join(problems))
|
|
|
|
print("OK: catalog signature verifies, the pubkey matches the CI trust anchor, "
|
|
"and the catalog validates clean.")
|
|
PY
|