Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
270 changes: 270 additions & 0 deletions .github/workflows/compliance.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
# Pulsar compliance gates -- étage-0 merge-gate conformance.
#
# These are the conformance checks required by the org-wide merge gate
# (docs/rules/git.md §1 + docs/rules/security.md §Détection). The build
# pipeline (pipeline.yml) proves pulsar.exe builds and broadcasts; this
# workflow proves the change is mergeable per policy: no leaked secret,
# no high/critical dependency CVE, a lockfile that is in sync, and a
# valid CODEOWNERS.
#
# Kept in a SEPARATE workflow from pipeline.yml on purpose:
# - different concern (governance vs build), different runners (all
# ubuntu, cheap + fast -- no MSVC, no Windows),
# - independent failure surface : a red secret-scan must not be tangled
# with the C++ build graph, and vice versa. Each job is its own check
# in `gh pr checks` so the reviewer sees exactly which gate broke.
#
# HARD RULE : no error-suppression anywhere (the step-skip toggle is
# banned per docs/rules/git.md). Every job here is allowed -- and intended
# -- to turn the PR red and block the merge.
#
# Trigger parity with pipeline.yml : pull_request to main fires once per
# push (the dedup reason pipeline.yml documents), plus push to main and
# tags so post-merge / release refs are re-checked. paths-ignore mirrors
# pipeline.yml: a docs-only / changelog / licence change can't introduce
# a secret-in-code, a dep CVE, a lockfile drift or a CODEOWNERS break.

name: compliance

on:
push:
branches: [main]
tags:
- 'v*.*.*'
paths-ignore:
- '**/*.md'
- 'docs/**'
- 'CHANGELOG.md'
- '.gitignore'
- 'LICENSE'
pull_request:
branches: [main]
paths-ignore:
- '**/*.md'
- 'docs/**'
- 'CHANGELOG.md'
- '.gitignore'
- 'LICENSE'

concurrency:
group: compliance-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:

# ── secret-scan : trufflehog (filesystem + git history) + detect-secrets ──
# Two complementary scanners :
# - trufflehog scans the full git history of the diff range (PR : base
# ..head ; push : the pushed range) AND the working tree, with
# --only-verified so a finding is a credential that actually
# authenticates somewhere -- not an entropy false-positive.
# - detect-secrets audits the working tree against .secrets.baseline.
# Any NEW finding not already in the baseline fails the job; this is
# the entropy/keyword net trufflehog's verified-only mode skips.
# Either scanner flagging a real/new secret turns the job red. A leaked
# secret => rotate + purge (security.md), never just revert.
secret-scan:
name: secret-scan
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout (full history)
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: TruffleHog (history + filesystem, verified secrets)
uses: trufflesecurity/trufflehog@main
with:
# PR : scan base..head. push : the action infers the pushed
# range from the event. Defaulting both to the repo path also
# scans the checked-out working tree.
path: ./
base: ${{ github.event.pull_request.base.sha || github.event.before }}
head: ${{ github.event.pull_request.head.sha || github.sha }}
extra_args: --only-verified

- name: Setup Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'

- name: Install detect-secrets
run: python -m pip install --upgrade "detect-secrets==1.5.0"

- name: detect-secrets audit against baseline
# Re-scan the tree against the committed baseline, then compare ONLY
# the `results` block (the actual findings) -- NOT the whole file --
# against what is committed. `scan --baseline` rewrites the file in
# place and always refreshes the volatile `generated_at` timestamp
# (and may reorder plugin metadata), so a raw `git diff` on the file
# would fail on the timestamp alone with zero new secrets. By diffing
# the `results` map alone we ignore that noise and fail only when a
# finding appears that is not already in the audited baseline -- i.e.
# a genuinely new secret-shaped string. To add a legitimate new
# allowlist entry: `detect-secrets scan > .secrets.baseline`, audit,
# commit. A real secret => ROTATE + purge per security.md.
run: |
set -euo pipefail
# Snapshot the committed baseline OUTSIDE the repo tree so the
# re-scan below doesn't pick the copy up as a new file.
cp .secrets.baseline "${RUNNER_TEMP}/baseline.committed.json"
detect-secrets scan --baseline .secrets.baseline
python - <<PY
import json, os, sys
def results(path):
with open(path) as f:
return json.load(f).get("results", {})
before = results(os.path.join(os.environ["RUNNER_TEMP"], "baseline.committed.json"))
after = results(".secrets.baseline")
if before != after:
print("::error::detect-secrets found findings not present in the audited .secrets.baseline.")
before_keys = {(fn, x["hashed_secret"]) for fn, lst in before.items() for x in lst}
after_keys = {(fn, x["hashed_secret"]) for fn, lst in after.items() for x in lst}
for fn, h in sorted(after_keys - before_keys):
print(f"::error::NEW finding in {fn} (hash {h[:12]}...). Review: real secret => rotate+purge; false positive => re-baseline.")
for fn, h in sorted(before_keys - after_keys):
print(f"::warning::baseline finding no longer present in {fn} (hash {h[:12]}...). Re-baseline to prune.")
sys.exit(1)
print("detect-secrets: no findings beyond the audited baseline.")
PY

# ── deps-audit : npm high/critical CVE gate ──────────────────────────
# Dependency scope = npm. The repo's runtime deps are the @clodocapeo/
# pulsar-* JS packages (package.json + package-lock.json). The Python
# side is dev-only probe glue (`websockets`), not a shipped/pinned
# dependency surface, so there is no requirements lock to pip-audit --
# the deps gate that matters for what ships is npm. --omit=dev so the
# gate reflects what consumers actually install (dev-only advisories,
# e.g. the vitest test toolchain, do not block a merge). --audit-level
# =high : moderate/low advise but do not block; high+critical block.
deps-audit:
name: deps-audit
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'

- name: npm audit (production deps, high+critical block)
run: npm audit --omit=dev --audit-level=high

# ── lockfile-check : package-lock.json is in sync + committed ────────
# `npm ci` refuses to run if package.json and package-lock.json are out
# of sync, so `npm ci --dry-run` is the canonical "lockfile drift"
# detector -- it errors on any divergence or unpinned dependency without
# touching node_modules. We then assert the working tree is still clean
# so a lockfile that gets rewritten on install (e.g. a non-deterministic
# or stale lock) is caught as an uncommitted change.
lockfile-check:
name: lockfile-check
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'

- name: Assert no stray yarn.lock (npm is the lock of record)
run: |
set -euo pipefail
if [ -f yarn.lock ]; then
echo "::error::yarn.lock present but Pulsar uses npm (package-lock.json) as the lock of record. Remove yarn.lock or migrate intentionally."
exit 1
fi
echo "no yarn.lock -- npm is the sole lockfile."

- name: npm ci --dry-run (lockfile in sync, deps pinned)
# --force : @clodocapeo/pulsar-bundle declares os:["win32"]
# cpu:["x64"], so on the ubuntu runner npm aborts with
# EBADPLATFORM before it ever checks lockfile sync. --force
# bypasses the os/cpu gate (same reason npm-publish in
# pipeline.yml uses it) WITHOUT weakening the sync check: a real
# lockfile drift surfaces as a distinct EUSAGE/"can only install
# with an up to date package-lock" error, not EBADPLATFORM.
env:
PULSAR_BUNDLE_SKIP_POSTINSTALL: '1'
run: npm ci --dry-run --force

- name: Assert lockfile unchanged after resolution
run: |
set -euo pipefail
if ! git diff --quiet -- package-lock.json; then
echo "::error::package-lock.json drifted during resolution -- regenerate with 'npm install' and commit."
git --no-pager diff -- package-lock.json
exit 1
fi
echo "package-lock.json is in sync and committed."

# ── codeowners-check : CODEOWNERS exists and is syntactically valid ──
# A merge gate that relies on CODEOWNERS for review routing is only as
# good as the file being present + parseable. We assert it exists, then
# lint it with the same parser semantics GitHub uses (pattern + at least
# one @owner / team / email per non-comment line). No external network
# call -- the validation is purely structural.
codeowners-check:
name: codeowners-check
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Locate + validate CODEOWNERS
run: |
set -euo pipefail
file=""
for cand in CODEOWNERS .github/CODEOWNERS docs/CODEOWNERS; do
if [ -f "$cand" ]; then file="$cand"; break; fi
done
if [ -z "$file" ]; then
echo "::error::No CODEOWNERS found (looked in /, /.github, /docs)."
exit 1
fi
echo "Validating $file"
# disable pathname expansion : CODEOWNERS patterns like '*' or
# '/plugins/*' must be treated literally, not glob-expanded
# against the runner's working tree by the unquoted `set --`.
set -f
fail=0
lineno=0
while IFS= read -r line || [ -n "$line" ]; do
lineno=$((lineno+1))
# strip leading/trailing whitespace
trimmed="$(echo "$line" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
# skip blank + comment lines
[ -z "$trimmed" ] && continue
case "$trimmed" in \#*) continue;; esac
# a rule line is: <pattern> <owner> [<owner>...]
# every token after the first must be @user, @org/team, or an email
set -- $trimmed
pattern="$1"; shift
if [ "$#" -eq 0 ]; then
echo "::error::$file:$lineno: rule '$pattern' has no owner."
fail=1; continue
fi
for owner in "$@"; do
case "$owner" in
@*/*) : ;; # @org/team
@*) : ;; # @user
*@*.*) : ;; # email
*)
echo "::error::$file:$lineno: invalid owner token '$owner' (expected @user, @org/team, or email)."
fail=1 ;;
esac
done
done < "$file"
[ "$fail" -eq 0 ] || exit 1
echo "CODEOWNERS is syntactically valid."
Loading
Loading