👮 Justice Bot #162
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # nullvariant-justice[bot] - The code enforcer | |
| # "テストは?型定義は?エラーハンドリングは?" | |
| name: 👮 Justice Bot | |
| permissions: {} | |
| on: | |
| pull_request_target: | |
| types: [opened, synchronize] | |
| paths: | |
| - '**/package.json' | |
| - '**/package-lock.json' | |
| workflow_run: | |
| workflows: ['CI'] | |
| types: [completed] | |
| workflow_dispatch: | |
| inputs: | |
| message: | |
| description: 'What should Justice inspect? (optional)' | |
| required: false | |
| default: 'Code review in progress...' | |
| jobs: | |
| dependency-review: | |
| name: 👮 Dependency Safety Review | |
| runs-on: ubuntu-latest | |
| if: > | |
| github.event_name == 'pull_request_target' && | |
| (github.actor == 'dependabot[bot]' || github.actor == 'renovate[bot]') | |
| env: | |
| GH_REPO: ${{ github.repository }} | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| steps: | |
| - name: Harden Runner | |
| uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 | |
| with: | |
| egress-policy: audit | |
| - name: 🔑 Get App Token | |
| id: app-token | |
| uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2 | |
| with: | |
| app_id: ${{ secrets.JUSTICE_BOT_APP_ID }} | |
| private_key: ${{ secrets.JUSTICE_BOT_PRIVATE_KEY }} | |
| # SECURITY: Verify PR author via API (not github.actor which is forgeable). | |
| # The job-level `if` on github.actor is a performance optimization only. | |
| - name: 🔒 Verify PR author | |
| id: verify | |
| env: | |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| run: | | |
| PR_AUTHOR=$(gh pr view "$PR_NUMBER" --json author --jq '.author.login') | |
| if [ "$PR_AUTHOR" != "dependabot[bot]" ] && [ "$PR_AUTHOR" != "renovate[bot]" ]; then | |
| echo "::notice::PR author ($PR_AUTHOR) is not a dependency bot. Skipping review." | |
| echo "is_bot=false" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "Verified: PR authored by $PR_AUTHOR" | |
| echo "is_bot=true" >> "$GITHUB_OUTPUT" | |
| fi | |
| # SECURITY: No code checkout. All file reads use the GitHub Contents API. | |
| # This avoids OpenSSF Scorecard "Dangerous-Workflow" alerts for | |
| # pull_request_target + actions/checkout combinations. | |
| - name: 👮 Analyze dependency changes | |
| if: steps.verify.outputs.is_bot == 'true' | |
| id: analyze | |
| env: | |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| BASE_SHA: ${{ github.event.pull_request.base.sha }} | |
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| run: | | |
| set -euo pipefail | |
| VERDICT="approve" | |
| : > /tmp/justice-findings.md | |
| # === Load rules from base branch via API (no checkout needed) === | |
| gh api "repos/${GITHUB_REPOSITORY}/contents/.github/justice-rules.json?ref=${BASE_SHA}" \ | |
| --jq '.content' | base64 -d > /tmp/justice-rules.json | |
| RULES="/tmp/justice-rules.json" | |
| # === Check for security labels === | |
| SECURITY_LABELS=$(gh pr view "$PR_NUMBER" --json labels \ | |
| --jq '[.labels[].name | select(test("security"; "i"))] | length' 2>/dev/null || echo "0") | |
| if [ "$SECURITY_LABELS" -gt 0 ]; then | |
| printf '🔴 **PRIORITY**: Security advisory detected. Expedite review and merge.\n\n' >> /tmp/justice-findings.md | |
| VERDICT="security" | |
| fi | |
| # === Get changed package.json files === | |
| CHANGED_FILES=$(gh pr diff "$PR_NUMBER" --name-only 2>/dev/null || true) | |
| PKG_FILES=$(echo "$CHANGED_FILES" | grep 'package\.json$' || true) | |
| if [ -z "$PKG_FILES" ]; then | |
| echo "No package.json changes detected." | |
| echo "should_comment=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| # === Check blocked packages === | |
| # Get patches for package.json files only (via API for precision) | |
| PKG_PATCHES=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files" \ | |
| --jq '[.[] | select(.filename | endswith("package.json")) | .patch // ""] | join("\n")' 2>/dev/null || true) | |
| while IFS= read -r pkg; do | |
| [ -z "$pkg" ] && continue | |
| # Only check added lines to avoid false positives from removals | |
| if echo "$PKG_PATCHES" | grep '^+' | grep -v '^+++' | grep -qF "\"${pkg}\""; then | |
| VERDICT="block" | |
| REASON=$(jq -r --arg name "$pkg" '.blocked_packages[] | select(.name == $name) | .reason' "$RULES") | |
| printf '🔴 **BLOCKED**: `%s` update detected.\n%s\n\n' "$pkg" "$REASON" >> /tmp/justice-findings.md | |
| fi | |
| done < <(jq -r '.blocked_packages[].name' "$RULES") | |
| # === Check blocked field changes === | |
| while IFS= read -r rule; do | |
| [ -z "$rule" ] || [ "$rule" = "null" ] && continue | |
| FILE=$(echo "$rule" | jq -r '.file') | |
| FIELD=$(echo "$rule" | jq -r '.field_path') | |
| REASON=$(echo "$rule" | jq -r '.reason') | |
| # Only check if the file is in the changed files | |
| if ! echo "$PKG_FILES" | grep -qF "$FILE"; then | |
| continue | |
| fi | |
| # Get base value via API | |
| BASE_VAL=$(gh api "repos/${GITHUB_REPOSITORY}/contents/${FILE}?ref=${BASE_SHA}" \ | |
| --jq '.content' 2>/dev/null | base64 -d 2>/dev/null | jq -r "${FIELD} // empty" 2>/dev/null || true) | |
| # Get head value via API | |
| HEAD_VAL=$(gh api "repos/${GITHUB_REPOSITORY}/contents/${FILE}?ref=${HEAD_SHA}" \ | |
| --jq '.content' 2>/dev/null | base64 -d 2>/dev/null | jq -r "${FIELD} // empty" 2>/dev/null || true) | |
| if [ -n "$BASE_VAL" ] && [ -n "$HEAD_VAL" ] && [ "$BASE_VAL" != "$HEAD_VAL" ]; then | |
| VERDICT="block" | |
| printf '🔴 **BLOCKED**: `%s` changed in `%s`\n- Base: `%s`\n- Head: `%s`\n%s\n\n' \ | |
| "$FIELD" "$FILE" "$BASE_VAL" "$HEAD_VAL" "$REASON" >> /tmp/justice-findings.md | |
| fi | |
| done < <(jq -c '.blocked_field_changes[]' "$RULES" 2>/dev/null) | |
| # === Check for major version bumps === | |
| ESLINT_PKGS=$(jq -r '.major_update_review.eslint_packages[]' "$RULES" 2>/dev/null || true) | |
| ESLINT_PREFIXES=$(jq -r '.major_update_review.eslint_prefixes[]' "$RULES" 2>/dev/null || true) | |
| ESLINT_REASON=$(jq -r '.major_update_review.eslint_reason' "$RULES" 2>/dev/null || true) | |
| DEFAULT_REASON=$(jq -r '.major_update_review.default_reason' "$RULES" 2>/dev/null || true) | |
| while IFS= read -r pkg_file; do | |
| [ -z "$pkg_file" ] && continue | |
| # Get base deps via API | |
| BASE_RAW=$(gh api "repos/${GITHUB_REPOSITORY}/contents/${pkg_file}?ref=${BASE_SHA}" \ | |
| --jq '.content' 2>/dev/null | base64 -d 2>/dev/null || true) | |
| [ -z "$BASE_RAW" ] && continue | |
| BASE_DEPS=$(echo "$BASE_RAW" | jq -S '(.dependencies // {}) + (.devDependencies // {})' 2>/dev/null || echo '{}') | |
| # Get head deps via API | |
| HEAD_RAW=$(gh api "repos/${GITHUB_REPOSITORY}/contents/${pkg_file}?ref=${HEAD_SHA}" \ | |
| --jq '.content' 2>/dev/null | base64 -d 2>/dev/null || true) | |
| [ -z "$HEAD_RAW" ] && continue | |
| HEAD_DEPS=$(echo "$HEAD_RAW" | jq -S '(.dependencies // {}) + (.devDependencies // {})' 2>/dev/null || echo '{}') | |
| # Find packages where major version changed | |
| MAJOR_CHANGES=$(jq -n --argjson base "$BASE_DEPS" --argjson head "$HEAD_DEPS" ' | |
| $head | to_entries[] | | |
| select($base[.key] != null) | | |
| { | |
| key, | |
| old_major: ($base[.key] | gsub("[^0-9.]"; "") | split(".") | if length > 0 then .[0] else "0" end), | |
| new_major: (.value | gsub("[^0-9.]"; "") | split(".") | if length > 0 then .[0] else "0" end), | |
| old_ver: $base[.key], | |
| new_ver: .value | |
| } | | |
| select(.old_major != .new_major) | |
| ' 2>/dev/null || true) | |
| [ -z "$MAJOR_CHANGES" ] && continue | |
| while IFS= read -r change; do | |
| [ -z "$change" ] && continue | |
| PKG_NAME=$(echo "$change" | jq -r '.key') | |
| OLD_VER=$(echo "$change" | jq -r '.old_ver') | |
| NEW_VER=$(echo "$change" | jq -r '.new_ver') | |
| # Check if ESLint-related | |
| IS_ESLINT=false | |
| for ep in $ESLINT_PKGS; do | |
| [ "$PKG_NAME" = "$ep" ] && IS_ESLINT=true | |
| done | |
| for prefix in $ESLINT_PREFIXES; do | |
| if echo "$PKG_NAME" | grep -q "^${prefix}"; then | |
| IS_ESLINT=true | |
| fi | |
| done | |
| if [ "$IS_ESLINT" = true ]; then | |
| printf '⚠️ **CAUTION**: Major update for `%s` (%s → %s). %s\n\n' \ | |
| "$PKG_NAME" "$OLD_VER" "$NEW_VER" "$ESLINT_REASON" >> /tmp/justice-findings.md | |
| else | |
| printf '⚠️ **CAUTION**: Major update for `%s` (%s → %s). %s\n\n' \ | |
| "$PKG_NAME" "$OLD_VER" "$NEW_VER" "$DEFAULT_REASON" >> /tmp/justice-findings.md | |
| fi | |
| done < <(echo "$MAJOR_CHANGES" | jq -c '.') | |
| done <<< "$PKG_FILES" | |
| # Update verdict based on findings | |
| if grep -q 'CAUTION.*Major' /tmp/justice-findings.md 2>/dev/null && [ "$VERDICT" = "approve" ]; then | |
| VERDICT="warn" | |
| fi | |
| # === Default: safe to merge === | |
| if [ ! -s /tmp/justice-findings.md ]; then | |
| printf '🟢 **APPROVED**: Patch/minor update. No blocked patterns detected. Safe to merge after CI passes.\n' >> /tmp/justice-findings.md | |
| fi | |
| echo "should_comment=true" >> "$GITHUB_OUTPUT" | |
| echo "verdict=${VERDICT}" >> "$GITHUB_OUTPUT" | |
| - name: 📬 Post review comment | |
| if: steps.verify.outputs.is_bot == 'true' && steps.analyze.outputs.should_comment == 'true' | |
| env: | |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| VERDICT: ${{ steps.analyze.outputs.verdict }} | |
| run: | | |
| set -euo pipefail | |
| # Determine verdict emoji | |
| case "$VERDICT" in | |
| block) EMOJI="🚫" ;; | |
| warn) EMOJI="⚠️" ;; | |
| security) EMOJI="🚨" ;; | |
| approve) EMOJI="✅" ;; | |
| *) EMOJI="👮" ;; | |
| esac | |
| # Build comment | |
| { | |
| printf '## 👮 ⚖️ Justice'"'"'s Dependency Verdict %s\n\n' "$EMOJI" | |
| cat /tmp/justice-findings.md | |
| printf '\n---\n\n' | |
| printf '> テストは?型定義は?エラーハンドリングは?\n\n' | |
| printf '*Verdict filed by nullvariant-justice[bot]*\n' | |
| } > /tmp/justice-comment.md | |
| # Find existing Justice comment (update if exists, create if not) | |
| # Filter by bot author to prevent spoofing via user comments with the same string | |
| EXISTING_ID=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \ | |
| --jq '.[] | select(.user.login == "nullvariant-justice[bot]") | select(.body | contains("Justice\u0027s Dependency Verdict")) | .id' 2>/dev/null | head -1 || true) | |
| if [ -n "$EXISTING_ID" ]; then | |
| gh api "repos/${GITHUB_REPOSITORY}/issues/comments/${EXISTING_ID}" \ | |
| -X PATCH -F body=@/tmp/justice-comment.md | |
| echo "Updated existing Justice verdict comment." | |
| else | |
| gh pr comment "$PR_NUMBER" --body-file /tmp/justice-comment.md | |
| echo "Posted new Justice verdict comment." | |
| fi | |
| # NOTE: ci-gated-approve also approves after CI passes. This early approval | |
| # allows dependency bot PRs to merge without waiting for full CI when the | |
| # safety verdict is clear. ci-gated-approve has a duplicate check to skip | |
| # if Justice has already approved. | |
| - name: ✅ Approve PR | |
| if: > | |
| steps.verify.outputs.is_bot == 'true' && | |
| steps.analyze.outputs.should_comment == 'true' && | |
| (steps.analyze.outputs.verdict == 'approve' || steps.analyze.outputs.verdict == 'security') | |
| env: | |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| run: | | |
| gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \ | |
| -f event="APPROVE" \ | |
| -f body="⚖️ Justice approves this dependency update. All safety checks passed." | |
| - name: ⚖️ Verdict delivered | |
| if: steps.verify.outputs.is_bot == 'true' && steps.analyze.outputs.should_comment == 'true' | |
| env: | |
| VERDICT: ${{ steps.analyze.outputs.verdict }} | |
| run: | | |
| echo "⚖️ JUSTICE HAS SPOKEN ⚖️" | |
| echo "Verdict: ${VERDICT}" | |
| if [ "$VERDICT" = "block" ]; then | |
| echo "::error::Justice has blocked this PR. See the review comment for details." | |
| exit 1 | |
| fi | |
| ci-gated-approve: | |
| name: 👮 CI-Gated Approval | |
| runs-on: ubuntu-latest | |
| # SECURITY: Reject fork PRs — only same-repo branches are trusted for auto-approval. | |
| if: > | |
| github.event_name == 'workflow_run' && | |
| github.event.workflow_run.event == 'pull_request' && | |
| github.event.workflow_run.conclusion == 'success' && | |
| github.event.workflow_run.head_repository != null && | |
| github.event.workflow_run.head_repository.full_name == github.repository | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| steps: | |
| - name: Harden Runner | |
| uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 | |
| with: | |
| egress-policy: audit | |
| - name: 🔑 Get App Token | |
| id: app-token | |
| uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2 | |
| with: | |
| app_id: ${{ secrets.JUSTICE_BOT_APP_ID }} | |
| private_key: ${{ secrets.JUSTICE_BOT_PRIVATE_KEY }} | |
| # SECURITY: workflow_run.pull_requests may be empty when CI was triggered | |
| # by push (not pull_request). Use commits API for reliable PR resolution. | |
| - name: 🔍 Find associated PR | |
| id: find-pr | |
| env: | |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | |
| HEAD_SHA: ${{ github.event.workflow_run.head_sha }} | |
| run: | | |
| PR_DATA=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${HEAD_SHA}/pulls" \ | |
| 2>/dev/null || echo "[]") | |
| PR_COUNT=$(echo "$PR_DATA" | jq 'length') | |
| if [ "$PR_COUNT" -eq 0 ]; then | |
| echo "No open PR found for SHA ${HEAD_SHA}. Skipping." | |
| echo "found=false" >> "$GITHUB_OUTPUT" | |
| elif [ "$PR_COUNT" -gt 1 ]; then | |
| echo "::warning::Multiple PRs ($PR_COUNT) found for SHA ${HEAD_SHA}. Skipping auto-approval." | |
| echo "found=false" >> "$GITHUB_OUTPUT" | |
| else | |
| PR_NUMBER=$(echo "$PR_DATA" | jq -r '.[0].number') | |
| echo "Found PR #${PR_NUMBER}" | |
| echo "found=true" >> "$GITHUB_OUTPUT" | |
| echo "number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: ✅ Approve PR | |
| if: steps.find-pr.outputs.found == 'true' | |
| env: | |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | |
| PR_NUMBER: ${{ steps.find-pr.outputs.number }} | |
| run: | | |
| # Avoid duplicate approvals | |
| EXISTING=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \ | |
| --jq '[.[] | select(.user.login == "nullvariant-justice[bot]" and .state == "APPROVED")] | length' \ | |
| 2>/dev/null || echo "0") | |
| if [ "$EXISTING" -gt 0 ]; then | |
| echo "PR already approved by Justice. Skipping." | |
| else | |
| gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \ | |
| -f event="APPROVE" \ | |
| -f body="⚖️ Justice grants passage. CI checks passed — this code meets the garden's standards." | |
| fi | |
| justice-commit: | |
| name: 👮 Enforce the law | |
| runs-on: ubuntu-latest | |
| if: github.event_name == 'workflow_dispatch' | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Harden Runner | |
| uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 | |
| with: | |
| egress-policy: audit | |
| - name: 👮 Begin inspection | |
| run: | | |
| echo "⚖️ JUSTICE HAS ARRIVED ⚖️" | |
| echo "Let me check your code quality..." | |
| echo "Tests? Types? Error handling?" | |
| - name: 🔑 Get App Token | |
| id: app-token | |
| uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2 | |
| with: | |
| app_id: ${{ secrets.JUSTICE_BOT_APP_ID }} | |
| private_key: ${{ secrets.JUSTICE_BOT_PRIVATE_KEY }} | |
| - name: 📥 Checkout (for inspection) | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| token: ${{ steps.app-token.outputs.token }} | |
| # SECURITY: Use environment variable to prevent script injection | |
| # See: https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#understanding-the-risk-of-script-injection | |
| - name: 📝 Leave a verdict | |
| env: | |
| USER_MESSAGE: ${{ github.event.inputs.message }} | |
| run: | | |
| # Sanitize: truncate to 500 chars, strip control characters except newlines | |
| USER_MESSAGE=$(printf '%s' "$USER_MESSAGE" | head -c 500 | tr -d '\000-\010\013\014\016-\037') | |
| export USER_MESSAGE | |
| mkdir -p docs | |
| cat > docs/JUSTICE.md << 'EOF' | |
| # 👮 Justice's Code Review | |
| > "The law applies equally to all code." | |
| ## Today's Verdict | |
| EOF | |
| printf '%s\n' "${USER_MESSAGE}" >> docs/JUSTICE.md | |
| cat >> docs/JUSTICE.md << 'EOF' | |
| ### Checklist | |
| - [ ] Tests written? | |
| - [ ] Types defined? | |
| - [ ] Errors handled? | |
| - [ ] Edge cases covered? | |
| --- | |
| EOF | |
| echo "*Last inspected: $(date -u +"%Y-%m-%d %H:%M:%S UTC")*" >> docs/JUSTICE.md | |
| cat >> docs/JUSTICE.md << 'EOF' | |
| > テストは?型定義は?エラーハンドリングは? | |
| EOF | |
| - name: 📬 Create PR with verified commit | |
| env: | |
| USER_MESSAGE: ${{ github.event.inputs.message }} | |
| uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 | |
| with: | |
| token: ${{ steps.app-token.outputs.token }} | |
| sign-commits: true | |
| author: nullvariant-justice[bot] <2610732+nullvariant-justice[bot]@users.noreply.github.com> | |
| committer: nullvariant-justice[bot] <2610732+nullvariant-justice[bot]@users.noreply.github.com> | |
| commit-message: | | |
| 👮 Code inspection complete | |
| ${{ env.USER_MESSAGE }} | |
| Remember: Tests? Types? Error handling? | |
| branch: justice/verdict-${{ github.run_number }} | |
| delete-branch: true | |
| title: "👮 Justice's code review update" | |
| body: | | |
| ## 👮 ⚖️ VERDICT ⚖️ | |
| ${{ env.USER_MESSAGE }} | |
| --- | |
| > テストは?型定義は?エラーハンドリングは? | |
| *This PR was officially filed by nullvariant-justice[bot]* | |
| - name: ⚖️ Case closed | |
| run: | | |
| echo "Inspection complete." | |
| echo "Stay vigilant. Write tests." | |
| echo "⚖️ JUSTICE NEVER SLEEPS ⚖️" |