diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..b7f9b0a --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "attribution": { + "commit": "", + "pr": "", + "sessionUrl": false + } +} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..054cbb4 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,29 @@ +name: tests + +# Pin the capability wrappers' contract: each action.yml stays a thin composite +# action that runs the right `rac` command, uploads SARIF, and re-surfaces the +# CLI exit code. Structural only (parses YAML) — no engine install needed. +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + action-contracts: + name: action contracts (pytest) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install test deps + run: python -m pip install --quiet --upgrade pip pytest pyyaml + + - name: Run action-contract tests + run: python -m pytest tests/ -q diff --git a/.github/workflows/watchkeeper.yml b/.github/workflows/watchkeeper.yml new file mode 100644 index 0000000..ae78fc8 --- /dev/null +++ b/.github/workflows/watchkeeper.yml @@ -0,0 +1,69 @@ +# Reusable Watchkeeper workflow (v0.12.3). +# +# Consumers call this with `uses:` to get product knowledge review on pull +# requests without writing checkout/base-ref wiring themselves: +# +# jobs: +# watchkeeper: +# uses: itsthelore/rac-ci/.github/workflows/watchkeeper.yml@ +# with: +# path: rac +# +# Pin an exact release tag. No moving major tag is published: package +# versions derive from git tags via setuptools-scm, and a floating tag +# would corrupt version derivation (see docs/watchkeeper.md). +name: Watchkeeper + +on: + workflow_call: + inputs: + path: + description: "Corpus directory to compare." + type: string + required: false + default: "rac" + base: + description: "Base revision (empty: origin/)." + type: string + required: false + default: "" + fail-on: + description: "Failure policy: error, warning, or none." + type: string + required: false + default: "error" + annotate: + description: "Emit inline annotations." + type: boolean + required: false + default: true + rac-version: + description: "Exact rac-core version from PyPI (empty: latest)." + type: string + required: false + default: "" + +permissions: + contents: read + +jobs: + watchkeeper: + name: watchkeeper + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + # The comparison needs the base revision's history, and a + # source-mode install needs git metadata for setuptools-scm. + fetch-depth: 0 + + # Reusable workflows run in the caller's repository, so the action is + # referenced by full path. Update this ref when cutting a release tag; + # until the first rac-ci release it tracks main. + - uses: itsthelore/rac-ci/watchkeeper/github@main + with: + path: ${{ inputs.path }} + base: ${{ inputs.base }} + fail-on: ${{ inputs.fail-on }} + annotate: ${{ inputs.annotate }} + rac-version: ${{ inputs.rac-version }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..75c6182 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +.pytest_cache/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..a78eab8 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# rac-ci + +The CI delivery surface for [RAC](https://github.com/itsthelore/rac-core) +(requirements-as-code) — one subdir per **capability**, with delivery platforms +nested inside (`github/` first). Per ADR-092 (one repo per concern, subdir per +member) this consolidates the CI wrappers that previously lived in `rac-core` and +in the standalone `rac-actions` / `lore-watchkeeper` / `lore-gatekeeper` repos. + +Every capability is a **thin wrapper over the public `rac` CLI** (ADR-063); all +analysis and policy live in the engine package. The wrappers install the +published `rac-core` from PyPI (pin with the `rac-version` input). + +## Capabilities + +| Capability | Subdir | Wraps | Consumed as | +| --- | --- | --- | --- | +| Watchkeeper | [`watchkeeper/github/`](watchkeeper/github/) | `rac watchkeeper` (PR knowledge review) | `uses: itsthelore/rac-ci/watchkeeper/github@` | +| Gatekeeper | [`gatekeeper/github/`](gatekeeper/github/) | `rac gate --sarif` (required merge gate) | `uses: itsthelore/rac-ci/gatekeeper/github@` | +| Registrar | [`registrar/github/`](registrar/github/) | `rac validate --sarif` (well-formedness, ADR-058) | `uses: itsthelore/rac-ci/registrar/github@` | +| Recordkeeper | [`recordkeeper/`](recordkeeper/) | read-access audit recorder (ADR-084) | *placeholder — not yet shipped* | + +A reusable Watchkeeper workflow is also published at +[`.github/workflows/watchkeeper.yml`](.github/workflows/watchkeeper.yml) +(`uses: itsthelore/rac-ci/.github/workflows/watchkeeper.yml@`). + +`bitbucket/` and `jenkins/` platform wrappers join under each capability when +demanded; the engine is already platform-neutral (SARIF/JSON), so that work is in +the wrappers, not the engine. + +## History + +The `watchkeeper/`, `gatekeeper/`, and `registrar/` wrappers moved here from +`rac-core` with history preserved (ADR-092 convergence). Consumers pinned to the +old `itsthelore/rac-core@`, `…/pr-gate-action@v0`, or `…/validate-action@v0` +paths keep resolving on those tags; new consumers use the `rac-ci` paths above. diff --git a/gatekeeper/github/action.yml b/gatekeeper/github/action.yml new file mode 100644 index 0000000..62922f0 --- /dev/null +++ b/gatekeeper/github/action.yml @@ -0,0 +1,107 @@ +# RAC PR-gate composite action (v0.21.14, ADR-049 / ADR-063). +# +# Carries the full RAC contract into a single required pull-request check: +# install RAC and run `rac gate` — one command that enforces validation, +# relationship integrity, and review under the corpus enforcement policy +# (`.rac/config.yaml`), emitting one SARIF document (ADR-054). The single SARIF +# is uploaded to GitHub Code Scanning under one category (`rac-gate`), and the +# CLI exit code is re-surfaced. The action is a thin consumer (ADR-063): all +# analysis and enforcement policy live in the package (ADR-015 / ADR-049); the +# action never reinterprets findings or decides what is blocking. +# +# The Watchkeeper action lives at `watchkeeper/github/` and the validate +# (Registrar) action at `registrar/github/`; this gate is referenced as +# `uses: itsthelore/rac-ci/gatekeeper/github@`. +name: "RAC PR gate" +description: >- + Enforce a requirements-as-code (RAC) corpus on a pull request with a single + `rac gate` command — validation, relationship integrity, and review under the + corpus enforcement policy — surfaced inline via GitHub Code Scanning (SARIF) as + a required status check. A thin wrapper over the `rac` CLI. +author: "Tom Ballard" + +branding: + icon: "shield" + color: "purple" + +inputs: + path: + description: "The RAC corpus directory to enforce (passed to `rac gate`)." + required: false + default: "rac" + upload-sarif: + description: >- + Upload SARIF to GitHub Code Scanning (`true` or `false`). Requires the job + to grant `security-events: write`. + required: false + default: "true" + sarif-dir: + description: "Directory the single `gate.sarif` document is written to." + required: false + default: "rac-sarif" + rac-version: + description: >- + Exact rac-core version to install from PyPI. Empty installs the + latest release. + required: false + default: "" + +runs: + using: "composite" + steps: + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install RAC + shell: bash + env: + RAC_VERSION: ${{ inputs.rac-version }} + run: | + python -m pip install --quiet --upgrade pip + if [ -n "$RAC_VERSION" ]; then + python -m pip install --quiet "rac-core==$RAC_VERSION" + else + python -m pip install --quiet rac-core + fi + + # The CLI is the source of truth (ADR-058). `set +e` lets a non-zero exit + # still produce SARIF for upload; the exit code is re-surfaced below. One + # command, one SARIF document — `rac gate` already composes validation, + # relationships, and review under the corpus enforcement policy. + - name: Run RAC gate (SARIF) + id: gate + shell: bash + env: + INPUT_PATH: ${{ inputs.path }} + SARIF_DIR: ${{ inputs.sarif-dir }} + run: | + set +e + mkdir -p "$SARIF_DIR" + rac gate "$INPUT_PATH" --sarif > "$SARIF_DIR/gate.sarif" + code=$? + echo "gate exited $code" + echo "exit_code=$code" >> "$GITHUB_OUTPUT" + + # A single SARIF document uploaded under one Code Scanning category. The gate + # is one tool ("rac") producing one run, so one categorised analysis suffices. + - name: Upload gate SARIF + if: ${{ always() && inputs.upload-sarif == 'true' }} + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: ${{ inputs.sarif-dir }}/gate.sarif + category: rac-gate + + # Any non-zero CLI exit fails the check; the Code Scanning annotations show + # every finding, blocking and advisory. What is blocking is decided by the + # engine under the corpus enforcement policy (ADR-049), not here. + - name: Report result + shell: bash + env: + EXIT_CODE: ${{ steps.gate.outputs.exit_code }} + run: | + if [ "$EXIT_CODE" != "0" ]; then + echo "::error::RAC PR gate failed (exit $EXIT_CODE) — see the Code Scanning annotations." + exit "$EXIT_CODE" + fi + echo "RAC PR gate passed." diff --git a/recordkeeper/README.md b/recordkeeper/README.md new file mode 100644 index 0000000..1ecb692 --- /dev/null +++ b/recordkeeper/README.md @@ -0,0 +1,7 @@ +# recordkeeper (placeholder) + +Reserved for the **read-access audit recorder** (ADR-084) — a CI capability that +records who read which artifacts. No wrapper ships yet; this directory holds the +capability's place in the `rac-ci` layout so it joins as `recordkeeper/github/` +(and other platforms) when the recorder lands, without re-litigating repository +structure. diff --git a/registrar/github/action.yml b/registrar/github/action.yml new file mode 100644 index 0000000..da02b3d --- /dev/null +++ b/registrar/github/action.yml @@ -0,0 +1,90 @@ +# RAC validate composite action (v0.17.2, ADR-058). +# +# A thin wrapper: install RAC, run one `rac validate --sarif`, upload the SARIF +# to GitHub Code Scanning, and re-surface the CLI exit code. All analysis and +# severity policy live in the package (ADR-015 / ADR-053); the action never +# reinterprets findings. The Watchkeeper action lives at `watchkeeper/github/`; +# this Registrar (validate) action is referenced as +# `uses: itsthelore/rac-ci/registrar/github@`. +name: "RAC Registrar" +description: >- + Validate a requirements-as-code (RAC) corpus and surface findings on the pull + request via GitHub Code Scanning (SARIF). A thin wrapper over the `rac` CLI. +author: "Tom Ballard" + +branding: + icon: "check-circle" + color: "purple" + +inputs: + path: + description: "The RAC corpus directory to validate (passed to `rac validate`)." + required: false + default: "rac" + upload-sarif: + description: >- + Upload SARIF to GitHub Code Scanning (`true` or `false`). Requires the job + to grant `security-events: write`. + required: false + default: "true" + sarif-file: + description: "Where the SARIF document is written." + required: false + default: "rac.sarif" + rac-version: + description: >- + Exact rac-core version to install from PyPI. Empty installs the + latest release. + required: false + default: "" + +runs: + using: "composite" + steps: + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install RAC + shell: bash + env: + RAC_VERSION: ${{ inputs.rac-version }} + run: | + python -m pip install --quiet --upgrade pip + if [ -n "$RAC_VERSION" ]; then + python -m pip install --quiet "rac-core==$RAC_VERSION" + else + python -m pip install --quiet rac-core + fi + + # The CLI is the source of truth (ADR-058). `set +e` lets a non-zero exit + # still produce SARIF for upload; the exit code is re-surfaced below. + - name: Run rac validate (SARIF) + id: validate + shell: bash + env: + INPUT_PATH: ${{ inputs.path }} + SARIF_FILE: ${{ inputs.sarif-file }} + run: | + set +e + rac validate "$INPUT_PATH" --sarif > "$SARIF_FILE" + echo "exit_code=$?" >> "$GITHUB_OUTPUT" + + - name: Upload SARIF to Code Scanning + if: ${{ always() && inputs.upload-sarif == 'true' }} + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: ${{ inputs.sarif-file }} + + # Errors fail the check; warnings (including findings downgraded in + # .rac/config.yaml, ADR-053) annotate without failing — warnings-first. + - name: Report result + shell: bash + env: + EXIT_CODE: ${{ steps.validate.outputs.exit_code }} + run: | + if [ "$EXIT_CODE" != "0" ]; then + echo "::error::rac validate exited $EXIT_CODE — see the Code Scanning annotations." + exit "$EXIT_CODE" + fi + echo "rac validate passed." diff --git a/tests/test_pr_gate_action.py b/tests/test_pr_gate_action.py new file mode 100644 index 0000000..3aa26ea --- /dev/null +++ b/tests/test_pr_gate_action.py @@ -0,0 +1,67 @@ +"""Structural tests for the RAC PR-gate composite action (v0.21.14). + +The action is a thin wrapper that runs a single `rac gate --sarif` — one +command that composes validation, relationship integrity, and review under the +corpus enforcement policy — uploads the single SARIF document, and re-surfaces +the CLI exit code. Its analysis is owned by the (separately tested) CLI; these +tests pin the action's *contract* so the wiring cannot silently drift (ADR-063: +the action computes nothing). +""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +ACTION = Path(__file__).parent.parent / "gatekeeper" / "github" / "action.yml" + + +def _action() -> dict: + return yaml.safe_load(ACTION.read_text(encoding="utf-8")) + + +def test_action_is_composite(): + a = _action() + assert a["runs"]["using"] == "composite" + assert a["name"] == "RAC PR gate" + + +def test_action_declares_expected_inputs(): + inputs = _action()["inputs"] + for name in ("path", "upload-sarif", "sarif-dir", "rac-version"): + assert name in inputs, f"missing input: {name}" + assert inputs["path"]["default"] == "rac" + assert inputs["upload-sarif"]["default"] == "true" + + +def test_action_runs_single_gate_command(): + run_steps = " ".join(s.get("run", "") for s in _action()["runs"]["steps"]) + assert "rac gate" in run_steps + assert "--sarif" in run_steps + # The three separate checks are now collapsed into the one gate command. + assert "rac validate" not in run_steps + assert "rac relationships" not in run_steps + assert "rac review" not in run_steps + + +def test_action_uploads_single_sarif_once(): + steps = _action()["runs"]["steps"] + uploads = [s for s in steps if "upload-sarif" in str(s.get("uses", ""))] + assert len(uploads) == 1, "the gate uploads exactly one SARIF document" + # Upload even on failure so findings still annotate the PR. + assert "always()" in uploads[0]["if"] + assert uploads[0]["with"]["category"] == "rac-gate" + + +def test_action_resurfaces_exit_code(): + run_steps = " ".join(s.get("run", "") for s in _action()["runs"]["steps"]) + assert 'exit "$EXIT_CODE"' in run_steps + + +def test_action_installs_published_rac_core(): + # The action installs the published rac-core (pinned via rac-version, else + # latest); source-install dogfood lives in rac-core's own CI, not here. + run_steps = " ".join(s.get("run", "") for s in _action()["runs"]["steps"]) + assert "pip install" in run_steps + assert "rac-core" in run_steps diff --git a/tests/test_validate_action.py b/tests/test_validate_action.py new file mode 100644 index 0000000..ccf1488 --- /dev/null +++ b/tests/test_validate_action.py @@ -0,0 +1,62 @@ +"""Structural tests for the Registrar (validate) composite action (ADR-058). + +The action is a thin wrapper over `rac validate --sarif`; its behaviour is owned +by the (separately tested) CLI. These tests pin the action's *contract* — that it +stays a composite action that runs `rac validate --sarif`, uploads SARIF, and +re-surfaces the CLI exit code — so the wiring cannot silently drift. +""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +ACTION = Path(__file__).parent.parent / "registrar" / "github" / "action.yml" + + +def _action() -> dict: + return yaml.safe_load(ACTION.read_text(encoding="utf-8")) + + +def test_action_is_composite(): + a = _action() + assert a["runs"]["using"] == "composite" + assert a["name"] == "RAC Registrar" + + +def test_action_declares_expected_inputs(): + inputs = _action()["inputs"] + for name in ("path", "upload-sarif", "sarif-file", "rac-version"): + assert name in inputs, f"missing input: {name}" + assert inputs["path"]["default"] == "rac" + assert inputs["upload-sarif"]["default"] == "true" + + +def test_action_runs_rac_validate_sarif(): + steps = _action()["runs"]["steps"] + run_steps = " ".join(s.get("run", "") for s in steps) + assert "rac validate" in run_steps + assert "--sarif" in run_steps + + +def test_action_uploads_sarif(): + steps = _action()["runs"]["steps"] + uploads = [s for s in steps if "upload-sarif" in str(s.get("uses", ""))] + assert uploads, "no SARIF upload step" + # Upload even on failure so findings still annotate the PR. + assert "always()" in uploads[0]["if"] + + +def test_action_resurfaces_exit_code(): + steps = _action()["runs"]["steps"] + run_steps = " ".join(s.get("run", "") for s in steps) + assert 'exit "$EXIT_CODE"' in run_steps + + +def test_action_installs_published_rac_core(): + # The action installs the published rac-core (pinned via rac-version, else + # latest); source-install dogfood lives in rac-core's own CI, not here. + run_steps = " ".join(s.get("run", "") for s in _action()["runs"]["steps"]) + assert "pip install" in run_steps + assert "rac-core" in run_steps diff --git a/watchkeeper/github/action.yml b/watchkeeper/github/action.yml new file mode 100644 index 0000000..a80bb6f --- /dev/null +++ b/watchkeeper/github/action.yml @@ -0,0 +1,110 @@ +# RAC Watchkeeper composite action (v0.12.3). +# +# A thin wrapper: install RAC, resolve the base revision, run one +# `rac watchkeeper --format github` invocation, and route its outputs — +# stdout to the step summary, stderr (workflow-command annotations) to the +# step log. All analysis, rendering, and failure policy live in the Python +# package (ADR-015); the action never reinterprets exit codes. +name: "RAC Watchkeeper" +description: >- + Review product knowledge changes (requirements, decisions, roadmaps, + designs, prompts) on pull requests: changed artifacts, validation and + relationship deltas, deterministic intent findings, and a review verdict. +author: "Tom Ballard" + +branding: + icon: "eye" + color: "purple" + +inputs: + path: + description: >- + Corpus directory to compare (passed to `rac watchkeeper`). + required: false + default: "rac" + base: + description: >- + Base state: a git revision or a directory. Empty (the default) + resolves to `origin/`. + required: false + default: "" + fail-on: + description: >- + Failure policy forwarded to `rac watchkeeper --fail-on`: + `error` (fail when review is recommended), `warning` (also fail on + any warning finding), or `none` (never fail, still report). + required: false + default: "error" + annotate: + description: >- + Emit inline annotations via workflow commands (`true` or `false`). + required: false + default: "true" + rac-version: + description: >- + Exact rac-core version to install from PyPI. Empty installs the + latest release. + required: false + default: "" + +runs: + using: "composite" + steps: + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install RAC + shell: bash + env: + RAC_VERSION: ${{ inputs.rac-version }} + run: | + python -m pip install --quiet --upgrade pip + if [ -n "$RAC_VERSION" ]; then + python -m pip install --quiet "rac-core==$RAC_VERSION" + else + python -m pip install --quiet rac-core + fi + + - name: Resolve base revision + shell: bash + env: + INPUT_BASE: ${{ inputs.base }} + PR_BASE_REF: ${{ github.base_ref }} + run: | + BASE="$INPUT_BASE" + if [ -z "$BASE" ]; then + BASE="origin/$PR_BASE_REF" + fi + # Make sure the base ref exists locally; harmless when it already + # does, best-effort when the base is a directory or a plain SHA + # (rac fails with exit 2 and a clear message if it cannot resolve). + case "$BASE" in + origin/*) + REF="${BASE#origin/}" + git fetch --quiet --no-tags origin \ + "+refs/heads/$REF:refs/remotes/origin/$REF" || true + ;; + esac + echo "WATCHKEEPER_BASE=$BASE" >> "$GITHUB_ENV" + + - name: Run watchkeeper + shell: bash + env: + INPUT_PATH: ${{ inputs.path }} + FAIL_ON: ${{ inputs.fail-on }} + ANNOTATE: ${{ inputs.annotate }} + run: | + ANNOTATE_FLAG="" + if [ "$ANNOTATE" != "true" ]; then + ANNOTATE_FLAG="--no-annotate" + fi + # stdout becomes the job's step summary; stderr stays in the live + # step log where the runner parses the annotation commands. The + # exit code propagates unchanged — policy lives in --fail-on. + rac watchkeeper "$INPUT_PATH" \ + --base "$WATCHKEEPER_BASE" \ + --format github \ + --fail-on "$FAIL_ON" \ + $ANNOTATE_FLAG \ + > "$GITHUB_STEP_SUMMARY"