diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f5bb17a1a..074f95b70 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -69,7 +69,8 @@ jobs: tests/test_skill_evals.bats \ tests/test_holdout_guard.bats \ tests/test_skill_eval_report.bats \ - tests/test_eval_health_notify.bats + tests/test_eval_health_notify.bats \ + tests/test_consumer_manifest.bats validate-agent-profiles: runs-on: ubuntu-latest diff --git a/scripts/lib/consumer-manifest.json b/scripts/lib/consumer-manifest.json new file mode 100644 index 000000000..782bf36e1 --- /dev/null +++ b/scripts/lib/consumer-manifest.json @@ -0,0 +1,169 @@ +{ + "consumers": [ + { + "refs": [ + ".github-private/.github/workflows/dev-lead-reusable.yml", + ".github/.github/workflows/add-to-project-reusable.yml", + ".github/.github/workflows/agent-shield-reusable.yml", + ".github/.github/workflows/auto-rebase-reusable.yml", + ".github/.github/workflows/dependabot-automerge-reusable.yml", + ".github/.github/workflows/dependabot-rebase-reusable.yml", + ".github/.github/workflows/feature-ideation-reusable.yml", + ".github/.github/workflows/pr-auto-review-reusable.yml", + ".github/.github/workflows/pr-review-mention-reusable.yml" + ], + "repo": "petry-projects/.github" + }, + { + "refs": [ + ".github-private/.github/workflows/actions-fleet-monitor.yml", + ".github-private/.github/workflows/auto-rebase-health.yml", + ".github-private/.github/workflows/ci-failure-analyst-reusable.yml", + ".github-private/.github/workflows/dev-lead-reusable.yml", + ".github-private/.github/workflows/pr-review.yml", + ".github-private/.github/workflows/token-report.yml", + ".github/.github/workflows/add-to-project-reusable.yml", + ".github/.github/workflows/agent-shield-reusable.yml", + ".github/.github/workflows/auto-rebase-reusable.yml", + ".github/.github/workflows/dependabot-automerge-reusable.yml", + ".github/.github/workflows/dependency-audit-reusable.yml", + ".github/.github/workflows/feature-ideation-reusable.yml", + ".github/.github/workflows/pr-review-mention-reusable.yml" + ], + "repo": "petry-projects/.github-private" + }, + { + "refs": [ + ".github-private/.github/workflows/ci-failure-analyst-reusable.yml", + ".github-private/.github/workflows/dev-lead-reusable.yml", + ".github-private/.github/workflows/pr-review.yml", + ".github/.github/workflows/add-to-project-reusable.yml", + ".github/.github/workflows/agent-shield-reusable.yml", + ".github/.github/workflows/auto-rebase-reusable.yml", + ".github/.github/workflows/dependabot-automerge-reusable.yml", + ".github/.github/workflows/dependabot-rebase-reusable.yml", + ".github/.github/workflows/dependency-audit-reusable.yml", + ".github/.github/workflows/pr-auto-review-reusable.yml", + ".github/.github/workflows/pr-review-mention-reusable.yml" + ], + "repo": "petry-projects/ContentTwin" + }, + { + "refs": [ + ".github-private/.github/workflows/ci-failure-analyst-reusable.yml", + ".github-private/.github/workflows/dev-lead-reusable.yml", + ".github-private/.github/workflows/pr-review-reusable.yml", + ".github/.github/workflows/add-to-project-reusable.yml", + ".github/.github/workflows/agent-shield-reusable.yml", + ".github/.github/workflows/auto-rebase-reusable.yml", + ".github/.github/workflows/dependabot-automerge-reusable.yml", + ".github/.github/workflows/dependabot-rebase-reusable.yml", + ".github/.github/workflows/dependency-audit-reusable.yml", + ".github/.github/workflows/feature-ideation-reusable.yml", + ".github/.github/workflows/pr-auto-review-reusable.yml", + ".github/.github/workflows/pr-review-mention-reusable.yml" + ], + "repo": "petry-projects/TalkTerm" + }, + { + "refs": [ + ".github-private/.github/workflows/ci-failure-analyst-reusable.yml", + ".github-private/.github/workflows/dev-lead-reusable.yml", + ".github-private/.github/workflows/pr-review.yml", + ".github/.github/workflows/add-to-project-reusable.yml", + ".github/.github/workflows/agent-shield-reusable.yml", + ".github/.github/workflows/auto-rebase-reusable.yml", + ".github/.github/workflows/claude-code-reusable.yml", + ".github/.github/workflows/dependabot-automerge-reusable.yml", + ".github/.github/workflows/dependabot-rebase-reusable.yml", + ".github/.github/workflows/dependency-audit-reusable.yml", + ".github/.github/workflows/pr-auto-review-reusable.yml", + ".github/.github/workflows/pr-review-mention-reusable.yml" + ], + "repo": "petry-projects/bmad-bgreat-suite" + }, + { + "refs": [ + ".github-private/.github/workflows/ci-failure-analyst-reusable.yml", + ".github-private/.github/workflows/dev-lead-reusable.yml", + ".github-private/.github/workflows/pr-review-reusable.yml", + ".github/.github/workflows/add-to-project-reusable.yml", + ".github/.github/workflows/agent-shield-reusable.yml", + ".github/.github/workflows/auto-rebase-reusable.yml", + ".github/.github/workflows/dependabot-automerge-reusable.yml", + ".github/.github/workflows/dependabot-rebase-reusable.yml", + ".github/.github/workflows/dependency-audit-reusable.yml", + ".github/.github/workflows/feature-ideation-reusable.yml", + ".github/.github/workflows/pr-auto-review-reusable.yml", + ".github/.github/workflows/pr-review-mention-reusable.yml" + ], + "repo": "petry-projects/broodly" + }, + { + "refs": [ + ".github-private/.github/workflows/ci-failure-analyst-reusable.yml", + ".github-private/.github/workflows/dev-lead-reusable.yml", + ".github-private/.github/workflows/pr-review.yml", + ".github/.github/workflows/add-to-project-reusable.yml", + ".github/.github/workflows/agent-shield-reusable.yml", + ".github/.github/workflows/auto-rebase-reusable.yml", + ".github/.github/workflows/dependabot-automerge-reusable.yml", + ".github/.github/workflows/dependabot-rebase-reusable.yml", + ".github/.github/workflows/dependency-audit-reusable.yml", + ".github/.github/workflows/feature-ideation-reusable.yml", + ".github/.github/workflows/pr-auto-review-reusable.yml", + ".github/.github/workflows/pr-review-mention-reusable.yml" + ], + "repo": "petry-projects/google-app-scripts" + }, + { + "refs": [ + ".github-private/.github/workflows/ci-failure-analyst-reusable.yml", + ".github-private/.github/workflows/dev-lead-reusable.yml", + ".github-private/.github/workflows/pr-review.yml", + ".github/.github/workflows/add-to-project-reusable.yml", + ".github/.github/workflows/agent-shield-reusable.yml", + ".github/.github/workflows/auto-rebase-reusable.yml", + ".github/.github/workflows/dependabot-automerge-reusable.yml", + ".github/.github/workflows/dependabot-rebase-reusable.yml", + ".github/.github/workflows/dependency-audit-reusable.yml", + ".github/.github/workflows/feature-ideation-reusable.yml", + ".github/.github/workflows/pr-auto-review-reusable.yml", + ".github/.github/workflows/pr-review-mention-reusable.yml" + ], + "repo": "petry-projects/markets" + } + ], + "providers": [ + ".github", + ".github-private" + ], + "surface_sources": { + ".github/workflows/dev-lead-reusable.yml": [ + "prompts/dev-lead/fix-bot-comment.md", + "prompts/dev-lead/fix-ci.md", + "prompts/dev-lead/fix-issue.md", + "prompts/dev-lead/fix-reviews.md", + "prompts/dev-lead/on-mention.md", + "prompts/dev-lead/rebase.md", + "prompts/dev-lead/review-changes.md", + "scripts/lib/auto-merge.sh", + "scripts/lib/git-identity.sh", + "scripts/lib/pr-worktree.sh" + ], + ".github/workflows/pr-review.yml": [ + "prompts/cascade-action.md", + "prompts/deep-review.md", + "prompts/rubber-duck.md", + "prompts/security-audit.md", + "prompts/single-review.md", + "prompts/synthesize-duck.md", + "prompts/triage.md", + "scripts/lib/advisory-review-gate.sh", + "scripts/lib/ci-status.sh", + "scripts/lib/review-cycle.sh", + "scripts/lib/review-registry.sh", + "scripts/lib/token-metrics.sh" + ] + } +} diff --git a/scripts/lib/validate-consumer-manifest.sh b/scripts/lib/validate-consumer-manifest.sh new file mode 100755 index 000000000..b21b161b2 --- /dev/null +++ b/scripts/lib/validate-consumer-manifest.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +set -euo pipefail +# validate-consumer-manifest.sh — schema + local-surface validator for the +# consumer manifest (issue #749, epic #748). +# +# The manifest (consumer-manifest.json, same directory) is the single +# machine-checkable source of truth mapping each provider reusable workflow to +# the org repos (consumers) that pin it, plus the in-provider scripts/lib + prompts +# each reusable sources (surface_sources). This validator is the gate that keeps +# the manifest honest. It exits non-zero on: +# - invalid JSON or a violated top-level schema shape +# - a surface_sources KEY that is not an existing reusable-workflow path in +# this repo (a file under .github/workflows/ that declares `workflow_call`) +# - a surface_sources VALUE that is not an existing scripts/lib/*.sh or prompts/* +# path in this repo +# +# Existence is always checked against --repo-root (the .github-private working +# tree), independent of where the manifest file itself lives, so a fixture in a +# tmp dir can be validated against the real repo surfaces. +# +# Usage: +# validate-consumer-manifest.sh [--manifest PATH] [--repo-root DIR] +# Env overrides (lower precedence than flags): +# CONSUMER_MANIFEST path to the manifest JSON +# MANIFEST_REPO_ROOT repo root used for local-surface existence checks +# Exit codes: 0 valid; 1 invalid (one or more errors printed to stderr); 2 usage. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +MANIFEST="${CONSUMER_MANIFEST:-$SCRIPT_DIR/consumer-manifest.json}" +REPO_ROOT="${MANIFEST_REPO_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}" + +while [ "$#" -gt 0 ]; do + case "$1" in + --manifest) + if [ "$#" -lt 2 ]; then + echo "validate-consumer-manifest: --manifest requires a value" >&2 + exit 2 + fi + MANIFEST="$2" + shift 2 + ;; + --repo-root) + if [ "$#" -lt 2 ]; then + echo "validate-consumer-manifest: --repo-root requires a value" >&2 + exit 2 + fi + REPO_ROOT="$2" + shift 2 + ;; + -h|--help) + grep '^#' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + exit 0 ;; + *) + echo "validate-consumer-manifest: unknown argument '$1'" >&2 + exit 2 ;; + esac +done + +errors=0 +err() { echo "validate-consumer-manifest: $*" >&2; errors=$((errors + 1)); } + +[ -f "$MANIFEST" ] || { err "manifest not found: $MANIFEST"; exit 1; } + +# 1. Valid JSON. jq empty parses without emitting output; any syntax error here +# means the whole manifest is unusable, so fail fast. +if ! jq empty "$MANIFEST" 2>/dev/null; then + err "manifest is not valid JSON: $MANIFEST" + exit 1 +fi + +# 2. Top-level schema shape: +# providers = non-empty array of strings +# consumers = array of {repo: string, refs: [string]} +# surface_sources = object whose values are arrays of strings +schema_ok=$(jq -r ' + . as $manifest | + def is_str_array: type == "array" and (all(.[]; type == "string")); + def valid_ref: . as $ref | $manifest.providers | any(. as $p | ($ref | startswith($p + "/"))); + ( ($manifest.providers | type == "array" and length > 0 and is_str_array) + and ($manifest.consumers | type == "array" + and all(.[]; type == "object" + and (.repo | type == "string") + and (.refs | is_str_array and all(.[]; valid_ref)))) + and ($manifest.surface_sources | type == "object" + and all(.[]; is_str_array)) + ) +' "$MANIFEST" 2>/dev/null || echo "false") + +if [ "$schema_ok" != "true" ]; then + err "manifest violates the required schema (providers/consumers/surface_sources shape)" + exit 1 +fi + +# 3. surface_sources keys must be existing reusable-workflow paths in this repo; +# their values must be existing scripts/lib/*.sh or prompts/* paths in this repo. +# Paths are resolved via realpath to prevent traversal sequences from escaping +# their intended root directories. +wf_root=$(realpath -m "$REPO_ROOT/.github/workflows") +lib_root=$(realpath -m "$REPO_ROOT/scripts/lib") +prompts_root=$(realpath -m "$REPO_ROOT/prompts") + +while IFS= read -r key; do + [ -n "$key" ] || continue + case "$key" in + .github/workflows/*) : ;; + *) err "surface_sources key is not under .github/workflows/: $key"; continue ;; + esac + resolved=$(realpath -m "$REPO_ROOT/$key") + if [ "${resolved#"${wf_root}/"}" = "$resolved" ]; then + err "surface_sources key contains path traversal: $key" + continue + fi + if [ ! -f "$resolved" ]; then + err "surface_sources key is not an existing reusable-workflow path: $key" + continue + fi + if ! grep -q 'workflow_call' "$resolved"; then + err "surface_sources key is not a reusable workflow (no workflow_call): $key" + fi +done < <(jq -r '.surface_sources | keys[]' "$MANIFEST") + +while IFS= read -r value; do + [ -n "$value" ] || continue + case "$value" in + scripts/lib/*.sh|prompts/*) : ;; + *) + err "surface_sources value is not a scripts/lib/*.sh or prompts/* path: $value" + continue ;; + esac + resolved=$(realpath -m "$REPO_ROOT/$value") + if [ "${resolved#"${lib_root}/"}" = "$resolved" ] && [ "${resolved#"${prompts_root}/"}" = "$resolved" ]; then + err "surface_sources value contains path traversal: $value" + continue + fi + if [ ! -f "$resolved" ]; then + err "surface_sources value points at a non-existent local surface: $value" + fi +done < <(jq -r '.surface_sources | to_entries[] | .value[]' "$MANIFEST") + +if [ "$errors" -ne 0 ]; then + echo "validate-consumer-manifest: FAILED with $errors error(s)" >&2 + exit 1 +fi + +echo "validate-consumer-manifest: OK ($MANIFEST)" diff --git a/scripts/refresh-consumer-manifest.sh b/scripts/refresh-consumer-manifest.sh new file mode 100755 index 000000000..a42ceaef3 --- /dev/null +++ b/scripts/refresh-consumer-manifest.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +set -euo pipefail +# refresh-consumer-manifest.sh — empirically repopulate the consumer manifest's +# `consumers` map (issue #749, epic #748). +# +# Consumers are NOT hand-curated: this script enumerates every repo in the org +# and detects which provider reusable workflows each one pins via `uses:`. The +# `providers` list and the hand-maintained `surface_sources` map are preserved +# from the existing manifest verbatim — this script only refreshes `consumers`. +# +# Output is written deterministically (sorted object keys, consumers sorted by +# repo, refs sorted + de-duplicated) so re-runs produce a clean, reviewable diff. +# +# Auth: uses GH_TOKEN exactly as .github/workflows/pr-review.yml does — pass the +# bot PAT (DON_PETRY_BOT_GH_PAT_CLASSIC || DON_PETRY_BOT_GH_PAT, surfaced as +# GH_PAT in that workflow). The org enumeration needs read access across the +# consumer repos, which that token already has. +# +# Usage: +# refresh-consumer-manifest.sh [--manifest PATH] [--org ORG] [--dry-run] +# Env overrides (lower precedence than flags): +# CONSUMER_MANIFEST path to the manifest JSON (default: scripts/lib/consumer-manifest.json) +# TARGET_ORG org to enumerate (default: petry-projects) +# --dry-run prints the regenerated manifest to stdout instead of writing it. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +MANIFEST="${CONSUMER_MANIFEST:-$SCRIPT_DIR/lib/consumer-manifest.json}" +ORG="${TARGET_ORG:-petry-projects}" +DRY_RUN=0 + +while [ "$#" -gt 0 ]; do + case "$1" in + --manifest) + if [ "$#" -lt 2 ]; then + echo "refresh-consumer-manifest: --manifest requires a value" >&2 + exit 2 + fi + MANIFEST="$2" + shift 2 + ;; + --org) + if [ "$#" -lt 2 ]; then + echo "refresh-consumer-manifest: --org requires a value" >&2 + exit 2 + fi + ORG="$2" + shift 2 + ;; + --dry-run) DRY_RUN=1; shift ;; + -h|--help) + grep '^#' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + exit 0 ;; + *) + echo "refresh-consumer-manifest: unknown argument '$1'" >&2 + exit 2 ;; + esac +done + +command -v gh >/dev/null 2>&1 || { echo "refresh-consumer-manifest: gh CLI not found" >&2; exit 1; } +command -v jq >/dev/null 2>&1 || { echo "refresh-consumer-manifest: jq not found" >&2; exit 1; } + +# Verify GH CLI authentication and access to the organization +if ! gh api "orgs/$ORG" >/dev/null 2>&1; then + echo "refresh-consumer-manifest: error: cannot access organization '$ORG'. Please check your GH_TOKEN / authentication." >&2 + exit 1 +fi + +# Preserve providers + surface_sources from the existing manifest; default to the +# fixed provider list (#653) when no manifest exists yet. +if [ -f "$MANIFEST" ] && jq empty "$MANIFEST" 2>/dev/null; then + providers_json=$(jq -c '.providers // [".github", ".github-private"]' "$MANIFEST") + surface_json=$(jq -c '.surface_sources // {}' "$MANIFEST") +else + providers_json='[".github", ".github-private"]' + surface_json='{}' +fi + +# Build a regex of provider repos to match in `uses:` refs, derived from the +# preserved providers list so the two stay in sync. +providers_alt=$(printf '%s' "$providers_json" \ + | jq -r '.[]' \ + | sed 's/[.]/\\./g' \ + | paste -sd'|' -) + +pairs_file="$(mktemp)" +repos_file="$(mktemp)" +trap 'rm -f "$pairs_file" "$repos_file"' EXIT + +# Enumerate all repos in the org using pagination (no 500-item cap). +if ! gh api "orgs/$ORG/repos" --paginate --jq '.[].full_name' > "$repos_file"; then + echo "refresh-consumer-manifest: error: failed to list repositories for org '$ORG'" >&2 + exit 1 +fi + +# Scan each repo's workflow files for `uses:` references to a provider reusable +# workflow. Each detection is recorded as a "\t/.github/workflows/.yml" line. +while IFS= read -r repo; do + [ -n "$repo" ] || continue + while IFS= read -r wf_path; do + [ -n "$wf_path" ] || continue + case "$wf_path" in *.yml|*.yaml) : ;; *) continue ;; esac + content_b64=$(gh api "repos/$repo/contents/$wf_path" --jq '.content' 2>/dev/null || true) + if [ -n "$content_b64" ] && [ "$content_b64" != "null" ]; then + content=$(printf '%s' "$content_b64" | tr -d '\r\n' | jq -Rr '@base64d' 2>/dev/null || true) + else + content="" + fi + [ -n "$content" ] || continue + matches=$(printf '%s\n' "$content" \ + | grep -oE "${ORG}/(${providers_alt})/\.github/workflows/[A-Za-z0-9_.-]+\.yml" \ + | sed "s#^${ORG}/##" || true) + [ -n "$matches" ] || continue + while IFS= read -r ref; do + [ -n "$ref" ] || continue + printf '%s\t%s\n' "$repo" "$ref" >> "$pairs_file" + done <<< "$matches" + done < <(gh api "repos/$repo/contents/.github/workflows" --jq '.[].path' 2>/dev/null || true) +done < "$repos_file" + +# Group detections into the consumers array: one entry per repo with sorted, +# de-duplicated refs. group_by sorts by repo, giving a stable order. +consumers_json=$(sort -u "$pairs_file" | jq -R -s ' + split("\n") + | map(select(length > 0) | split("\t")) + | group_by(.[0]) + | map({ repo: .[0][0], refs: (map(.[1]) | unique) }) +') + +manifest_json=$(jq -n --sort-keys \ + --argjson providers "$providers_json" \ + --argjson consumers "$consumers_json" \ + --argjson surface_sources "$surface_json" \ + '{ providers: $providers, consumers: $consumers, surface_sources: $surface_sources }') + +if [ "$DRY_RUN" -eq 1 ]; then + printf '%s\n' "$manifest_json" +else + printf '%s\n' "$manifest_json" > "$MANIFEST" + echo "refresh-consumer-manifest: wrote $(jq '.consumers | length' "$MANIFEST") consumer(s) to $MANIFEST" >&2 +fi diff --git a/tests/test_consumer_manifest.bats b/tests/test_consumer_manifest.bats new file mode 100644 index 000000000..436f5897b --- /dev/null +++ b/tests/test_consumer_manifest.bats @@ -0,0 +1,158 @@ +#!/usr/bin/env bats +# Unit tests for the consumer manifest + its validator (issue #749, epic #748). +# +# The manifest (scripts/lib/consumer-manifest.json) is the single machine-checkable +# source of truth that maps each provider reusable workflow to the org repos +# (consumers) that pin it, plus the in-provider scripts/lib + prompts each reusable +# sources (surface_sources). The validator (scripts/lib/validate-consumer-manifest.sh) +# is the gate: it must reject invalid JSON/schema, a surface_sources key that is not +# an existing reusable-workflow path in this repo, and a surface_sources value that +# does not point at an existing scripts/lib or prompts file in this repo. +# +# Run with: bats tests/test_consumer_manifest.bats + +setup() { + REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + VALIDATOR="$REPO_ROOT/scripts/lib/validate-consumer-manifest.sh" + MANIFEST="$REPO_ROOT/scripts/lib/consumer-manifest.json" + FIXTURE="$BATS_TEST_TMPDIR/manifest.json" +} + +# Existence checks always resolve against the real repo root, so a fixture can +# reference real-or-bogus surfaces while living in the tmp dir. +run_validator() { + run "$VALIDATOR" --manifest "$1" --repo-root "$REPO_ROOT" +} + +# --------------------------------------------------------------------------- +# Happy path — the committed manifest is well-formed and self-consistent +# --------------------------------------------------------------------------- + +@test "committed manifest passes the validator" { + run_validator "$MANIFEST" + [ "$status" -eq 0 ] +} + +@test "committed manifest is valid JSON with the required top-level keys" { + run jq -e '.providers and .consumers and .surface_sources' "$MANIFEST" + [ "$status" -eq 0 ] +} + +@test "committed manifest declares both providers" { + run jq -e '.providers == [".github", ".github-private"]' "$MANIFEST" + [ "$status" -eq 0 ] +} + +# --------------------------------------------------------------------------- +# Malformed JSON fails +# --------------------------------------------------------------------------- + +@test "malformed JSON fails validation" { + printf '{ "providers": [".github",,] ' > "$FIXTURE" + run_validator "$FIXTURE" + [ "$status" -ne 0 ] +} + +# --------------------------------------------------------------------------- +# Schema-shape violations fail +# --------------------------------------------------------------------------- + +@test "missing surface_sources key fails validation" { + jq 'del(.surface_sources)' "$MANIFEST" > "$FIXTURE" + run_validator "$FIXTURE" + [ "$status" -ne 0 ] +} + +@test "consumer entry missing repo fails validation" { + jq '.consumers = [{"refs": [".github-private/.github/workflows/pr-review.yml"]}]' \ + "$MANIFEST" > "$FIXTURE" + run_validator "$FIXTURE" + [ "$status" -ne 0 ] +} + +@test "consumer entry with non-array refs fails validation" { + jq '.consumers = [{"repo": "petry-projects/markets", "refs": "nope"}]' \ + "$MANIFEST" > "$FIXTURE" + run_validator "$FIXTURE" + [ "$status" -ne 0 ] +} + +# --------------------------------------------------------------------------- +# surface_sources referencing a non-existent local surface fails +# --------------------------------------------------------------------------- + +@test "surface_sources key that is not an existing reusable workflow fails" { + jq '.surface_sources = {".github/workflows/does-not-exist.yml": []}' \ + "$MANIFEST" > "$FIXTURE" + run_validator "$FIXTURE" + [ "$status" -ne 0 ] +} + +@test "surface_sources value pointing at a missing scripts/lib file fails" { + jq '.surface_sources = {".github/workflows/pr-review.yml": ["scripts/lib/nope-does-not-exist.sh"]}' \ + "$MANIFEST" > "$FIXTURE" + run_validator "$FIXTURE" + [ "$status" -ne 0 ] +} + +@test "surface_sources value pointing at a missing prompts file fails" { + jq '.surface_sources = {".github/workflows/pr-review.yml": ["prompts/nope-does-not-exist.md"]}' \ + "$MANIFEST" > "$FIXTURE" + run_validator "$FIXTURE" + [ "$status" -ne 0 ] +} + +@test "surface_sources value outside scripts/lib or prompts fails" { + # A real, existing file that is NOT under scripts/lib/ or prompts/ must still + # be rejected — surface_sources only declares lib/prompt surfaces. + jq '.surface_sources = {".github/workflows/pr-review.yml": ["scripts/engine.sh"]}' \ + "$MANIFEST" > "$FIXTURE" + run_validator "$FIXTURE" + [ "$status" -ne 0 ] +} + +@test "a well-formed minimal manifest with real surfaces passes" { + cat > "$FIXTURE" <<'JSON' +{ + "providers": [".github", ".github-private"], + "consumers": [ + { "repo": "petry-projects/markets", "refs": [".github-private/.github/workflows/pr-review.yml"] } + ], + "surface_sources": { + ".github/workflows/pr-review.yml": ["prompts/single-review.md", "scripts/lib/ci-status.sh"] + } +} +JSON + run_validator "$FIXTURE" + [ "$status" -eq 0 ] +} + +# --------------------------------------------------------------------------- +# Consumer refs must reference a declared provider +# --------------------------------------------------------------------------- + +@test "consumer ref not matching a declared provider fails validation" { + jq '.consumers = [{"repo": "petry-projects/foo", "refs": ["not-a-provider/.github/workflows/foo.yml"]}]' \ + "$MANIFEST" > "$FIXTURE" + run_validator "$FIXTURE" + [ "$status" -ne 0 ] +} + +# --------------------------------------------------------------------------- +# Path traversal in surface_sources is rejected +# --------------------------------------------------------------------------- + +@test "surface_sources key with path traversal is rejected" { + jq '.surface_sources = {".github/workflows/../../scripts/lib/auto-merge.sh": []}' \ + "$MANIFEST" > "$FIXTURE" + run_validator "$FIXTURE" + [ "$status" -ne 0 ] +} + +@test "surface_sources value with path traversal is rejected" { + # scripts/lib/*.sh glob matches this path, but realpath resolves it outside scripts/lib/ + jq '.surface_sources = {".github/workflows/pr-review.yml": ["scripts/lib/../../scripts/engine.sh"]}' \ + "$MANIFEST" > "$FIXTURE" + run_validator "$FIXTURE" + [ "$status" -ne 0 ] +}