Skip to content

chore: document maintenance freeze and pause version-update PRs #596

chore: document maintenance freeze and pause version-update PRs

chore: document maintenance freeze and pause version-update PRs #596

Workflow file for this run

name: CI Baseline
# Minimal CI for Phase 0: lint, secret scan, and proto lint (when applicable).
# Build / test workflows are added in Phase 1+ once Bazel targets exist.
on:
push:
branches: [main]
pull_request:
branches: [main]
# Required for GitHub merge queue. Every context in main-protection's
# required_status_checks list is a job in THIS workflow, so without a
# merge_group trigger a queued PR waits forever on checks that never
# run — the queue deadlocks rather than failing visibly. Adding the
# trigger must therefore land BEFORE the merge_queue rule is enabled.
merge_group:
types: [checks_requested]
# github.ref is the queue's own gh-readonly-queue/... ref on merge_group
# events, so each queued entry gets its own concurrency group and entries
# do not cancel each other.
concurrency:
group: ci-baseline-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: read
jobs:
# ---------------------------------------------------------------------------
# Pre-commit: runs all hooks defined in .pre-commit-config.yaml
# This is the primary lint / format gate.
# ---------------------------------------------------------------------------
pre-commit:
name: Pre-commit hooks
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0 # needed for buf-breaking against main
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.12"
- name: Run pre-commit
uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1
# ---------------------------------------------------------------------------
# Lockfile format guard — cheap, fast-failing check that the repo-root
# pnpm-lock.yaml still declares lockfileVersion 6.0. Root package.json
# pins "packageManager": "pnpm@8.6.7", and ADR-0015 explains why: 6.0 is
# the lockfile format aspect_rules_js's npm_translate_lock expects. Both
# this workflow's "Install frontend deps (hermetic pnpm)" step and
# release-staging-frontend.yml consume this lockfile via Bazel.
#
# If the lockfile is ever regenerated with a pnpm newer than 8.6.7 (e.g.
# a Dependabot npm PR that runs its own pnpm), lockfileVersion drifts to
# 9.0 and the Bazel build breaks, not just frontend tests. No Bazel, no
# pnpm install, no network here — just a checkout and a grep, so this
# fails loud in seconds instead of surfacing later as a confusing Bazel
# error.
#
# The value match is tolerant of quoting: native pnpm@8.6.7 output is
# single-quoted (lockfileVersion: '6.0'), but the repo's prettier
# pre-commit hook rewrites it to double-quoted (lockfileVersion: "6.0")
# once it runs. Both are legitimate depending on whether prettier has
# touched the file yet, so the check accepts bare, single-quoted, or
# double-quoted "6.0" — but the value itself must match exactly
# ("16.0" or "6.01" still fail; this is not a substring match).
# ---------------------------------------------------------------------------
lockfile-format-guard:
name: Lockfile format guard
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Assert pnpm-lock.yaml is lockfileVersion 6.0
run: |
set -euo pipefail
file="pnpm-lock.yaml"
expected_desc='lockfileVersion 6.0 (bare, single-quoted, or double-quoted)'
if [ ! -f "$file" ]; then
echo "::error::${file} not found at repo root. Expected ${expected_desc}. Regenerate with pnpm@8.6.7 per ADR-0015 (docs/ADR/0015-hermetic-nodejs-via-aspect-rules-js.md)."
exit 1
fi
found=$(grep -m1 '^lockfileVersion:' "$file" || true)
if [ -z "$found" ]; then
echo "::error::${file} has no lockfileVersion line at all. Expected ${expected_desc}. Regenerate with pnpm@8.6.7 per ADR-0015 (docs/ADR/0015-hermetic-nodejs-via-aspect-rules-js.md)."
exit 1
fi
# Anchored to the whole line: cannot match a nested key, and
# cannot substring-match "16.0" / "6.01" — the value between
# the optional quotes must be exactly 6.0.
regex="^lockfileVersion:[[:space:]]*[\"']?6\.0[\"']?[[:space:]]*\$"
if ! printf '%s\n' "$found" | grep -Eq "$regex"; then
echo "::error::${file} lockfileVersion mismatch. Expected: ${expected_desc}. Found: ${found}. Regenerate the lockfile with pnpm@8.6.7 (root package.json packageManager pin) per ADR-0015 — a different lockfileVersion (e.g. 9.0 from a newer pnpm) breaks aspect_rules_js's npm_translate_lock and the Bazel build, not just the frontend tests."
exit 1
fi
echo "OK: ${file} declares ${expected_desc} (found: ${found})"
# ---------------------------------------------------------------------------
# Secret scanning — redundant with GitHub push protection + pre-commit
# gitleaks, but runs in CI for pull requests from forks where push
# protection may not apply.
# ---------------------------------------------------------------------------
secret-scan:
name: Gitleaks secret scan
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
- name: Run gitleaks
uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# ---------------------------------------------------------------------------
# Proto lint — only runs when proto/ directory contains .proto files.
# In Phase 0 this job is a no-op until the contracts land in Phase 1.
# ---------------------------------------------------------------------------
buf-lint:
name: Proto lint
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Check for proto files
id: check_protos
run: |
if ls proto/**/*.proto >/dev/null 2>&1; then
echo "has_protos=true" >> "$GITHUB_OUTPUT"
else
echo "has_protos=false" >> "$GITHUB_OUTPUT"
fi
- name: Set up buf
if: steps.check_protos.outputs.has_protos == 'true'
uses: bufbuild/buf-setup-action@a47c93e0b1648d5651a065437926377d060baa99 # v1
- name: Run buf lint
if: steps.check_protos.outputs.has_protos == 'true'
run: buf lint
- name: Run buf breaking
if: steps.check_protos.outputs.has_protos == 'true' && github.event_name == 'pull_request'
# Pass the PR base ref through env, not direct ${{ }} interpolation
# into the run: body — branch names are untrusted github-context
# data, so inlining them is a shell-injection sink (semgrep
# run-shell-injection). The env indirection makes the value a
# quoted shell variable the runner cannot evaluate as code.
env:
BASE_REF: ${{ github.base_ref }}
run: buf breaking --against ".git#branch=${BASE_REF}"
# ---------------------------------------------------------------------------
# Proto codegen drift — ADR-0013. The .pb.go files under
# gateway_go/gen/go/ are checked in for IDE/gopls consumption while
# Bazel remains the authoritative producer. This job re-runs
# `buf generate` and fails if the working tree differs, catching PRs
# that edit .proto without re-running tools/scripts/proto_gen.sh.
# ---------------------------------------------------------------------------
proto-codegen-drift:
name: Proto codegen drift check
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Regenerate via tools/scripts/proto_gen.sh
run: ./tools/scripts/proto_gen.sh
- name: Verify no diff
# `frontend_web/src/gen/`, not `frontend_web/gen/`. buf.gen.yaml
# writes the TypeScript tree under src/ so Vite's `@/` alias
# resolves it; the old pathspec pointed at a directory that has
# never existed, which made `git diff` match nothing and let TS
# codegen drift through unnoticed.
run: |
if ! git diff --exit-code -- gateway_go/gen/ frontend_web/src/gen/ 2>/dev/null; then
echo "::error::Generated proto code is out of sync with .proto sources."
echo "::error::Run ./tools/scripts/proto_gen.sh locally and commit the result."
exit 1
fi
# ---------------------------------------------------------------------------
# Bazel unit tests — runs //engine_cpp/tests/unit/... and any future
# //gateway_go/...:test targets. Excludes integration tests tagged
# `requires-model` because CI does not fetch the 75 MB whisper model
# (per ADR-0011's CI integration plan; full WER regression lands in
# Phase 2 with the cron-based workflow).
#
# actions/cache holds .bazel_cache so subsequent CI runs skip the
# cold whisper.cpp + grpc build. Cache key is keyed on MODULE.bazel
# so dep bumps invalidate cleanly.
# ---------------------------------------------------------------------------
bazel-unit-tests:
name: Bazel unit tests
runs-on: ubuntu-latest
# cold ggml builds run 17-28min; 30 was too tight (run 27428960657 hit the wall 2026-06-12)
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Cache Bazel
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
# Linux CI runners have spaceless paths so our bazelisk wrapper
# uses the in-repo .bazel_cache; on macOS dev machines with
# spaces in the repo path it routes to /tmp/aegis-bazel-* —
# both included so the cache pattern is portable.
# .bazelisk/ holds the downloaded Bazel binary.
# actions/cache stays as the no-internet fallback (ADR-0014
# α); BuildBuddy layered on top in the next step shortens
# cold runs further by sharing artifacts across PRs.
path: |
${{ github.workspace }}/.bazel_cache
${{ github.workspace }}/.bazelisk
/tmp/aegis-bazel-*
key: bazel-unit-${{ runner.os }}-${{ hashFiles('MODULE.bazel', 'MODULE.bazel.lock', '.bazelversion', '.bazelrc') }}
restore-keys: |
bazel-unit-${{ runner.os }}-
# ADR-0014 Option β (Phase A, demo horizon): BuildBuddy Personal
# remote cache. Writes .bazelrc.user (gitignored, runner-local
# only) so every subsequent bazelisk build/test call in this job
# inherits the cache flags without having to repeat them. The
# committed .bazelrc stays cache-free, preserving the "clone it,
# build it, it just works" local posture (README.md Quick Start).
#
# In forks without BUILDBUDDY_API_KEY set, this step no-ops and
# Bazel falls back to local execution — CI still works, just
# colder. See docs/runbooks/buildbuddy-cache-setup.md for how
# upstream operators and fork operators provision the key.
- name: Configure BuildBuddy remote cache
env:
BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }}
run: |
if [ -z "${BUILDBUDDY_API_KEY:-}" ]; then
echo "BUILDBUDDY_API_KEY not set — skipping remote cache (normal in forks)"
exit 0
fi
# Ensure any accidental echo of the key in later steps is masked.
echo "::add-mask::$BUILDBUDDY_API_KEY"
cat > .bazelrc.user <<EOF
build --remote_cache=grpcs://remote.buildbuddy.io
build --remote_header=x-buildbuddy-api-key=$BUILDBUDDY_API_KEY
build --bes_backend=grpcs://remote.buildbuddy.io
build --bes_results_url=https://app.buildbuddy.io/invocation/
build --remote_timeout=3600
EOF
echo "BuildBuddy cache configured. Invocation dashboard: https://app.buildbuddy.io/"
- name: Build proto + engine + gateway + gateway OCI image
run: |
# //packaging/gateway:image is the Phase 4a Slice 1 OCI smoke
# target — builds the distroless layered image. ADR-0025.
./tools/bazelisk/bazelisk build \
//proto/aegis/v1:aegis_cc_grpc \
//proto/aegis/v1:aegis_go_proto \
//engine_cpp/cmd/engine:engine \
//gateway_go/cmd/gateway:gateway \
//packaging/gateway:image
# ADR-0025 Camp B / dev-CI split: dev boxes never need Docker
# (host-native binary suffices for development). CI is the SOLE
# gate that proves "image actually boots inside a container", and
# is the leftmost gate in the promotion chain to staging
# (Trivy / Cosign / ECR push / ArgoCD sync land downstream in
# Slices 4a-3 and Phase 4b). Skipping this would make Camp B
# untenable.
#
# Verifies, in order:
# 1. image loads into runner Docker (validates rules_oci output)
# 2. container starts as nonroot under read-only rootfs
# (validates ADR-0025 runtime posture, not just BUILD claims)
# 3. /healthz returns 200 within 10s (validates the static-linked
# gateway binary actually executes inside distroless — catches
# missing /etc/passwd, DNS, certs, etc.)
# 4. container is removed cleanly (no leak into next CI run)
- name: Smoke-test gateway OCI image (boot + healthz + read-only rootfs)
run: |
set -euxo pipefail
# The image_load target's repo_tags are defined in
# packaging/gateway/BUILD.bazel as "aegis-core-gateway:dev-local";
# `bazel run` does NOT accept a tag override as a positional
# argument (oci_load reads repo_tags from the BUILD attribute,
# not from argv). Aligning the docker tag below.
./tools/bazelisk/bazelisk run //packaging/gateway:image_load
docker run -d --name gw \
--read-only \
--user 65532:65532 \
-p 18080:8080 \
aegis-core-gateway:dev-local
# Wait up to 10s for /healthz to return 200; the gateway tries
# to dial the C++ engine on startup, which fails (engine isn't
# in this CI step), but per main.go:440-443 /healthz still
# returns 200 with engine.reachable=false. That's the expected
# boot-up shape and what we're validating here.
for i in 1 2 3 4 5 6 7 8 9 10; do
if curl -sf http://localhost:18080/healthz > /tmp/healthz.json; then
echo "healthz returned 200 after ${i}s"
cat /tmp/healthz.json
break
fi
sleep 1
if [ "$i" = "10" ]; then
echo "ERROR: healthz never returned 200 within 10s"
docker logs gw
exit 1
fi
done
docker logs gw
docker stop gw
docker rm gw
# Phase 4a Slice 2 — SBOM (CycloneDX) for the gateway image, per
# ADR-0025 §"Sequencing across slices" and ARCHITECTURE.md §10.1
# ("every release artifact ... produces a CycloneDX or SPDX SBOM
# via Syft"). Generated against the docker image that the smoke
# step just verified — `aegis-core-gateway:dev-local` is still loaded
# in the runner's docker daemon (only the container was removed,
# the image stays). Pin by commit SHA, not @v0, for reproducibility
# — bumping syft is a 1-line PR with the new SHA.
#
# Output is a workflow artifact `gateway-sbom-cyclonedx` retained
# 90 days; Slice 4a-3 (ECR push) will pull it via download-artifact
# to attach alongside the image; Phase 4b will sign it as a
# Cosign attestation (anchore/sbom-action supports `cosign-*`
# inputs natively at that point).
- name: Generate gateway image SBOM (CycloneDX)
# anchore/sbom-action v0.24.0 (released 2026-03-20)
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610
with:
image: aegis-core-gateway:dev-local
format: cyclonedx-json
artifact-name: gateway.sbom.cdx.json
output-file: gateway.sbom.cdx.json
upload-artifact: true
upload-release-assets: false
# ADR-0021 P3 — shared ggml runtime drift check.
#
# Layer 1: a cheap grep of GGML_VERSION_* from each archive's
# ggml/CMakeLists.txt. Catches the easy case (someone bumped one of
# the triple without the others) and the dangerous-direction case
# (standalone @ggml older than a consumer's bundled ggml, which
# will cause undefined-symbol link failures).
#
# Layer 2: build (not run) the integration test targets. These
# link whisper + llama + shared ggml into a single binary, so the
# link step fails if the three archives' ggml symbol tables don't
# align — catches the "same version string, divergent source"
# drift that Layer 1 by itself cannot detect (incident-10).
- name: ggml version-string drift check
run: ./tools/scripts/check_ggml_versions.sh
- name: Build integration tests (shared ggml link check)
run: |
./tools/bazelisk/bazelisk build \
//engine_cpp/tests/integration/...
- name: Run unit tests (excludes requires-model)
run: |
./tools/bazelisk/bazelisk test \
//engine_cpp/tests/unit/... \
--test_tag_filters=-requires-model
# ---------------------------------------------------------------------------
# ADR-0002 Phase 3 Web Frontend compliance — Tauri-wrap compatibility.
# Greps frontend_web/src/ for the six forbidden patterns from ADR-0002's
# "Constraints on the Phase 3 Web Frontend" section. Runs in < 1s; fails
# the PR if a chrome.* / Service Worker / SharedArrayBuffer / out-of-
# provider notification / Blob-download / navigator.mediaDevices slip-
# through appears. Protects the Phase 4+ Tauri shell migration path.
# ---------------------------------------------------------------------------
frontend-tauri-compliance:
name: Frontend Tauri-compatibility check
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Run ADR-0002 Phase 3 compliance grep
run: ./tools/scripts/check_frontend_tauri_compliance.sh
# ---------------------------------------------------------------------------
# Live-browser smoke (Playwright) — Phase 3c Slice 6. Runs the frontend
# in real chromium + webkit engines via Playwright so UI-level
# regressions cannot hide behind jsdom approximations. Rooted in
# Incident 09's lesson: loopback/mock tests silently passed while
# every real-browser Opus frame failed to decode.
#
# Browsers are kept out of actions/cache at the global user dir;
# PLAYWRIGHT_BROWSERS_PATH is pinned to a repo-local path matching
# tools/scripts/frontend.sh so the cache key can live in-repo.
# ---------------------------------------------------------------------------
frontend-e2e-smoke:
name: Frontend live-browser smoke (Playwright)
runs-on: ubuntu-latest
timeout-minutes: 15
env:
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Cache Playwright browsers
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: ${{ github.workspace }}/.playwright-browsers
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
playwright-${{ runner.os }}-
- name: Cache Bazel (Node toolchain)
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: |
${{ github.workspace }}/.bazel_cache
${{ github.workspace }}/.bazelisk
key: bazel-e2e-${{ runner.os }}-${{ hashFiles('MODULE.bazel', 'MODULE.bazel.lock', '.bazelversion') }}
restore-keys: |
bazel-e2e-${{ runner.os }}-
- name: Install frontend deps (hermetic pnpm)
run: ./tools/scripts/frontend.sh install
- name: Install Playwright browsers
run: ./tools/scripts/frontend.sh e2e:install
- name: Run Playwright smoke
run: ./tools/scripts/frontend.sh e2e
- name: Upload Playwright report on failure
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: playwright-report
path: frontend_web/playwright-report/
retention-days: 7
# ---------------------------------------------------------------------------
# Documentation link check — basic sanity for internal cross-references.
# Uses a lightweight action; does not fail the build on external link
# timeouts (those are noisy).
# ---------------------------------------------------------------------------
markdown-link-check:
name: Markdown link check
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Check links in markdown
uses: gaurav-nelson/github-action-markdown-link-check@5c5dfc0ac2e225883c0e5f03a85311ec2830d368 # v1
with:
use-quiet-mode: "yes"
use-verbose-mode: "no"
check-modified-files-only: "yes"
base-branch: main
# Allowlist for GitHub / BuildBuddy settings URLs that 404
# anonymously by design (see config file header for rationale).
config-file: .github/workflows/mlc_config.json
# ---------------------------------------------------------------------------
# Phase 4b security scanners. Each is a distinct concern, parallelised:
#
# - govulncheck: Go module + stdlib CVE scan (golang.org/x/vuln)
# - gosec: Go source-level security lint (hardcoded creds,
# SQL injection patterns, weak crypto primitives)
# - semgrep: Polyglot SAST (Go + TS + bash + YAML); curated p/ci
# ruleset covers CWE Top 25 + framework-specific
# patterns ARCHITECTURE.md §10.2 calls for.
#
# The K8s-manifest scanners (Checkov + kube-score) that used to live
# here were removed when the deploy manifests moved out of this repo
# to `aegis-core-deploy` (ADR-0036) — there is no `apps/staging/` left
# for them to scan. Manifest scanning will be re-established in
# `aegis-core-deploy`'s own CI as a follow-up.
#
# All three remaining scanners are advisory on PR-time today
# (non-blocking `|| true` guard) to let us see the baseline signal
# before flipping to hard gates. Graduation criterion: a clean
# main-branch run of each scanner; at that point we remove the guard
# in a follow-up PR and the scanners become blocking. ARCHITECTURE.md
# §10.2 is the anchor.
# ---------------------------------------------------------------------------
go-vulncheck:
name: Go vulnerability scan (govulncheck)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: "1.25"
cache-dependency-path: gateway_go/go.sum
- name: Run govulncheck
working-directory: gateway_go
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
# Advisory-only on PR time; tighten to hard-gate once main is clean.
govulncheck ./... || true
go-security-scan:
name: Go security scan (gosec)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: "1.25"
cache-dependency-path: gateway_go/go.sum
- name: Run gosec
working-directory: gateway_go
run: |
go install github.com/securego/gosec/v2/cmd/gosec@latest
# Advisory-only on PR time; -exclude-dir suppresses generated
# protobuf code (G402 false positives on gRPC insecure creds
# are inevitable in LOCAL-mode test setup).
gosec -exclude-dir=gen ./... || true
semgrep:
name: Semgrep SAST scan
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.x"
- name: Run Semgrep
run: |
# `semgrep` Python package — pinning the major minor leaves
# patch-level fixes auto-pulled while preventing surprise
# major bumps that break the ruleset shape. Update line in
# the same PR as a deliberate ruleset graduation.
pip install --quiet 'semgrep~=1.95'
# `--config p/ci` is Semgrep's curated CI-suitable ruleset
# covering CWE Top 25 + framework-specific patterns across
# Go / TS / Python / bash. Multi-language pass in one run.
# `--metrics off` skips Semgrep Cloud telemetry per the
# Phase 0 governance posture (don't phone home unless we
# made an explicit choice).
# `--error` makes findings exit non-zero; `|| true` keeps
# the job advisory on PR-time per the Phase 4b graduation
# discipline noted in the comment block above.
semgrep scan --config p/ci --error --metrics off