diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 18b39d5..89cbbf2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -26,28 +26,4 @@ jobs: run: python -m pip install --quiet --upgrade pip pytest pyyaml - name: Run action-contract tests - run: python -m pytest tests/ -q --ignore=tests/test_herald_renderer.py - - # The Herald renderer is behavioral, not just structural: it shells the real - # `decided decisions-for --json` (published asdecided-core — the same contract the - # wrapper consumes in the field, ADR-063) and must render byte-identical - # comments for the same corpus and diff. Kept out of the structural job so - # that tier stays engine-free. - herald-renderer: - name: herald renderer (published engine) - 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 - - - name: Install the verified native engine - run: bash shared/install-native.sh 0.23.1 - - - name: Run the renderer battery - run: python -m pytest tests/test_herald_renderer.py -q + run: python -m pytest tests/ -q diff --git a/README.md b/README.md index 9536008..ea9004c 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,9 @@ member) this consolidates the CI wrappers that previously lived in `asdecided-co in the standalone `rac-actions` / `lore-watchkeeper` / `lore-gatekeeper` repos. Every capability is a **thin wrapper over the public `decided` CLI** (ADR-063); -all analysis and policy live in the native engine. The wrappers download a -checksum-verified `asdecided-core` release (pin with the -`asdecided-version` input). Python is used only by Herald's comment renderer, -not as the engine. +all analysis, policy, and Herald rendering live in the native engine. The +wrappers download a checksum-verified `asdecided-core` release (pin with the +`asdecided-version` input). No action requires a Python runtime. ## Capabilities @@ -19,8 +18,9 @@ not as the engine. | Watchkeeper | [`watchkeeper/github/`](watchkeeper/github/) | `decided watchkeeper` (PR knowledge review) | `uses: asdecided/ci/watchkeeper/github@` | | Gatekeeper | [`gatekeeper/github/`](gatekeeper/github/) | `decided gate --sarif` (required merge gate) | `uses: asdecided/ci/gatekeeper/github@` | | Registrar | [`registrar/github/`](registrar/github/) | `decided validate --sarif` (well-formedness, ADR-058) | `uses: asdecided/ci/registrar/github@` | -| Herald | [`herald/github/`](herald/github/) | `decided decisions-for --json` (advisory governing-decisions comment on PRs) | `uses: asdecided/ci/herald/github@` | +| Herald | [`herald/github/`](herald/github/) | `decided herald` (advisory governing-decisions comment on PRs) | `uses: asdecided/ci/herald/github@` | | Recordkeeper | [`recordkeeper/`](recordkeeper/) | read-access audit recorder (ADR-084) | *placeholder — not yet shipped* | +| Sentry | [`sentry/github/`](sentry/github/) | `decided sentry --sarif` (deterministic decision-to-code enforcement) | `uses: asdecided/ci/sentry/github@` | A reusable Watchkeeper workflow is also published at [`.github/workflows/watchkeeper.yml`](.github/workflows/watchkeeper.yml) @@ -30,6 +30,20 @@ A reusable Watchkeeper workflow is also published at demanded; the engine is already platform-neutral (SARIF/JSON), so that work is in the wrappers, not the engine. +## Distribution facades + +Development remains in this repository. Marketplace-facing repositories are +generated release facades so each action can carry a root `action.yml`: + +`asdecided/gatekeeper`, `asdecided/herald`, `asdecided/recordkeeper`, +`asdecided/registrar`, `asdecided/watchkeeper`, and `asdecided/sentry`. + +`distribution/actions.json` is the source manifest and +`scripts/package-facades.sh ` builds all six +deterministically. Recordkeeper is packaged as a companion-repository +placeholder but is not marked Marketplace-ready until its execution wrapper +ships; the other five contain root action manifests. + ## History The `watchkeeper/`, `gatekeeper/`, and `registrar/` wrappers moved here from diff --git a/distribution/actions.json b/distribution/actions.json new file mode 100644 index 0000000..745fe02 --- /dev/null +++ b/distribution/actions.json @@ -0,0 +1,11 @@ +{ + "schema_version": 1, + "facades": [ + {"name": "gatekeeper", "source": "gatekeeper/github", "marketplace": true}, + {"name": "herald", "source": "herald/github", "marketplace": true}, + {"name": "recordkeeper", "source": "recordkeeper", "marketplace": false}, + {"name": "registrar", "source": "registrar/github", "marketplace": true}, + {"name": "watchkeeper", "source": "watchkeeper/github", "marketplace": true}, + {"name": "sentry", "source": "sentry/github", "marketplace": true} + ] +} diff --git a/gatekeeper/github/action.yml b/gatekeeper/github/action.yml index ec24cc8..77bf2b7 100644 --- a/gatekeeper/github/action.yml +++ b/gatekeeper/github/action.yml @@ -12,12 +12,12 @@ # The Watchkeeper action lives at `watchkeeper/github/` and the validate # (Registrar) action at `registrar/github/`; this gate is referenced as # `uses: asdecided/ci/gatekeeper/github@`. -name: "RAC PR gate" +name: "As Decided Gatekeeper" description: >- - Enforce a requirements-as-code (RAC) corpus on a pull request with a single + Enforce an As Decided knowledge corpus on a pull request with a single `decided 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. + a required status check. A thin wrapper over the native `decided` CLI. author: "Tom Ballard" branding: @@ -26,9 +26,9 @@ branding: inputs: path: - description: "The RAC corpus directory to enforce (passed to `decided gate`)." + description: "The As Decided corpus directory to enforce." required: false - default: "rac" + default: "decisions" upload-sarif: description: >- Upload SARIF to GitHub Code Scanning (`true` or `false`). Requires the job @@ -43,7 +43,7 @@ inputs: description: >- Verified native asdecided-core release to install. required: false - default: "0.23.1" + default: "0.24.0" runs: using: "composite" @@ -79,7 +79,7 @@ runs: uses: github/codeql-action/upload-sarif@v3 with: sarif_file: ${{ inputs.sarif-dir }}/gate.sarif - category: rac-gate + category: asdecided-gatekeeper # 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 @@ -90,7 +90,7 @@ runs: 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." + echo "::error::As Decided Gatekeeper failed (exit $EXIT_CODE) — see Code Scanning annotations." exit "$EXIT_CODE" fi - echo "RAC PR gate passed." + echo "As Decided Gatekeeper passed." diff --git a/herald/github/action.yml b/herald/github/action.yml index 4520ae0..8bec770 100644 --- a/herald/github/action.yml +++ b/herald/github/action.yml @@ -3,8 +3,8 @@ # # Proclaims the recorded decisions that govern a pull request's changed paths # as ONE advisory comment: compute the merge-base diff, run -# `decided decisions-for --json` per changed path (the live-decisions lookup the -# CLI and MCP serve, `decision-to-code-proximity`), and post a terse, +# native `decided herald` renderer (built on the live-decisions lookup the CLI +# and MCP serve, `decision-to-code-proximity`), and post a terse, # deduplicated comment that re-runs update in place. Facts, never verdicts # (ADR-034): the comment names what governs and recommends review; it never # gates the merge — the action succeeds whatever it finds, and only @@ -22,11 +22,11 @@ # `pull-requests: write` on the job for the comment. On forks, where the # token is read-only, the comment falls back to the step summary instead of # failing the check. -name: "AsDecided decisions on PR" +name: "As Decided Herald" description: >- Comment the recorded decisions that govern a pull request's changed paths — id, title, and the declared scope that matched — as one advisory, - update-in-place comment. A thin wrapper over `decided decisions-for`; facts, + update-in-place comment. A thin wrapper over `decided herald`; facts, never a merge gate. author: "Tom Ballard" branding: @@ -34,9 +34,9 @@ branding: color: "purple" inputs: path: - description: "The RAC corpus directory (passed to `decided decisions-for`)." + description: "The As Decided corpus directory." required: false - default: "rac" + default: "decisions" max-inline: description: >- Governing decisions listed in full before the rest collapse into a @@ -47,13 +47,10 @@ inputs: description: >- Verified native asdecided-core release to install. required: false - default: "0.23.1" + default: "0.24.0" runs: using: "composite" steps: - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - name: Install AsDecided shell: bash run: | @@ -74,9 +71,8 @@ runs: fi git diff --name-only "$BASE_SHA...$HEAD_SHA" > lore-changed-paths.txt echo "$(wc -l < lore-changed-paths.txt) changed path(s)" - # One deterministic comment body: `decided decisions-for --json` per changed - # path, merged by decision id, sorted, no wall-clock input — the same - # bytes for the same corpus and diff. + # The native engine owns collection, deduplication, sorting, and rendering. + # The same corpus and diff produce the same bytes; no Python sidecar. - name: Render governing decisions id: render shell: bash @@ -85,12 +81,12 @@ runs: MAX_INLINE: ${{ inputs.max-inline }} LINK_BASE: ${{ github.server_url }}/${{ github.repository }}/blob/${{ github.event.pull_request.head.sha }} run: | - python "$GITHUB_ACTION_PATH/render.py" \ - --corpus "$INPUT_PATH" \ + decided herald "$INPUT_PATH" \ --paths-file lore-changed-paths.txt \ --link-base "$LINK_BASE" \ --max-inline "$MAX_INLINE" \ - --out lore-decisions-comment.md + --out lore-decisions-comment.md \ + --github-output "$GITHUB_OUTPUT" # One comment per PR, updated in place on re-runs (found by its marker). # No comment is created when nothing governs; an existing comment is # updated even to the empty state, so it never goes stale. A read-only diff --git a/herald/github/render.py b/herald/github/render.py deleted file mode 100644 index b831bb6..0000000 --- a/herald/github/render.py +++ /dev/null @@ -1,164 +0,0 @@ -#!/usr/bin/env python3 -"""Render the decisions-on-PR comment from `decided decisions-for --json`. - -The thin-client half of the `pr-decision-surfacing` design (ADR-063): run the -engine's live-decisions lookup for each changed path, merge by decision id, -and emit one deterministic Markdown comment — facts, never verdicts (ADR-034). -Everything semantic (scope matching, liveness) is the engine's; this script -only joins, sorts, and formats. No wall-clock input: the same corpus and diff -render the same bytes. - -Exit code is 0 whether or not decisions govern (advisory surfaces never fail -a check); only operational breakage — the `rac` binary missing outright — -exits non-zero. A lookup failure on one path is skipped with a warning so a -single odd path cannot take down the whole comment. -""" - -from __future__ import annotations - -import argparse -import json -import os -import subprocess -import sys - -MARKER = "" - -EMPTY_BODY = ( - f"{MARKER}\n" - "### Decisions governing this change\n\n" - "No recorded decisions govern the paths changed by this pull request.\n" -) - -# Changed paths listed per decision before collapsing to a count. -_PATHS_SHOWN = 3 - - -def _lookup(decided_bin: str, path: str, corpus: str) -> dict | None: - """One `decided decisions-for --json` call, parsed, or None.""" - try: - proc = subprocess.run( - [decided_bin, "decisions-for", path, corpus, "--json"], - capture_output=True, - text=True, - check=False, - ) - except FileNotFoundError: - print(f"error: decided binary not found: {decided_bin!r}", file=sys.stderr) - raise SystemExit(1) from None - if proc.returncode != 0: - print( - f"warning: decisions-for skipped {path!r} (exit {proc.returncode})", - file=sys.stderr, - ) - return None - try: - return json.loads(proc.stdout) - except json.JSONDecodeError: - print(f"warning: decisions-for emitted non-JSON for {path!r}", file=sys.stderr) - return None - - -def collect(decided_bin: str, corpus: str, changed_paths: list[str]) -> list[dict]: - """Governing decisions merged across paths, sorted by decision id. - - Each entry: id, title, status, path (the artifact file), matching entries - and changed paths as sorted lists. The engine already scopes the answer to - live decisions with a matching declared `## Applies To`. - """ - merged: dict[str, dict] = {} - for changed in changed_paths: - result = _lookup(decided_bin, changed, corpus) - if not result: - continue - for decision in result.get("decisions", []): - entry = merged.setdefault( - decision["id"], - { - "id": decision["id"], - "title": decision["title"], - "status": decision["status"], - "path": decision["path"], - "matching_entries": set(), - "changed_paths": set(), - }, - ) - entry["matching_entries"].add(decision["matching_entry"]) - entry["changed_paths"].add(changed) - decisions = [] - for entry in sorted(merged.values(), key=lambda e: e["id"]): - entry["matching_entries"] = sorted(entry["matching_entries"]) - entry["changed_paths"] = sorted(entry["changed_paths"]) - decisions.append(entry) - return decisions - - -def _bullet(decision: dict, link_base: str) -> str: - scopes = ", ".join(f"`{s}`" for s in decision["matching_entries"]) - shown = decision["changed_paths"][:_PATHS_SHOWN] - changed = ", ".join(f"`{p}`" for p in shown) - more = len(decision["changed_paths"]) - len(shown) - if more > 0: - changed += f" +{more} more" - link = f"{link_base}/{decision['path']}" if link_base else decision["path"] - return ( - f"- **[{decision['id']} — {decision['title']}]({link})**" - f" ({decision['status']}) — applies to {scopes} — changed: {changed}" - ) - - -def render(decisions: list[dict], link_base: str, max_inline: int) -> str: - """The comment body: marker, terse header, one bullet per decision.""" - if not decisions: - return EMPTY_BODY - count = len(decisions) - plural = "" if count == 1 else "s" - lines = [ - MARKER, - "### Decisions governing this change", - "", - f"This pull request touches paths governed by {count} recorded " - f"decision{plural} — review recommended.", - "", - ] - inline = decisions[:max_inline] if max_inline > 0 else decisions - rest = decisions[len(inline) :] - lines.extend(_bullet(d, link_base) for d in inline) - if rest: - lines.append("") - lines.append(f"
{len(rest)} more governing decision" - f"{'' if len(rest) == 1 else 's'}") - lines.append("") - lines.extend(_bullet(d, link_base) for d in rest) - lines.append("") - lines.append("
") - lines.append("") - return "\n".join(lines) - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--corpus", required=True) - parser.add_argument("--paths-file", required=True) - parser.add_argument("--link-base", default="") - parser.add_argument("--max-inline", type=int, default=5) - parser.add_argument("--out", required=True) - parser.add_argument("--decided-bin", default="decided") - args = parser.parse_args(argv) - with open(args.paths_file, encoding="utf-8") as fh: - changed_paths = sorted({line.strip() for line in fh if line.strip()}) - decisions = collect(args.decided_bin, args.corpus, changed_paths) - body = render(decisions, args.link_base.rstrip("/"), args.max_inline) - with open(args.out, "w", encoding="utf-8") as fh: - fh.write(body) - has_decisions = "true" if decisions else "false" - output_path = os.environ.get("GITHUB_OUTPUT") - if output_path: - with open(output_path, "a", encoding="utf-8") as fh: - fh.write(f"has_decisions={has_decisions}\n") - print(f"{len(decisions)} governing decision(s); has_decisions={has_decisions}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/registrar/github/action.yml b/registrar/github/action.yml index 953a4e1..6910c10 100644 --- a/registrar/github/action.yml +++ b/registrar/github/action.yml @@ -6,10 +6,10 @@ # reinterprets findings. The Watchkeeper action lives at `watchkeeper/github/`; # this Registrar (validate) action is referenced as # `uses: asdecided/ci/registrar/github@`. -name: "RAC Registrar" +name: "As Decided 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. + Validate an As Decided knowledge corpus and surface findings on the pull + request via GitHub Code Scanning (SARIF). A thin wrapper over `decided`. author: "Tom Ballard" branding: @@ -18,9 +18,9 @@ branding: inputs: path: - description: "The RAC corpus directory to validate (passed to `decided validate`)." + description: "The As Decided corpus directory to validate." required: false - default: "rac" + default: "decisions" upload-sarif: description: >- Upload SARIF to GitHub Code Scanning (`true` or `false`). Requires the job @@ -35,7 +35,7 @@ inputs: description: >- Verified native asdecided-core release to install. required: false - default: "0.23.1" + default: "0.24.0" runs: using: "composite" diff --git a/scripts/package-facades.sh b/scripts/package-facades.sh new file mode 100755 index 0000000..2c4c978 --- /dev/null +++ b/scripts/package-facades.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +output="${1:-$repo_root/dist}" +manifest="$repo_root/distribution/actions.json" + +if [[ -e "$output" ]]; then + echo "output already exists: $output" >&2 + exit 2 +fi +mkdir -p "$output" + +count=0 +while IFS=$'\t' read -r name source marketplace; do + count=$((count + 1)) + destination="$output/$name" + mkdir -p "$destination" + cp "$repo_root/LICENSE" "$destination/LICENSE" + for asset in "$repo_root/$source"/*; do + [[ -f "$asset" ]] || continue + case "$(basename "$asset")" in + action.yml|README.md) ;; + *) cp "$asset" "$destination/$(basename "$asset")" ;; + esac + done + if [[ -f "$repo_root/$source/action.yml" ]]; then + sed 's#\$GITHUB_ACTION_PATH/../../shared/#\$GITHUB_ACTION_PATH/shared/#g' \ + "$repo_root/$source/action.yml" > "$destination/action.yml" + mkdir -p "$destination/shared" + cp "$repo_root/shared/install-native.sh" "$destination/shared/install-native.sh" + fi + if [[ -f "$repo_root/$source/README.md" ]]; then + cp "$repo_root/$source/README.md" "$destination/README.md" + else + { + printf '# As Decided %s\n\n' "$name" + printf 'Generated distribution facade. Authoritative development lives in [asdecided/ci](https://github.com/asdecided/ci/tree/main/%s).\n' "$source" + if [[ "$marketplace" == "true" ]]; then + printf '\nUse from GitHub Actions as `asdecided/%s@`.\n' "$name" + fi + } > "$destination/README.md" + fi +done < <( + python3 -c 'import json,sys; data=json.load(open(sys.argv[1])); [print(x["name"], x["source"], str(x["marketplace"]).lower(), sep="\t") for x in data["facades"]]' "$manifest" +) + +if [[ "$count" -ne 6 ]]; then + echo "expected six facade definitions, packaged $count" >&2 + exit 1 +fi +printf 'Packaged six facades in %s\n' "$output" diff --git a/sentry/github/action.yml b/sentry/github/action.yml new file mode 100644 index 0000000..d7a9d10 --- /dev/null +++ b/sentry/github/action.yml @@ -0,0 +1,111 @@ +name: "As Decided Sentry" +description: >- + Enforce deterministic decision constraints against pull-request source + changes and surface violations through GitHub Code Scanning. +author: "Tom Ballard" + +branding: + icon: "shield" + color: "yellow" + +inputs: + path: + description: "Decision corpus directory passed to `decided sentry`." + required: false + default: "decisions" + repository: + description: "Repository root containing the source code to enforce." + required: false + default: "." + base: + description: >- + Git base revision. Empty resolves to the pull request base SHA. + required: false + default: "" + full: + description: "Check the complete repository tree instead of changed lines." + required: false + default: "false" + upload-sarif: + description: >- + Upload SARIF to GitHub Code Scanning (`true` or `false`). Requires + `security-events: write`. + required: false + default: "true" + sarif-file: + description: "Where the Sentry SARIF document is written." + required: false + default: "asdecided-sentry.sarif" + asdecided-version: + description: "Verified native asdecided-core release to install." + required: false + default: "0.24.0" + +outputs: + exit-code: + description: "The native Sentry process exit code." + value: ${{ steps.sentry.outputs.exit_code }} + +runs: + using: "composite" + steps: + - name: Install As Decided + shell: bash + run: | + bash "$GITHUB_ACTION_PATH/../../shared/install-native.sh" \ + "${{ inputs.asdecided-version }}" + + - name: Resolve enforcement mode + shell: bash + env: + INPUT_BASE: ${{ inputs.base }} + INPUT_FULL: ${{ inputs.full }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + if [ "$INPUT_FULL" = "true" ]; then + echo "SENTRY_MODE=--full" >> "$GITHUB_ENV" + exit 0 + fi + BASE="$INPUT_BASE" + if [ -z "$BASE" ]; then + BASE="$PR_BASE_SHA" + fi + if [ -z "$BASE" ]; then + echo "::error::Sentry needs a base revision outside a pull_request event; set the base input or use full=true." + exit 2 + fi + echo "SENTRY_MODE=--base $BASE" >> "$GITHUB_ENV" + + - name: Run As Decided Sentry + id: sentry + shell: bash + env: + INPUT_PATH: ${{ inputs.path }} + INPUT_REPOSITORY: ${{ inputs.repository }} + SARIF_FILE: ${{ inputs.sarif-file }} + run: | + set +e + decided sentry "$INPUT_PATH" \ + --repository "$INPUT_REPOSITORY" \ + $SENTRY_MODE \ + --sarif > "$SARIF_FILE" + code=$? + echo "exit_code=$code" >> "$GITHUB_OUTPUT" + + - name: Upload Sentry SARIF + if: ${{ always() && inputs.upload-sarif == 'true' }} + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: ${{ inputs.sarif-file }} + category: asdecided-sentry + + - name: Report result + shell: bash + env: + EXIT_CODE: ${{ steps.sentry.outputs.exit_code }} + run: | + if [ "$EXIT_CODE" != "0" ]; then + echo "::error::As Decided Sentry failed (exit $EXIT_CODE) — see Code Scanning annotations." + exit "$EXIT_CODE" + fi + echo "As Decided Sentry passed." diff --git a/shared/install-native.sh b/shared/install-native.sh index 80db235..b33e44e 100644 --- a/shared/install-native.sh +++ b/shared/install-native.sh @@ -1,27 +1,20 @@ #!/usr/bin/env bash set -euo pipefail -version="${1:-0.23.1}" +version="${1:-0.24.0}" version="${version#v}" -if [[ "$version" != "0.23.1" ]]; then - echo "::error::asdecided-ci does not have a verified checksum for asdecided-core $version" - exit 1 -fi case "${RUNNER_OS:-}-$(uname -m)" in Linux-x86_64) archive="asdecided-x86_64-unknown-linux-gnu.tar.gz" - digest="51cce8025a7cb2f8b2caea93a8ea71be0ad8c5c316fd0ecced688267bf97b8ac" executable="decided" ;; macOS-arm64) archive="asdecided-aarch64-apple-darwin.tar.gz" - digest="40d7129541609cbcf967f7d7d453e7689412bbaac81aa8d3c84e636367804628" executable="decided" ;; Windows-x86_64) archive="asdecided-x86_64-pc-windows-msvc.zip" - digest="14e3fd9b1c693a11f364f51587f74ec7f9ba6fa09ae183e049bc4b378d35cd25" executable="decided.exe" ;; *) @@ -30,6 +23,31 @@ case "${RUNNER_OS:-}-$(uname -m)" in ;; esac +case "$version:$archive" in + 0.23.1:asdecided-x86_64-unknown-linux-gnu.tar.gz) + digest="51cce8025a7cb2f8b2caea93a8ea71be0ad8c5c316fd0ecced688267bf97b8ac" + ;; + 0.23.1:asdecided-aarch64-apple-darwin.tar.gz) + digest="40d7129541609cbcf967f7d7d453e7689412bbaac81aa8d3c84e636367804628" + ;; + 0.23.1:asdecided-x86_64-pc-windows-msvc.zip) + digest="14e3fd9b1c693a11f364f51587f74ec7f9ba6fa09ae183e049bc4b378d35cd25" + ;; + 0.24.0:asdecided-x86_64-unknown-linux-gnu.tar.gz) + digest="53e36eebb34a58d59fc92a0b23eda3a35d2f0f17a84fad6b4339759ce4f855f4" + ;; + 0.24.0:asdecided-aarch64-apple-darwin.tar.gz) + digest="f87d0e3a77ffe6ef6249ec43cd5908a94f84d22e030f94bbd2fecbf91933926b" + ;; + 0.24.0:asdecided-x86_64-pc-windows-msvc.zip) + digest="e3069423ecb523186ff8b7d798a93e55385fccf5851dad46b81d8bffae39486c" + ;; + *) + echo "::error::asdecided-ci does not have a verified checksum for asdecided-core $version" + exit 1 + ;; +esac + install_dir="${RUNNER_TEMP}/asdecided-${version}" download="${RUNNER_TEMP}/${archive}" url="https://github.com/asdecided/core/releases/download/v${version}/${archive}" diff --git a/tests/test_facade_packaging.py b/tests/test_facade_packaging.py new file mode 100644 index 0000000..9ab116b --- /dev/null +++ b/tests/test_facade_packaging.py @@ -0,0 +1,44 @@ +import json +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).parent.parent + + +def test_manifest_names_all_six_capabilities(): + manifest = json.loads( + (ROOT / "distribution" / "actions.json").read_text(encoding="utf-8") + ) + assert [entry["name"] for entry in manifest["facades"]] == [ + "gatekeeper", + "herald", + "recordkeeper", + "registrar", + "watchkeeper", + "sentry", + ] + + +def test_packager_emits_root_actions_with_facade_relative_installer(tmp_path): + output = tmp_path / "facades" + subprocess.run( + ["bash", str(ROOT / "scripts" / "package-facades.sh"), str(output)], + cwd=ROOT, + check=True, + ) + assert sorted(path.name for path in output.iterdir()) == [ + "gatekeeper", + "herald", + "recordkeeper", + "registrar", + "sentry", + "watchkeeper", + ] + for name in ("gatekeeper", "herald", "registrar", "sentry", "watchkeeper"): + action = (output / name / "action.yml").read_text(encoding="utf-8") + assert "$GITHUB_ACTION_PATH/shared/install-native.sh" in action + assert "../../shared/install-native.sh" not in action + assert (output / name / "shared" / "install-native.sh").is_file() + assert "pip install" not in action + assert not (output / "recordkeeper" / "action.yml").exists() diff --git a/tests/test_herald_action.py b/tests/test_herald_action.py index 55e2021..496ca01 100644 --- a/tests/test_herald_action.py +++ b/tests/test_herald_action.py @@ -1,6 +1,6 @@ -"""Structural tests for the AsDecided decisions-on-PR (Herald) action. +"""Structural tests for the native As Decided Herald action. -The action is a thin wrapper (ADR-063) over `decided decisions-for --json`: compute +The action is a thin wrapper (ADR-063) over `decided herald`: compute the PR's merge-base diff, render one deterministic advisory comment, and post it update-in-place. Matching and liveness are the engine's; these tests pin the action's *contract* — inputs, the triple-dot diff, the renderer hand-off, and @@ -30,13 +30,13 @@ def _step(name_fragment: str) -> dict: def test_action_is_composite(): a = _action() assert a["runs"]["using"] == "composite" - assert a["name"] == "AsDecided decisions on PR" + assert a["name"] == "As Decided Herald" def test_action_declares_exactly_expected_inputs(): inputs = _action()["inputs"] assert set(inputs) == {"path", "max-inline", "asdecided-version"} - assert inputs["path"]["default"] == "rac" + assert inputs["path"]["default"] == "decisions" assert inputs["max-inline"]["default"] == "5" @@ -44,7 +44,7 @@ def test_action_installs_verified_native_engine(): run_steps = " ".join(s.get("run", "") for s in _action()["runs"]["steps"]) assert "shared/install-native.sh" in run_steps assert "pip install" not in run_steps - assert _action()["inputs"]["asdecided-version"]["default"] == "0.23.1" + assert _action()["inputs"]["asdecided-version"]["default"] == "0.24.0" def test_action_diffs_from_the_merge_base(): @@ -54,10 +54,12 @@ def test_action_diffs_from_the_merge_base(): assert "pull_request events only" in diff -def test_action_delegates_rendering_to_render_py(): +def test_action_delegates_rendering_to_native_engine(): render = _step("Render governing decisions")["run"] - assert "$GITHUB_ACTION_PATH/render.py" in render - assert (ACTION.parent / "render.py").is_file() + assert "decided herald" in render + assert "--github-output" in render + assert "python" not in render + assert not (ACTION.parent / "render.py").exists() def test_action_comment_step_updates_in_place_and_never_gates(): diff --git a/tests/test_herald_renderer.py b/tests/test_herald_renderer.py deleted file mode 100644 index 6f0702b..0000000 --- a/tests/test_herald_renderer.py +++ /dev/null @@ -1,155 +0,0 @@ -"""Behavioral battery for the Herald renderer (`herald/github/render.py`). - -Unlike the structural action-contract tests, these exercise the real published -engine: build a tiny corpus, shell `render.py` (which shells -`decided decisions-for --json` per changed path — the same contract the wrapper -consumes in the field, ADR-063), and pin the rendered comment. Facts, never -verdicts (ADR-034): governed and ungoverned diffs both exit 0, and the same -corpus and diff must render byte-identical output. -""" - -from __future__ import annotations - -import subprocess -import shutil -import sys -from pathlib import Path - -RENDER = Path(__file__).parent.parent / "herald" / "github" / "render.py" - -# The native engine installed by the workflow (or local developer environment). -DECIDED_BIN = shutil.which("decided") or "decided" - -LINK_BASE = "https://example.com/blob/HEAD" - - -def _write_decision(repo: Path, stem: str, number: int, title: str, scope: str) -> None: - path = repo / "rac" / "decisions" / f"{stem}.md" - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - f"""--- -schema_version: 1 -id: ACT-{number:04d} -type: decision ---- -# {title} - -## Context - -Recorded for the Herald renderer battery. - -## Decision - -{title}. - -## Consequences - -The scope below is governed. - -## Status - -Accepted - -## Applies To - -- {scope} -""", - encoding="utf-8", - ) - - -def _make_repo(tmp_path: Path, scopes: dict[str, str]) -> Path: - """A tmp repository: `.rac/config.yaml` plus one decision per scope.""" - repo = tmp_path / "repo" - (repo / ".rac").mkdir(parents=True) - (repo / ".rac" / "config.yaml").write_text( - "repository_key: ACT\n", encoding="utf-8" - ) - for number, (stem, scope) in enumerate(sorted(scopes.items()), start=1): - _write_decision(repo, stem, number, stem.replace("-", " ").title(), scope) - return repo - - -def _render( - repo: Path, changed_paths: list[str], max_inline: int = 5 -) -> tuple[subprocess.CompletedProcess, str]: - paths_file = repo / "changed-paths.txt" - paths_file.write_text("".join(f"{p}\n" for p in changed_paths), encoding="utf-8") - out = repo / "comment.md" - proc = subprocess.run( - [ - sys.executable, - str(RENDER), - "--corpus", - "rac", - "--paths-file", - str(paths_file), - "--link-base", - LINK_BASE, - "--max-inline", - str(max_inline), - "--out", - str(out), - "--decided-bin", - str(DECIDED_BIN), - ], - cwd=repo, - capture_output=True, - text=True, - check=False, - ) - assert proc.returncode == 0, proc.stderr - return proc, out.read_text(encoding="utf-8") - - -def test_governing_decisions_render_sorted_and_deduplicated(tmp_path): - repo = _make_repo( - tmp_path, - {"alpha-rule": "src/**/*.py", "beta-rule": "docs/**"}, - ) - proc, body = _render(repo, ["src/app.py", "src/pkg/util.py", "docs/guide.md"]) - assert "2 governing decision(s)" in proc.stdout - # One bullet per decision, sorted by id, however many changed paths matched. - assert body.count("- **[alpha-rule") == 1 - assert body.index("alpha-rule") < body.index("beta-rule") - assert "applies to `src/**/*.py`" in body - assert f"[alpha-rule — Alpha Rule]({LINK_BASE}/rac/decisions/alpha-rule.md)" in body - assert "`src/app.py`, `src/pkg/util.py`" in body - - -def test_ungoverned_diff_renders_the_empty_state(tmp_path): - repo = _make_repo(tmp_path, {"alpha-rule": "src/**/*.py"}) - proc, body = _render(repo, ["README.md"]) - assert "No recorded decisions govern" in body - assert "has_decisions=false" in proc.stdout - - -def test_a_bad_changed_path_does_not_take_down_the_comment(tmp_path): - repo = _make_repo(tmp_path, {"alpha-rule": "src/**/*.py"}) - proc, body = _render(repo, ["no/such/file.py", "src/app.py"]) - assert proc.returncode == 0 - assert "alpha-rule" in body - - -def test_overflow_collapses_into_a_details_expander(tmp_path): - repo = _make_repo( - tmp_path, - { - "alpha-rule": "src/**/*.py", - "beta-rule": "docs/**", - "gamma-rule": "tools/**", - }, - ) - _, body = _render( - repo, ["src/app.py", "docs/guide.md", "tools/run.sh"], max_inline=2 - ) - assert "
1 more governing decision" in body - assert body.index("gamma-rule") > body.index("
") - - -def test_rendering_is_byte_deterministic(tmp_path): - scopes = {"alpha-rule": "src/**/*.py", "beta-rule": "docs/**"} - changed = ["src/app.py", "docs/guide.md", "src/pkg/util.py"] - _, forward = _render(_make_repo(tmp_path / "a", scopes), changed) - _, backward = _render(_make_repo(tmp_path / "b", scopes), list(reversed(changed))) - assert forward == backward diff --git a/tests/test_pr_gate_action.py b/tests/test_pr_gate_action.py index 4a64429..f279d78 100644 --- a/tests/test_pr_gate_action.py +++ b/tests/test_pr_gate_action.py @@ -24,14 +24,14 @@ def _action() -> dict: def test_action_is_composite(): a = _action() assert a["runs"]["using"] == "composite" - assert a["name"] == "RAC PR gate" + assert a["name"] == "As Decided Gatekeeper" def test_action_declares_expected_inputs(): inputs = _action()["inputs"] for name in ("path", "upload-sarif", "sarif-dir", "asdecided-version"): assert name in inputs, f"missing input: {name}" - assert inputs["path"]["default"] == "rac" + assert inputs["path"]["default"] == "decisions" assert inputs["upload-sarif"]["default"] == "true" @@ -51,7 +51,7 @@ def test_action_uploads_single_sarif_once(): 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" + assert uploads[0]["with"]["category"] == "asdecided-gatekeeper" def test_action_resurfaces_exit_code(): @@ -63,4 +63,4 @@ def test_action_installs_verified_native_engine(): run_steps = " ".join(s.get("run", "") for s in _action()["runs"]["steps"]) assert "shared/install-native.sh" in run_steps assert "pip install" not in run_steps - assert _action()["inputs"]["asdecided-version"]["default"] == "0.23.1" + assert _action()["inputs"]["asdecided-version"]["default"] == "0.24.0" diff --git a/tests/test_sentry_action.py b/tests/test_sentry_action.py new file mode 100644 index 0000000..68965d5 --- /dev/null +++ b/tests/test_sentry_action.py @@ -0,0 +1,38 @@ +from pathlib import Path + +import yaml + + +ACTION = Path(__file__).parent.parent / "sentry" / "github" / "action.yml" + + +def action() -> dict: + return yaml.safe_load(ACTION.read_text(encoding="utf-8")) + + +def test_sentry_is_thin_native_composite(): + manifest = action() + assert manifest["runs"]["using"] == "composite" + commands = " ".join(step.get("run", "") for step in manifest["runs"]["steps"]) + assert "decided sentry" in commands + assert "--sarif" in commands + assert "python" not in commands + assert "pip install" not in commands + + +def test_sentry_supports_diff_and_full_tree_modes(): + inputs = action()["inputs"] + assert inputs["base"]["default"] == "" + assert inputs["full"]["default"] == "false" + commands = " ".join(step.get("run", "") for step in action()["runs"]["steps"]) + assert "--full" in commands + assert "--base" in commands + + +def test_sentry_uploads_one_sarif_and_resurfaces_exit(): + steps = action()["runs"]["steps"] + uploads = [step for step in steps if "upload-sarif" in str(step.get("uses", ""))] + assert len(uploads) == 1 + assert uploads[0]["with"]["category"] == "asdecided-sentry" + commands = " ".join(step.get("run", "") for step in steps) + assert 'exit "$EXIT_CODE"' in commands diff --git a/tests/test_validate_action.py b/tests/test_validate_action.py index 64db012..dc117b6 100644 --- a/tests/test_validate_action.py +++ b/tests/test_validate_action.py @@ -22,14 +22,14 @@ def _action() -> dict: def test_action_is_composite(): a = _action() assert a["runs"]["using"] == "composite" - assert a["name"] == "RAC Registrar" + assert a["name"] == "As Decided Registrar" def test_action_declares_expected_inputs(): inputs = _action()["inputs"] for name in ("path", "upload-sarif", "sarif-file", "asdecided-version"): assert name in inputs, f"missing input: {name}" - assert inputs["path"]["default"] == "rac" + assert inputs["path"]["default"] == "decisions" assert inputs["upload-sarif"]["default"] == "true" @@ -58,4 +58,4 @@ def test_action_installs_verified_native_engine(): run_steps = " ".join(s.get("run", "") for s in _action()["runs"]["steps"]) assert "shared/install-native.sh" in run_steps assert "pip install" not in run_steps - assert _action()["inputs"]["asdecided-version"]["default"] == "0.23.1" + assert _action()["inputs"]["asdecided-version"]["default"] == "0.24.0" diff --git a/watchkeeper/github/action.yml b/watchkeeper/github/action.yml index 3bbf2b9..f2bb80b 100644 --- a/watchkeeper/github/action.yml +++ b/watchkeeper/github/action.yml @@ -5,7 +5,7 @@ # 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" +name: "As Decided Watchkeeper" description: >- Review product knowledge changes (requirements, decisions, roadmaps, designs, prompts) on pull requests: changed artifacts, validation and @@ -21,7 +21,7 @@ inputs: description: >- Corpus directory to compare (passed to `decided watchkeeper`). required: false - default: "rac" + default: "decisions" base: description: >- Base state: a git revision or a directory. Empty (the default) @@ -44,7 +44,7 @@ inputs: description: >- Verified native asdecided-core release to install. required: false - default: "0.23.1" + default: "0.24.0" runs: using: "composite"