From 1f230faae2a40f153e68eddb8f98a521dd31ca1b Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sat, 27 Jun 2026 16:55:27 +0000 Subject: [PATCH 1/3] feat(init): expose rac init --profile and report written files [roadmap:v0.22.0] Implements rac/decisions/adr-088-enterprise-profile-scaffold.md. Adds --profile {default,enterprise} to rac init (unknown value exits 2), and reports the applied profile and each written file in human and JSON output. --- .github/workflows/watchkeeper.yml | 69 +++++++++++++++++ gatekeeper/github/action.yml | 117 ++++++++++++++++++++++++++++ registrar/github/action.yml | 100 ++++++++++++++++++++++++ tests/test_pr_gate_action.py | 65 ++++++++++++++++ tests/test_validate_action.py | 60 +++++++++++++++ watchkeeper/github/action.yml | 122 ++++++++++++++++++++++++++++++ 6 files changed, 533 insertions(+) create mode 100644 .github/workflows/watchkeeper.yml create mode 100644 gatekeeper/github/action.yml create mode 100644 registrar/github/action.yml create mode 100644 tests/test_pr_gate_action.py create mode 100644 tests/test_validate_action.py create mode 100644 watchkeeper/github/action.yml diff --git a/.github/workflows/watchkeeper.yml b/.github/workflows/watchkeeper.yml new file mode 100644 index 0000000..7d9fe57 --- /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-core/.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 requirements-as-code 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 v0.12.x release it tracks main. + - uses: itsthelore/rac-core@main + with: + path: ${{ inputs.path }} + base: ${{ inputs.base }} + fail-on: ${{ inputs.fail-on }} + annotate: ${{ inputs.annotate }} + rac-version: ${{ inputs.rac-version }} diff --git a/gatekeeper/github/action.yml b/gatekeeper/github/action.yml new file mode 100644 index 0000000..d8d2db6 --- /dev/null +++ b/gatekeeper/github/action.yml @@ -0,0 +1,117 @@ +# 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 the repository root and the single-command +# validate action at `validate-action/`; this gate is referenced as +# `uses: itsthelore/rac-core/pr-gate-action@`. +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 requirements-as-code version to install from PyPI. Empty installs the + latest release. Ignored when `install-from` is `source`. + required: false + default: "" + install-from: + description: >- + Where to install RAC from: `pypi` (the default for consumers) or `source` + (the action checkout itself — for this repository's own dogfood job; + requires `uses: ./pr-gate-action` on a full checkout). + required: false + default: "pypi" + +runs: + using: "composite" + steps: + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install RAC + shell: bash + env: + INSTALL_FROM: ${{ inputs.install-from }} + RAC_VERSION: ${{ inputs.rac-version }} + run: | + python -m pip install --quiet --upgrade pip + if [ "$INSTALL_FROM" = "source" ]; then + python -m pip install --quiet "$GITHUB_ACTION_PATH/.." + elif [ -n "$RAC_VERSION" ]; then + python -m pip install --quiet "requirements-as-code==$RAC_VERSION" + else + python -m pip install --quiet requirements-as-code + 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/registrar/github/action.yml b/registrar/github/action.yml new file mode 100644 index 0000000..efdda97 --- /dev/null +++ b/registrar/github/action.yml @@ -0,0 +1,100 @@ +# 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 the repository root; +# this validate action is referenced as +# `uses: itsthelore/rac-core/validate-action@`. +name: "RAC validate" +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 requirements-as-code version to install from PyPI. Empty installs the + latest release. Ignored when `install-from` is `source`. + required: false + default: "" + install-from: + description: >- + Where to install RAC from: `pypi` (the default for consumers) or `source` + (the action checkout itself — for this repository's own dogfood job; + requires `uses: ./validate-action` on a full checkout). + required: false + default: "pypi" + +runs: + using: "composite" + steps: + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install RAC + shell: bash + env: + INSTALL_FROM: ${{ inputs.install-from }} + RAC_VERSION: ${{ inputs.rac-version }} + run: | + python -m pip install --quiet --upgrade pip + if [ "$INSTALL_FROM" = "source" ]; then + python -m pip install --quiet "$GITHUB_ACTION_PATH/.." + elif [ -n "$RAC_VERSION" ]; then + python -m pip install --quiet "requirements-as-code==$RAC_VERSION" + else + python -m pip install --quiet requirements-as-code + 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..79064fd --- /dev/null +++ b/tests/test_pr_gate_action.py @@ -0,0 +1,65 @@ +"""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 / "pr-gate-action" / "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", "install-from"): + 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_install_supports_source_for_dogfood(): + # `install-from: source` lets the repo dogfood the action with uses: ./pr-gate-action. + run_steps = " ".join(s.get("run", "") for s in _action()["runs"]["steps"]) + assert "GITHUB_ACTION_PATH" in run_steps diff --git a/tests/test_validate_action.py b/tests/test_validate_action.py new file mode 100644 index 0000000..f17193a --- /dev/null +++ b/tests/test_validate_action.py @@ -0,0 +1,60 @@ +"""Structural tests for the RAC validate composite action (v0.17.2, 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 / "validate-action" / "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 validate" + + +def test_action_declares_expected_inputs(): + inputs = _action()["inputs"] + for name in ("path", "upload-sarif", "sarif-file", "rac-version", "install-from"): + 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_install_supports_source_for_dogfood(): + # `install-from: source` lets the repo dogfood the action with uses: ./validate-action. + run_steps = " ".join(s.get("run", "") for s in _action()["runs"]["steps"]) + assert "GITHUB_ACTION_PATH" in run_steps diff --git a/watchkeeper/github/action.yml b/watchkeeper/github/action.yml new file mode 100644 index 0000000..4a23e61 --- /dev/null +++ b/watchkeeper/github/action.yml @@ -0,0 +1,122 @@ +# 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 requirements-as-code version to install from PyPI. Empty + installs the latest release. Ignored when `install-from` is `source`. + required: false + default: "" + install-from: + description: >- + Where to install RAC from: `pypi` (the default for consumers) or + `source` (the action checkout itself — used by this repository's own + dogfood job; requires `uses: ./` on a full checkout). + required: false + default: "pypi" + +runs: + using: "composite" + steps: + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install RAC + shell: bash + env: + INSTALL_FROM: ${{ inputs.install-from }} + RAC_VERSION: ${{ inputs.rac-version }} + run: | + python -m pip install --quiet --upgrade pip + if [ "$INSTALL_FROM" = "source" ]; then + # The action checkout itself; setuptools-scm needs git metadata, + # so this path requires `uses: ./` on a real checkout. + python -m pip install --quiet "$GITHUB_ACTION_PATH" + elif [ -n "$RAC_VERSION" ]; then + python -m pip install --quiet "requirements-as-code==$RAC_VERSION" + else + python -m pip install --quiet requirements-as-code + 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" From c810a45c1e26b1a0685253a37bc4aebdc7bf7fba Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sat, 27 Jun 2026 20:17:35 +0000 Subject: [PATCH 2/3] chore(ci): install rac-core in the action wrappers and publish workflows Implements rac/decisions/adr-092-repository-topology.md. Points the composite-action pip installs, the TestPyPI rehearsal notes, and the watchkeeper version input at rac-core. (python-publish.yml is unchanged: Trusted Publishing derives the project from the built distribution.) --- .github/workflows/watchkeeper.yml | 2 +- gatekeeper/github/action.yml | 6 +++--- registrar/github/action.yml | 6 +++--- watchkeeper/github/action.yml | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/watchkeeper.yml b/.github/workflows/watchkeeper.yml index 7d9fe57..6aed701 100644 --- a/.github/workflows/watchkeeper.yml +++ b/.github/workflows/watchkeeper.yml @@ -38,7 +38,7 @@ on: required: false default: true rac-version: - description: "Exact requirements-as-code version from PyPI (empty: latest)." + description: "Exact rac-core version from PyPI (empty: latest)." type: string required: false default: "" diff --git a/gatekeeper/github/action.yml b/gatekeeper/github/action.yml index d8d2db6..23c2c99 100644 --- a/gatekeeper/github/action.yml +++ b/gatekeeper/github/action.yml @@ -41,7 +41,7 @@ inputs: default: "rac-sarif" rac-version: description: >- - Exact requirements-as-code version to install from PyPI. Empty installs the + Exact rac-core version to install from PyPI. Empty installs the latest release. Ignored when `install-from` is `source`. required: false default: "" @@ -70,9 +70,9 @@ runs: if [ "$INSTALL_FROM" = "source" ]; then python -m pip install --quiet "$GITHUB_ACTION_PATH/.." elif [ -n "$RAC_VERSION" ]; then - python -m pip install --quiet "requirements-as-code==$RAC_VERSION" + python -m pip install --quiet "rac-core==$RAC_VERSION" else - python -m pip install --quiet requirements-as-code + python -m pip install --quiet rac-core fi # The CLI is the source of truth (ADR-058). `set +e` lets a non-zero exit diff --git a/registrar/github/action.yml b/registrar/github/action.yml index efdda97..babfbcb 100644 --- a/registrar/github/action.yml +++ b/registrar/github/action.yml @@ -33,7 +33,7 @@ inputs: default: "rac.sarif" rac-version: description: >- - Exact requirements-as-code version to install from PyPI. Empty installs the + Exact rac-core version to install from PyPI. Empty installs the latest release. Ignored when `install-from` is `source`. required: false default: "" @@ -62,9 +62,9 @@ runs: if [ "$INSTALL_FROM" = "source" ]; then python -m pip install --quiet "$GITHUB_ACTION_PATH/.." elif [ -n "$RAC_VERSION" ]; then - python -m pip install --quiet "requirements-as-code==$RAC_VERSION" + python -m pip install --quiet "rac-core==$RAC_VERSION" else - python -m pip install --quiet requirements-as-code + python -m pip install --quiet rac-core fi # The CLI is the source of truth (ADR-058). `set +e` lets a non-zero exit diff --git a/watchkeeper/github/action.yml b/watchkeeper/github/action.yml index 4a23e61..783623e 100644 --- a/watchkeeper/github/action.yml +++ b/watchkeeper/github/action.yml @@ -42,7 +42,7 @@ inputs: default: "true" rac-version: description: >- - Exact requirements-as-code version to install from PyPI. Empty + Exact rac-core version to install from PyPI. Empty installs the latest release. Ignored when `install-from` is `source`. required: false default: "" @@ -73,9 +73,9 @@ runs: # so this path requires `uses: ./` on a real checkout. python -m pip install --quiet "$GITHUB_ACTION_PATH" elif [ -n "$RAC_VERSION" ]; then - python -m pip install --quiet "requirements-as-code==$RAC_VERSION" + python -m pip install --quiet "rac-core==$RAC_VERSION" else - python -m pip install --quiet requirements-as-code + python -m pip install --quiet rac-core fi - name: Resolve base revision From 0f5103c0ae61e925f4bf82476e08306865e8cf9e Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Mon, 29 Jun 2026 08:37:07 +0000 Subject: [PATCH 3/3] refactor(ci): land the CI wrappers in the rac-ci capability layout [roadmap:rac-ci] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements rac/roadmaps/repo-topology/rac-ci.md (ADR-092). Seeds rac-ci from the rac-core action sources (history preserved) under the capability x platform layout: - watchkeeper/github/ (← rac-core action.yml) - gatekeeper/github/ (← rac-core pr-gate-action/) - registrar/github/ (← rac-core validate-action/; action renamed "RAC Registrar") - recordkeeper/ (placeholder for the ADR-084 recorder) The wrappers now install the published rac-core from PyPI (pin via rac-version); the install-from: source mode is dropped — rac-core dogfoods the gates via direct source-install CLI steps, not by consuming these wrappers. Repoints the reusable watchkeeper.yml and the moved action tests to the new paths, and adds a tests workflow that pins each wrapper's contract. --- .claude/settings.json | 7 +++++++ .github/workflows/tests.yml | 29 +++++++++++++++++++++++++ .github/workflows/watchkeeper.yml | 6 +++--- .gitignore | 3 +++ README.md | 35 +++++++++++++++++++++++++++++++ gatekeeper/github/action.yml | 20 +++++------------- recordkeeper/README.md | 7 +++++++ registrar/github/action.yml | 22 ++++++------------- tests/test_pr_gate_action.py | 12 ++++++----- tests/test_validate_action.py | 16 +++++++------- watchkeeper/github/action.yml | 18 +++------------- 11 files changed, 114 insertions(+), 61 deletions(-) create mode 100644 .claude/settings.json create mode 100644 .github/workflows/tests.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 recordkeeper/README.md 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 index 6aed701..ae78fc8 100644 --- a/.github/workflows/watchkeeper.yml +++ b/.github/workflows/watchkeeper.yml @@ -5,7 +5,7 @@ # # jobs: # watchkeeper: -# uses: itsthelore/rac-core/.github/workflows/watchkeeper.yml@ +# uses: itsthelore/rac-ci/.github/workflows/watchkeeper.yml@ # with: # path: rac # @@ -59,8 +59,8 @@ jobs: # 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 v0.12.x release it tracks main. - - uses: itsthelore/rac-core@main + # until the first rac-ci release it tracks main. + - uses: itsthelore/rac-ci/watchkeeper/github@main with: path: ${{ inputs.path }} base: ${{ inputs.base }} 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 index 23c2c99..62922f0 100644 --- a/gatekeeper/github/action.yml +++ b/gatekeeper/github/action.yml @@ -9,9 +9,9 @@ # 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 the repository root and the single-command -# validate action at `validate-action/`; this gate is referenced as -# `uses: itsthelore/rac-core/pr-gate-action@`. +# 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 @@ -42,16 +42,9 @@ inputs: rac-version: description: >- Exact rac-core version to install from PyPI. Empty installs the - latest release. Ignored when `install-from` is `source`. + latest release. required: false default: "" - install-from: - description: >- - Where to install RAC from: `pypi` (the default for consumers) or `source` - (the action checkout itself — for this repository's own dogfood job; - requires `uses: ./pr-gate-action` on a full checkout). - required: false - default: "pypi" runs: using: "composite" @@ -63,13 +56,10 @@ runs: - name: Install RAC shell: bash env: - INSTALL_FROM: ${{ inputs.install-from }} RAC_VERSION: ${{ inputs.rac-version }} run: | python -m pip install --quiet --upgrade pip - if [ "$INSTALL_FROM" = "source" ]; then - python -m pip install --quiet "$GITHUB_ACTION_PATH/.." - elif [ -n "$RAC_VERSION" ]; then + if [ -n "$RAC_VERSION" ]; then python -m pip install --quiet "rac-core==$RAC_VERSION" else python -m pip install --quiet rac-core 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 index babfbcb..da02b3d 100644 --- a/registrar/github/action.yml +++ b/registrar/github/action.yml @@ -3,10 +3,10 @@ # 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 the repository root; -# this validate action is referenced as -# `uses: itsthelore/rac-core/validate-action@`. -name: "RAC validate" +# 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. @@ -34,16 +34,9 @@ inputs: rac-version: description: >- Exact rac-core version to install from PyPI. Empty installs the - latest release. Ignored when `install-from` is `source`. + latest release. required: false default: "" - install-from: - description: >- - Where to install RAC from: `pypi` (the default for consumers) or `source` - (the action checkout itself — for this repository's own dogfood job; - requires `uses: ./validate-action` on a full checkout). - required: false - default: "pypi" runs: using: "composite" @@ -55,13 +48,10 @@ runs: - name: Install RAC shell: bash env: - INSTALL_FROM: ${{ inputs.install-from }} RAC_VERSION: ${{ inputs.rac-version }} run: | python -m pip install --quiet --upgrade pip - if [ "$INSTALL_FROM" = "source" ]; then - python -m pip install --quiet "$GITHUB_ACTION_PATH/.." - elif [ -n "$RAC_VERSION" ]; then + if [ -n "$RAC_VERSION" ]; then python -m pip install --quiet "rac-core==$RAC_VERSION" else python -m pip install --quiet rac-core diff --git a/tests/test_pr_gate_action.py b/tests/test_pr_gate_action.py index 79064fd..3aa26ea 100644 --- a/tests/test_pr_gate_action.py +++ b/tests/test_pr_gate_action.py @@ -14,7 +14,7 @@ import yaml -ACTION = Path(__file__).parent.parent / "pr-gate-action" / "action.yml" +ACTION = Path(__file__).parent.parent / "gatekeeper" / "github" / "action.yml" def _action() -> dict: @@ -29,7 +29,7 @@ def test_action_is_composite(): def test_action_declares_expected_inputs(): inputs = _action()["inputs"] - for name in ("path", "upload-sarif", "sarif-dir", "rac-version", "install-from"): + 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" @@ -59,7 +59,9 @@ def test_action_resurfaces_exit_code(): assert 'exit "$EXIT_CODE"' in run_steps -def test_action_install_supports_source_for_dogfood(): - # `install-from: source` lets the repo dogfood the action with uses: ./pr-gate-action. +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 "GITHUB_ACTION_PATH" in run_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 index f17193a..ccf1488 100644 --- a/tests/test_validate_action.py +++ b/tests/test_validate_action.py @@ -1,4 +1,4 @@ -"""Structural tests for the RAC validate composite action (v0.17.2, ADR-058). +"""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 @@ -12,7 +12,7 @@ import yaml -ACTION = Path(__file__).parent.parent / "validate-action" / "action.yml" +ACTION = Path(__file__).parent.parent / "registrar" / "github" / "action.yml" def _action() -> dict: @@ -22,12 +22,12 @@ def _action() -> dict: def test_action_is_composite(): a = _action() assert a["runs"]["using"] == "composite" - assert a["name"] == "RAC validate" + assert a["name"] == "RAC Registrar" def test_action_declares_expected_inputs(): inputs = _action()["inputs"] - for name in ("path", "upload-sarif", "sarif-file", "rac-version", "install-from"): + 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" @@ -54,7 +54,9 @@ def test_action_resurfaces_exit_code(): assert 'exit "$EXIT_CODE"' in run_steps -def test_action_install_supports_source_for_dogfood(): - # `install-from: source` lets the repo dogfood the action with uses: ./validate-action. +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 "GITHUB_ACTION_PATH" in run_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 index 783623e..a80bb6f 100644 --- a/watchkeeper/github/action.yml +++ b/watchkeeper/github/action.yml @@ -42,17 +42,10 @@ inputs: default: "true" rac-version: description: >- - Exact rac-core version to install from PyPI. Empty - installs the latest release. Ignored when `install-from` is `source`. + Exact rac-core version to install from PyPI. Empty installs the + latest release. required: false default: "" - install-from: - description: >- - Where to install RAC from: `pypi` (the default for consumers) or - `source` (the action checkout itself — used by this repository's own - dogfood job; requires `uses: ./` on a full checkout). - required: false - default: "pypi" runs: using: "composite" @@ -64,15 +57,10 @@ runs: - name: Install RAC shell: bash env: - INSTALL_FROM: ${{ inputs.install-from }} RAC_VERSION: ${{ inputs.rac-version }} run: | python -m pip install --quiet --upgrade pip - if [ "$INSTALL_FROM" = "source" ]; then - # The action checkout itself; setuptools-scm needs git metadata, - # so this path requires `uses: ./` on a real checkout. - python -m pip install --quiet "$GITHUB_ACTION_PATH" - elif [ -n "$RAC_VERSION" ]; then + if [ -n "$RAC_VERSION" ]; then python -m pip install --quiet "rac-core==$RAC_VERSION" else python -m pip install --quiet rac-core