# Skill approval check. Copy into .github/workflows/ and set the three `env` values.
# Proves: the ONE skill/record pair in `env` is self-consistent, and so was the base.
# Cannot prove: that the change should be approved (CODEOWNERS + branch protection). Audits no approvals directory.
# Rationale for every block: README.md next to this file.
name: Skill approval check

# pull_request, never pull_request_target. This file runs as the pull request
# writes it, so put `.github/workflows/**` under CODEOWNERS (see README.md).
on:
  pull_request:

# Read-only: no secrets, no write permission (see README.md).
permissions:
  contents: read

env:
  # The skill directory and its approval record, repository-relative.
  SKILL_DIR: .claude/skills/log-summarizer
  APPROVAL: .sigildex/approvals/log-summarizer.lock.json
  # Pin the tool; update it like any other dependency.
  SIGILDEX_VERSION: 0.1.2

jobs:
  approval-check:
    runs-on: ubuntu-latest
    steps:
      - name: Check out the pull request head
        uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
        with:
          persist-credentials: false

      - name: Set up Node.js
        uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
        with:
          node-version: 20

      - name: Install the approval tool outside the workspace
        env:
          # No dependency lifecycle scripts during the tool install.
          NPM_CONFIG_IGNORE_SCRIPTS: "true"
        run: |
          set -euo pipefail
          # Installed OUTSIDE the checkout: resolution that starts in the
          # workspace would prefer a package the pull request committed (see README.md).
          TOOL="$RUNNER_TEMP/sigildex-tool"
          mkdir -p "$TOOL"
          # A private package.json pins the install root; entering the directory
          # keeps npm from reading a committed .npmrc.
          printf '%s\n' '{"name":"sigildex-tool-install","private":true}' > "$TOOL/package.json"
          cd "$TOOL"
          npm install --no-save --ignore-scripts --no-audit --no-fund "sigildex@$SIGILDEX_VERSION"
          [ -x "$TOOL/node_modules/.bin/sigildex" ] \
            || { echo "::error::sigildex@$SIGILDEX_VERSION did not install an executable at $TOOL/node_modules/.bin/sigildex."; exit 1; }

      - name: Materialize the base revision
        env:
          # The base commit from the event payload, never origin/main (see README.md).
          BASE_SHA: ${{ github.event.pull_request.base.sha }}
        run: |
          set -euo pipefail
          # The default checkout is shallow and may not contain the base commit.
          git fetch --no-tags --depth=1 origin "$BASE_SHA"
          # A detached worktree gives the base tree as plain files without
          # touching the checked-out pull request tree.
          git worktree add --detach "$RUNNER_TEMP/base" "$BASE_SHA"

      - name: Check approval consistency
        env:
          BASE_SHA: ${{ github.event.pull_request.base.sha }}
        run: |
          set -euo pipefail
          BASE="$RUNNER_TEMP/base"
          # Absolute path into the install above. A bare name or `npx` would
          # resolve through the pull request's own tree (see README.md).
          SIGILDEX="$RUNNER_TEMP/sigildex-tool/node_modules/.bin/sigildex"
          # Nothing from the candidate skill is executed, sourced, or installed;
          # it is only read and hashed.
          SUMMARY="${GITHUB_STEP_SUMMARY}"
          echo "### Skill approval check" >> "$SUMMARY"

          fail() { echo "::error::$1"; printf '\n**Failed:** %s\n' "$1" >> "$SUMMARY"; exit 1; }
          note() { echo "$1"; printf '\n%s\n' "$1" >> "$SUMMARY"; }

          [ -x "$SIGILDEX" ] \
            || fail "The approval tool is not installed at the expected path. The install step must run before this one."

          # Only pull_request supplies a base commit. An empty one would make
          # `git diff ""` read as "changed", a pass built on nothing. Refuse.
          git rev-parse --verify --quiet "${BASE_SHA:-}^{commit}" > /dev/null \
            || fail "BASE_SHA is not a commit in this checkout. This workflow requires the pull_request trigger."

          # What exists on each side.
          [ -d "$BASE/$SKILL_DIR" ] && base_skill=yes || base_skill=no
          [ -f "$BASE/$APPROVAL" ]  && base_lock=yes  || base_lock=no
          [ -d "$SKILL_DIR" ]       && head_skill=yes || head_skill=no
          [ -f "$APPROVAL" ]        && head_lock=yes  || head_lock=no
          # What this pull request touched, per Git.
          git diff --quiet "$BASE_SHA" -- "$SKILL_DIR" && skill_changed=no || skill_changed=yes
          git diff --quiet "$BASE_SHA" -- "$APPROVAL"  && lock_changed=no  || lock_changed=yes

          # Prove the base before any outcome is decided, INCLUDING the passing
          # ones: a removal, a record-only change and a no-op are all statements
          # about the base revision (see README.md).
          if [ "$base_skill" = yes ] && [ "$base_lock" = yes ]; then
            base_status=0
            "$SIGILDEX" check "$BASE/$SKILL_DIR" --against "$BASE/$APPROVAL" > /dev/null || base_status=$?
            case "$base_status" in
              0) : ;;
              2) fail "The base revision's skill does not match its own approval record." ;;
              3) fail "The base revision's $APPROVAL is not a valid approval record for this tool version." ;;
              *) fail "sigildex check could not complete for the base revision (exit $base_status)." ;;
            esac
          fi

          # Removal and the partial states around it: a skill and its record
          # arrive and depart together.
          if [ "$head_skill" = no ] && [ "$head_lock" = no ]; then
            if [ "$base_skill" = yes ] && [ "$base_lock" = yes ]; then
              note "Skill and approval record removed together."; exit 0
            elif [ "$base_skill" = no ] && [ "$base_lock" = no ]; then
              note "No skill and no approval record on either side; nothing to check."; exit 0
            fi
            fail "The base revision is already in a partial state (skill: $base_skill, approval: $base_lock)."
          fi
          if [ "$head_skill" = no ]; then fail "Approval record $APPROVAL has no artifact at $SKILL_DIR."; fi
          if [ "$head_lock" = no ]; then fail "Skill $SKILL_DIR has no approval record at $APPROVAL."; fi

          # Informational approval delta for the reviewer: exit 0 (identical) or
          # 2 (differ); any other status means the walk failed and there is no delta.
          if [ "$base_skill" = yes ]; then
            diff_status=0
            "$SIGILDEX" diff "$BASE/$SKILL_DIR" "$SKILL_DIR" --json > "$RUNNER_TEMP/delta.json" || diff_status=$?
            case "$diff_status" in
              0|2) : ;;
              *) fail "Could not compare the base and candidate skill directories (exit $diff_status)." ;;
            esac
          fi

          # The gate: does the candidate skill match the candidate record?
          # Consistent or unchanged -> 0; skill or record changed alone -> 2.
          check_status=0
          "$SIGILDEX" check "$SKILL_DIR" --against "$APPROVAL" || check_status=$?
          case "$check_status" in
            0) : ;;
            2) fail "$SKILL_DIR does not match $APPROVAL. Re-review the skill, then re-run sigildex lock." ;;
            3) fail "$APPROVAL is not a valid approval record for this tool version." ;;
            *) fail "sigildex check could not complete (exit $check_status)." ;;
          esac

          # Category counts only, never skill content: the summary renders
          # Markdown. Counts are advisory; the exit codes above are the gate.
          if [ -s "${RUNNER_TEMP}/delta.json" ]; then
            # Parsed from the JSON, not scanned from its text: the report embeds
            # the skill's frontmatter verbatim (see README.md).
            counts=$(node -e '
              const report = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8"));
              const size = (key) => (Array.isArray(report[key]) ? report[key].length : 0);
              process.stdout.write([size("added"), size("removed"), size("changed")].join(" "));
            ' "${RUNNER_TEMP}/delta.json") || fail "Could not read the approval delta."
            added_count=${counts%% *}; counts_rest=${counts#* }
            removed_count=${counts_rest%% *}; changed_count=${counts_rest#* }
            {
              printf '\n| category | files |\n| --- | --- |\n'
              printf '| added | %d |\n' "$added_count"
              printf '| removed | %d |\n' "$removed_count"
              printf '| changed | %d |\n' "$changed_count"
            } >> "$SUMMARY"
          fi

          if [ "$lock_changed" = yes ] && [ "$skill_changed" = no ]; then
            note "The approval record changed on its own. Consistent, but only a human reviewer can approve it."
          elif [ "$skill_changed" = yes ]; then
            note "Skill and approval record changed consistently. Approval is a human decision, not this result."
          else
            note "Neither the skill nor its approval record changed."
          fi
