diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 56e361feb..d8556fd53 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -7,4 +7,5 @@ # This keeps CODEOWNERS files stable and centralizes membership management. # See: standards/codeowners-standard.md +# Default catch-all — org-leads own everything * @petry-projects/org-leads diff --git a/.github/workflows/agent-shield.yml b/.github/workflows/agent-shield.yml index 8704981de..d627ea4cd 100644 --- a/.github/workflows/agent-shield.yml +++ b/.github/workflows/agent-shield.yml @@ -30,4 +30,4 @@ permissions: jobs: agent-shield: - uses: petry-projects/.github/.github/workflows/agent-shield-reusable.yml@v1 + uses: petry-projects/.github/.github/workflows/agent-shield-reusable.yml@v2 diff --git a/.github/workflows/auto-rebase-reusable.yml b/.github/workflows/auto-rebase-reusable.yml index 66d77baa5..e3461d717 100644 --- a/.github/workflows/auto-rebase-reusable.yml +++ b/.github/workflows/auto-rebase-reusable.yml @@ -22,14 +22,23 @@ # Failure modes handled gracefully: # - without `workflows` permission (403): posts an idempotent comment asking # the author to rebase manually (sentinel: ) -# - merge conflict (422): posts an idempotent comment asking the author to -# resolve conflicts (sentinel: ) +# - merge conflict (422): posts an idempotent comment noting that dev-lead will +# attempt an agentic rebase (sentinel: ), which +# triggers the rebase intent in dev-lead.yml # -# No secrets required — uses github.token only. No auto-merge logic. +# Secrets: GH_PAT_WORKFLOWS is optional but required for the rebase sentinel +# to trigger dev-lead. Sentinel comments posted with GITHUB_TOKEN do not fire +# issue_comment workflow runs (GitHub limitation). When the PAT is absent the +# sentinel is still posted but dev-lead will not be scheduled. +# No auto-merge logic. name: Auto-rebase non-Dependabot PRs (Reusable) on: workflow_call: + secrets: + GH_PAT_WORKFLOWS: + description: "PAT with workflows scope — required for sentinel comment to trigger dev-lead rebase" + required: false jobs: auto-rebase: @@ -40,7 +49,8 @@ jobs: steps: - name: Update behind non-Dependabot PRs env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ secrets.GH_PAT_WORKFLOWS || github.token }} + HAS_PAT: ${{ secrets.GH_PAT_WORKFLOWS != '' }} REPO: ${{ github.repository }} run: | # Find open non-Dependabot PRs from the same repo (exclude forks) @@ -99,21 +109,40 @@ jobs: gh pr comment "$PR_NUMBER" --repo "$REPO" --body "$BLOCKED_BODY" fi elif echo "$UPDATE_OUTPUT" | grep -qi "merge conflict"; then - # Merge conflict — ask the author to resolve it. + # Merge conflict — post the sentinel to trigger dev-lead rebase. # gh api surfaces the GitHub API error JSON: {"message":"merge conflict",...} # - # Idempotent: skip if sentinel comment already exists. - SENTINEL="" - ALREADY_POSTED=$(gh pr view "$PR_NUMBER" --repo "$REPO" \ - --json comments --jq "[.comments[] | select(.body | contains(\"$SENTINEL\"))] | length") - if [[ "$ALREADY_POSTED" -gt 0 ]]; then - echo " Skipping — conflict comment already posted" + # Re-trigger strategy: embed the current BASE_BRANCH HEAD SHA in + # the sentinel so we can detect whether the base has moved. If an + # existing sentinel already carries the same SHA, skip delete+repost + # (avoiding cancellation of an in-flight dev-lead rebase run). Only + # delete and re-post when the base HEAD has changed (new conflict). + BASE_SHA=$(gh api "repos/$REPO/git/ref/heads/$BASE_BRANCH" --jq '.object.sha') + SENTINEL_PREFIX="" + SAME_SHA_COUNT=$(gh pr view "$PR_NUMBER" --repo "$REPO" \ + --json comments --jq "[.comments[] | select(.body | contains(\"$SENTINEL\")) | .databaseId] | length") + if [[ "$SAME_SHA_COUNT" -gt 0 ]]; then + echo " Sentinel already posted for base SHA $BASE_SHA, skipping to avoid cancelling active rebase" else + OLD_COMMENT_IDS=$(gh pr view "$PR_NUMBER" --repo "$REPO" \ + --json comments --jq "[.comments[] | select(.body | contains(\"$SENTINEL_PREFIX\")) | .databaseId] | .[]") + for OLD_ID in $OLD_COMMENT_IDS; do + echo " Deleting stale sentinel comment $OLD_ID" + gh api "repos/$REPO/issues/comments/$OLD_ID" -X DELETE || true + done echo " Posting conflict resolution request" - CONFLICT_BODY="" + CONFLICT_BODY="$SENTINEL" CONFLICT_BODY+=$'\n'"**Auto-rebase failed — merge conflict** — this branch has conflicts" - CONFLICT_BODY+=" with \`$BASE_BRANCH\` that must be resolved manually." - CONFLICT_BODY+=$'\n\n'"Please resolve the conflicts and push:" + CONFLICT_BODY+=" with \`$BASE_BRANCH\` that must be resolved." + if [[ "$HAS_PAT" == "true" ]]; then + CONFLICT_BODY+=$'\n\n'"dev-lead will attempt to resolve this automatically. If it cannot, a" + CONFLICT_BODY+=" follow-up comment will explain what needs manual attention." + CONFLICT_BODY+=$'\n\n'"To resolve manually instead:" + else + CONFLICT_BODY+=$'\n\n'"Please resolve this manually" + CONFLICT_BODY+=" (automatic resolution requires \`GH_PAT_WORKFLOWS\` to be configured):" + fi CONFLICT_BODY+=$'\n'"\`\`\`"$'\n'"git fetch origin" CONFLICT_BODY+=$'\n'"git merge origin/$BASE_BRANCH" CONFLICT_BODY+=$'\n'"# resolve conflicts, then:" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 077a82dbb..6397804d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ on: permissions: {} concurrency: - group: ci-${{ github.ref }} + group: ci-${{ github.ref }}-${{ github.sha }} cancel-in-progress: true jobs: @@ -95,3 +95,34 @@ jobs: - name: Run AgentShield run: | npx ecc-agentshield scan --path . --format json --min-severity high + + secret-scan: + name: Secret scan (gitleaks) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout (full history) + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Install gitleaks + env: + GITLEAKS_VERSION: "8.30.1" + run: | + # Checksum kept in the shell run block (not as a YAML env var) to avoid + # SonarCloud flagging hex strings in env: sections as Security Hotspots. + gitleaks_checksum="551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb" + tarball="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + url="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/${tarball}" + install_dir="${RUNNER_TEMP}/gitleaks-bin" + mkdir -p "${install_dir}" + wget -q "${url}" -O /tmp/gitleaks.tar.gz + echo "${gitleaks_checksum} /tmp/gitleaks.tar.gz" | sha256sum -c + tar -xzf /tmp/gitleaks.tar.gz -C "${install_dir}" gitleaks + chmod +x "${install_dir}/gitleaks" + echo "${install_dir}" >> "${GITHUB_PATH}" + + - name: Run gitleaks + run: gitleaks detect --source . --config .gitleaks.toml --redact --verbose --exit-code 1 diff --git a/.github/workflows/claude-code-reusable.yml b/.github/workflows/claude-code-reusable.yml deleted file mode 100644 index 4cf481efe..000000000 --- a/.github/workflows/claude-code-reusable.yml +++ /dev/null @@ -1,249 +0,0 @@ -# Reusable Claude Code workflow — single source of truth for the org. -# Repo-level claude.yml files call this to avoid duplicating the prompt and config. -# Standard: https://github.com/petry-projects/.github/blob/main/standards/ci-standards.md#4-claude-code-claudeyml -name: Claude Code (Reusable) - -on: - workflow_call: - secrets: - CLAUDE_CODE_OAUTH_TOKEN: - description: "Claude Code OAuth token for API access" - required: true - GH_PAT_WORKFLOWS: - description: "PAT with workflows scope — lets Claude push .github/workflows/ changes" - required: false - -jobs: - # Interactive mode: PR reviews and comments from trusted contributors - claude: - if: >- - (github.event_name == 'pull_request' && - github.event.pull_request.head.repo.full_name == github.repository && - github.event.pull_request.user.login != 'dependabot[bot]') || - (github.event_name == 'issue_comment' && github.event.issue.pull_request && - github.event.comment.user.login != 'claude[bot]' && - contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) || - (github.event_name == 'pull_request_review_comment' && - github.event.comment.user.login != 'claude[bot]' && - (contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) || - (github.event.pull_request.head.repo.full_name == github.repository && - contains(fromJson('["coderabbitai[bot]","Copilot","copilot-pull-request-reviewer[bot]","gemini-code-assist[bot]"]'), github.event.comment.user.login)))) - runs-on: ubuntu-latest - timeout-minutes: 60 - permissions: - contents: write - id-token: write - pull-requests: write - issues: write - actions: read - checks: read - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 1 - token: ${{ secrets.GH_PAT_WORKFLOWS || github.token }} - - name: Run Claude Code - uses: anthropics/claude-code-action@476e359e6203e73dad705c8b322e333fabbd7416 # v1.0.119 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - additional_permissions: | - actions: read - checks: read - - # Automation mode: CI failure response — diagnose and fix failing checks on PRs - claude-ci-fix: - if: >- - github.event_name == 'check_run' && - github.event.check_run.conclusion == 'failure' && - !startsWith(github.event.check_run.name, 'claude-code / ') - concurrency: - group: claude-ci-fix-${{ github.event.check_run.head_sha }} - cancel-in-progress: true - runs-on: ubuntu-latest - timeout-minutes: 60 - permissions: - contents: write - id-token: write - pull-requests: write - issues: write - actions: read - checks: read - steps: - - name: Resolve PR number - id: pr - env: - GH_TOKEN: ${{ secrets.GH_PAT_WORKFLOWS || github.token }} - run: | - PR="${{ github.event.check_run.pull_requests[0].number }}" - if [ -z "$PR" ]; then - PR=$(gh api \ - "repos/${{ github.repository }}/commits/${{ github.event.check_run.head_sha }}/pulls" \ - --jq '[.[] | select(.state == "open")] | first | .number // empty') - fi - # Trust gate: skip fork PRs — this job has write/secret access - if [ -n "$PR" ]; then - HEAD_REPO=$(gh api "repos/${{ github.repository }}/pulls/$PR" \ - --jq '.head.repo.full_name // empty') - if [ "$HEAD_REPO" != "${{ github.repository }}" ]; then - echo "Skipping: fork PR (head=$HEAD_REPO)" - PR="" - fi - fi - echo "number=$PR" >> "$GITHUB_OUTPUT" - - name: Checkout repository - if: steps.pr.outputs.number != '' - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 1 - token: ${{ secrets.GH_PAT_WORKFLOWS || github.token }} - - name: Run Claude Code - if: steps.pr.outputs.number != '' - uses: anthropics/claude-code-action@476e359e6203e73dad705c8b322e333fabbd7416 # v1.0.119 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - github_token: ${{ secrets.GH_PAT_WORKFLOWS || github.token }} - # yamllint disable rule:line-length - claude_args: | - --allowedTools "Bash(gh pr checkout:*),Bash(gh pr view:*),Bash(gh pr comment:*),Bash(gh run view:*),Bash(gh run list:*),Bash(gh run watch:*),Bash(gh api:*),Edit,Write" - # yamllint enable rule:line-length - # yamllint disable rule:line-length - prompt: | - CI check "${{ github.event.check_run.name }}" has failed on PR #${{ steps.pr.outputs.number }}. - - Check details: - - Check: ${{ github.event.check_run.name }} - - Conclusion: ${{ github.event.check_run.conclusion }} - - Head SHA: ${{ github.event.check_run.head_sha }} - - Details URL: ${{ github.event.check_run.details_url }} - - Please diagnose and fix the failure: - 1. Check out the PR branch: gh pr checkout ${{ steps.pr.outputs.number }} - 2. Read the failure details — visit the details URL or use `gh run list --commit ${{ github.event.check_run.head_sha }}` and `gh run view` to read the logs. For SonarCloud or external check services, inspect the PR annotations via `gh api repos/${{ github.repository }}/check-runs/${{ github.event.check_run.id }}/annotations?per_page=100`. - 3. Read the relevant source files and understand the root cause. - 4. Apply the minimal fix needed to address the reported issues. - 5. Commit and push the fix to the PR branch. - 6. Leave a concise comment on PR #${{ steps.pr.outputs.number }} explaining what you found and what you changed. - # yamllint enable rule:line-length - - # Automation mode: issue-triggered work — implement, open PR, review, and notify - claude-issue: - if: >- - github.event_name == 'issues' && github.event.action == 'labeled' && - github.event.label.name == 'claude' - concurrency: - group: claude-issue-${{ github.event.issue.number || github.run_id }} - cancel-in-progress: true - runs-on: ubuntu-latest - timeout-minutes: 60 - permissions: - contents: write - id-token: write - pull-requests: write - issues: write - actions: read - checks: read - # Note: GitHub Actions does NOT expose an "administration" permission scope. - # Admin operations (create rulesets, enable Discussions, etc.) work via the - # GH_PAT_WORKFLOWS token below, which must be a classic PAT with `repo` scope - # (or fine-grained with Administration:write) for those calls to succeed. - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 1 - token: ${{ secrets.GH_PAT_WORKFLOWS || github.token }} - - name: Check for existing open PR - id: dedup - env: - GH_TOKEN: ${{ secrets.GH_PAT_WORKFLOWS || github.token }} - ISSUE: ${{ github.event.issue.number }} - run: | - # Search by branch prefix (claude/issue-NNN-*) - PR_URL=$(gh pr list \ - --repo "$GITHUB_REPOSITORY" \ - --state open \ - --json number,url,headRefName \ - --jq ".[] | select(.headRefName | startswith(\"claude/issue-${ISSUE}-\")) | .url" \ - | head -1) - - # Fallback: search PR body for "Closes #NNN" - if [ -z "$PR_URL" ]; then - PR_URL=$(gh pr list \ - --repo "$GITHUB_REPOSITORY" \ - --state open \ - --search "Closes #${ISSUE} in:body" \ - --json url \ - --jq '.[0].url' 2>/dev/null || true) - fi - - if [ -n "$PR_URL" ]; then - echo "existing_pr_url=$PR_URL" >> "$GITHUB_OUTPUT" - gh issue comment "$ISSUE" \ - --repo "$GITHUB_REPOSITORY" \ - --body "An open PR already addresses this issue: $PR_URL — skipping new Claude run to avoid duplicates." - echo "Skipping: existing PR found at $PR_URL" - else - echo "No existing open PR found — proceeding with Claude." - fi - - name: Run Claude Code - if: steps.dedup.outputs.existing_pr_url == '' - uses: anthropics/claude-code-action@476e359e6203e73dad705c8b322e333fabbd7416 # v1.0.119 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - github_token: ${{ secrets.GH_PAT_WORKFLOWS }} - label_trigger: "claude" - track_progress: "true" - additional_permissions: | - actions: read - checks: read - # yamllint disable rule:line-length - claude_args: | - --allowedTools "Bash(gh pr create:*),Bash(gh pr view:*),Bash(gh pr comment:*),Bash(gh issue comment:*),Bash(gh run view:*),Bash(gh run watch:*),Bash(gh api:*),Bash(gh label create:*),Edit,Write" - # yamllint enable rule:line-length - prompt: | - Implement a fix for issue #${{ github.event.issue.number }}. - - **Standards-conformance rules — read these before writing any code:** - - - **For workflow files** (`.github/workflows/*.yml`): if a template - exists in `petry-projects/.github/standards/workflows/` for what - you're adding, **copy it verbatim** rather than writing from - scratch. Available templates: `agent-shield.yml`, `claude.yml`, - `dependabot-automerge.yml`, `dependabot-rebase.yml`, - `dependency-audit.yml`, `feature-ideation.yml`. Fetch via: - `gh api repos/petry-projects/.github/contents/standards/workflows/.yml --jq '.content' | base64 -d` - Adapt only when the file genuinely needs repo-specific content. - - - **For org standards** (labels, settings, rulesets, CODEOWNERS): - read `petry-projects/.github/standards/` first via `gh api`. - Match colors, names, and structure exactly. The full standards - tree is at `petry-projects/.github/tree/main/standards/`. - - - **For SHA pinning** (Action Pinning Policy in - `standards/ci-standards.md`): never guess or fabricate SHAs. - Always look them up: - * Tags: `gh api repos/{owner}/{repo}/git/refs/tags/{tag} --jq '.object.sha'` - * Branches: `gh api repos/{owner}/{repo}/branches/{branch} --jq '.commit.sha'` - If the lookup fails, do not pin — open the PR with the action - still using its tag and clearly explain the blocker in the PR - body so a human can complete the fix. - - - **For CodeQL** (`codeql.yml`): all ecosystems present in the repo - MUST be configured as CodeQL languages. Repos with - `.github/workflows/*.yml` files MUST scan the `actions` - ecosystem. Use a matrix strategy for multi-language repos. - - - **Do not skip the work** if previous comments say "blocked": the - prior infrastructure issues that produced those comments may - have been resolved. Attempt the fix; if you hit a *new* error, - report the actual error message rather than referring to history. - - After implementing: - 1. Create a pull request with a clear title and description. Include "Closes #${{ github.event.issue.number }}" in the PR body. - 2. Self-review your own PR — look for bugs, style issues, missed edge cases, and test gaps. If you find problems, push fixes. - 3. Review all comments and review threads on the PR. For each one: - - If you can address the feedback, make the fix, push, and mark the conversation as resolved. - - If the comment requires human judgment, leave a reply explaining what you need. - 4. Check CI status. If CI fails, read the logs, fix the issues, and push again. Repeat until CI passes. - 5. When CI is green, all actionable review comments are resolved, and the PR is ready, read the CODEOWNERS file and leave a comment tagging the relevant code owners to review and merge. diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml deleted file mode 100644 index 8f7c686d3..000000000 --- a/.github/workflows/claude.yml +++ /dev/null @@ -1,31 +0,0 @@ -# Claude Code — thin caller that delegates to the org-level reusable workflow. -# All logic and prompts are maintained centrally in claude-code-reusable.yml. -# Standard: https://github.com/petry-projects/.github/blob/main/standards/ci-standards.md#4-claude-code-claudeyml -name: Claude Code - -on: - pull_request: - branches: [main] - types: [opened, reopened, synchronize] - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - issues: - types: [labeled] - check_run: - types: [completed] - -permissions: {} - -jobs: - claude-code: - uses: petry-projects/.github/.github/workflows/claude-code-reusable.yml@main - secrets: inherit - permissions: - contents: write - id-token: write - pull-requests: write - issues: write - actions: read - checks: read diff --git a/.github/workflows/compliance-audit-and-improvement.yml b/.github/workflows/compliance-audit-and-improvement.yml index faa2396a0..0899eaa89 100644 --- a/.github/workflows/compliance-audit-and-improvement.yml +++ b/.github/workflows/compliance-audit-and-improvement.yml @@ -50,6 +50,9 @@ jobs: error_count: ${{ steps.audit.outputs.error_count }} warning_count: ${{ steps.audit.outputs.warning_count }} repos_with_findings: ${{ steps.audit.outputs.repos_with_findings }} + issues_added: ${{ steps.audit.outputs.issues_added }} + issues_existing: ${{ steps.audit.outputs.issues_existing }} + issues_removed: ${{ steps.audit.outputs.issues_removed }} steps: - name: Checkout .github repo uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -75,6 +78,19 @@ jobs: echo "warning_count=$WARNING_COUNT" >> "$GITHUB_OUTPUT" echo "repos_with_findings=$REPOS_WITH_FINDINGS" >> "$GITHUB_OUTPUT" + # Parse issue management counts written by the audit script + COUNTS_FILE="$REPORT_DIR/issue-counts.json" + if [ -f "$COUNTS_FILE" ]; then + ISSUES_ADDED=$(jq '.added' "$COUNTS_FILE") + ISSUES_EXISTING=$(jq '.existing' "$COUNTS_FILE") + ISSUES_REMOVED=$(jq '.removed' "$COUNTS_FILE") + else + ISSUES_ADDED=0; ISSUES_EXISTING=0; ISSUES_REMOVED=0 + fi + echo "issues_added=$ISSUES_ADDED" >> "$GITHUB_OUTPUT" + echo "issues_existing=$ISSUES_EXISTING" >> "$GITHUB_OUTPUT" + echo "issues_removed=$ISSUES_REMOVED" >> "$GITHUB_OUTPUT" + - name: Write step summary if: always() run: | @@ -291,7 +307,7 @@ jobs: env: GH_TOKEN: ${{ secrets.ORG_SCORECARD_TOKEN }} DRY_RUN: ${{ inputs.dry_run || 'false' }} - uses: anthropics/claude-code-action@476e359e6203e73dad705c8b322e333fabbd7416 # v1 + uses: anthropics/claude-code-action@51ea8ea73a139f2a74ff649e3092c25a904aed7e # v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} prompt: | @@ -314,6 +330,9 @@ jobs: - Errors: ${{ needs.audit.outputs.error_count }} - Warnings: ${{ needs.audit.outputs.warning_count }} - Repos with findings: ${{ needs.audit.outputs.repos_with_findings }} + - Issues added (new): ${{ needs.audit.outputs.issues_added }} + - Issues existing (updated): ${{ needs.audit.outputs.issues_existing }} + - Issues removed (resolved): ${{ needs.audit.outputs.issues_removed }} ### Health Survey (runtime telemetry) - Report JSON: ${{ runner.temp }}/health-survey.json @@ -488,12 +507,25 @@ jobs: **Deduplication:** - Check existing open issues before creating + - **Group findings of the same type across repos into a single issue.** For example, + if the same compliance check is failing in 5 repos, create ONE issue listing all + affected repos — not 5 separate issues. The compliance audit script already creates + per-repo issues; your job is to identify systemic patterns and create higher-level + issues for them. - If a similar issue exists, add a comment with latest findings instead - When commenting on existing issues, also ensure the `claude` label is present: ```bash gh issue edit --repo ${{ github.repository_owner }}/ --add-label claude ``` + **Before writing the Phase 6 summary**, gather linked PR data for all issues you + created or updated this run, and for all compliance issues from the audit: + ```bash + gh search prs --owner ${{ github.repository_owner }} --state open \ + --json number,repository,url,closingIssuesReferences --limit 300 + ``` + Store this PR data to populate the "Related PRs" column in the summary. + ## Phase 6: Summary Report Write to $GITHUB_STEP_SUMMARY: @@ -520,9 +552,30 @@ jobs: | Repos without branch protection | N | ### Issues Created/Updated - | Repo | Issue | Severity | Title | - |------|-------|----------|-------| - | ... | #N | ... | ... | + | Action | Count | + |--------|-------| + | Added (new) | ${{ needs.audit.outputs.issues_added }} | + | Existing (updated) | ${{ needs.audit.outputs.issues_existing }} | + | Removed (resolved) | ${{ needs.audit.outputs.issues_removed }} | + + Group by compliance issue type — one subsection per distinct check/finding type, + listing every affected repo with a linked issue number and any related open PRs. + Render each issue and PR as a markdown hyperlink. For example: + + #### `` (``) + _N repos affected_ + + | Repo | Issue | Related PRs | + |------|-------|-------------| + | [repo-name](https://github.com/owner/repo) | [#42 — Compliance: check-type](issue-url) | [#7](pr-url) | + | [other-repo](https://github.com/owner/repo) | [#18 — Compliance: check-type](issue-url) | — | + + Repeat for each distinct check type, errors before warnings, sorted alphabetically + within each severity group. + + For issues you created in Phase 5 (health/automation/best-practice issues): + list them after compliance issues under a sub-heading "#### Phase 5 Issues". + Format: `[#N — Title](url)` grouped by repo, with Related PRs where found. ### Per-Repo Scorecard | Repo | Compliance | CI | Security | PRs | Protection | Score | diff --git a/.github/workflows/daily-org-status.yml b/.github/workflows/daily-org-status.yml index 3128f524e..f279659f4 100644 --- a/.github/workflows/daily-org-status.yml +++ b/.github/workflows/daily-org-status.yml @@ -39,9 +39,18 @@ jobs: - name: Truncate report if near GitHub issue body limit run: | MAX_BYTES=64000 - if [ "$(wc -c < /tmp/report.md)" -gt "$MAX_BYTES" ]; then - truncated=$(head -c "$MAX_BYTES" /tmp/report.md) - printf '%s\n\n_Report truncated — exceeded GitHub issue body limit._\n' "$truncated" > /tmp/report.md + REPORT_SIZE=$(wc -c < /tmp/report.md) + echo "Report size: ${REPORT_SIZE} bytes (limit: ${MAX_BYTES})" + if [ "$REPORT_SIZE" -gt "$MAX_BYTES" ]; then + echo "Truncating to line boundary near ${MAX_BYTES} bytes..." + FOOTER=$'\n_Report truncated — exceeded GitHub issue body limit._\n' + FOOTER_LEN=$(printf '%s' "$FOOTER" | wc -c) + SAFE_BUDGET=$(( MAX_BYTES - FOOTER_LEN )) + TRUNCATE_AT=$(python3 -c "d=open('/tmp/report.md','rb').read(${SAFE_BUDGET});nl=d.rfind(b'\n');print(nl+1 if nl>=0 else len(d))") + head -c "$TRUNCATE_AT" /tmp/report.md > /tmp/report_truncated.md + printf '%s' "$FOOTER" >> /tmp/report_truncated.md + mv /tmp/report_truncated.md /tmp/report.md + echo "Final size: $(wc -c < /tmp/report.md) bytes" fi - name: Create daily-report issue diff --git a/.github/workflows/dependabot-automerge-reusable.yml b/.github/workflows/dependabot-automerge-reusable.yml index e661fc770..9380a25c5 100644 --- a/.github/workflows/dependabot-automerge-reusable.yml +++ b/.github/workflows/dependabot-automerge-reusable.yml @@ -81,7 +81,7 @@ jobs: - name: Generate app token if: steps.eligible.outputs.eligible == 'true' id: app-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 with: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml index 845c32956..497f77c2e 100644 --- a/.github/workflows/dependabot-automerge.yml +++ b/.github/workflows/dependabot-automerge.yml @@ -35,5 +35,5 @@ jobs: permissions: contents: read pull-requests: read - uses: petry-projects/.github/.github/workflows/dependabot-automerge-reusable.yml@v1 + uses: petry-projects/.github/.github/workflows/dependabot-automerge-reusable.yml@v2 secrets: inherit diff --git a/.github/workflows/dependabot-rebase-reusable.yml b/.github/workflows/dependabot-rebase-reusable.yml index c777bf4f0..e808d63b5 100644 --- a/.github/workflows/dependabot-rebase-reusable.yml +++ b/.github/workflows/dependabot-rebase-reusable.yml @@ -70,7 +70,7 @@ jobs: - name: Generate app token id: app-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 with: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} diff --git a/.github/workflows/dependency-audit-reusable.yml b/.github/workflows/dependency-audit-reusable.yml index c4b037db0..d0da2c837 100644 --- a/.github/workflows/dependency-audit-reusable.yml +++ b/.github/workflows/dependency-audit-reusable.yml @@ -101,7 +101,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 # v4 + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v4 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: diff --git a/.github/workflows/dependency-audit.yml b/.github/workflows/dependency-audit.yml index a8cc76d9e..3ec178076 100644 --- a/.github/workflows/dependency-audit.yml +++ b/.github/workflows/dependency-audit.yml @@ -105,7 +105,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 - - uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 # v4 + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v4 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: @@ -159,7 +159,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - name: Install cargo-audit run: cargo install cargo-audit@0.22.1 --locked diff --git a/.github/workflows/dev-lead.yml b/.github/workflows/dev-lead.yml new file mode 100644 index 000000000..02f5ada0d --- /dev/null +++ b/.github/workflows/dev-lead.yml @@ -0,0 +1,59 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Dev-Lead Agent — thin caller stub +# Standard: petry-projects/.github/standards/ci-standards.md#5-dev-lead-agent +# Reusable: petry-projects/.github-private/.github/workflows/dev-lead-reusable.yml +# +# ADOPTING THIS WORKFLOW: +# 1. Copy this file verbatim to .github/workflows/dev-lead.yml in your repo. +# 2. Ensure CLAUDE_CODE_OAUTH_TOKEN is set as an org or repo secret. +# 3. Optionally set GH_PAT_WORKFLOWS (required if Claude pushes workflow files). +# 4. Optionally set vars.DEV_LEAD_ENGINE = "claude" | "gemini" | "copilot". +# +# UNLIKE claude.yml, this file has NO OIDC byte-for-byte constraint and may be +# freely modified on PR branches to adjust triggers for repo-specific needs. +# +# REQUIRED secrets: CLAUDE_CODE_OAUTH_TOKEN +# OPTIONAL secrets: GH_PAT_WORKFLOWS, GOOGLE_API_KEY, GH_PAT +# ───────────────────────────────────────────────────────────────────────────── + +name: Dev-Lead Agent + +on: + pull_request: + branches: [main] + types: [opened, reopened, synchronize] + pull_request_review: + types: [submitted] + pull_request_review_comment: + types: [created] + issue_comment: + types: [created] + issues: + types: [labeled] + check_run: + types: [completed] + repository_dispatch: + types: [dev-lead-ci-failure] + +permissions: {} + +concurrency: + # One active run per repo; ci-relay (check_run) keeps an ephemeral per-SHA slot + # so it can fire immediately without blocking or being blocked by the dispatch queue. + group: >- + ${{ + github.event_name == 'check_run' && format('dev-lead-ci-relay-{0}', github.event.check_run.head_sha) || + 'dev-lead' + }} + cancel-in-progress: false + +jobs: + dev-lead: + uses: petry-projects/.github-private/.github/workflows/dev-lead-reusable.yml@main + secrets: inherit + permissions: + contents: write + pull-requests: write + issues: write + actions: read + checks: read diff --git a/.github/workflows/feature-ideation-reusable.yml b/.github/workflows/feature-ideation-reusable.yml index 7434d1cfd..e69c75a30 100644 --- a/.github/workflows/feature-ideation-reusable.yml +++ b/.github/workflows/feature-ideation-reusable.yml @@ -215,7 +215,7 @@ jobs: FOCUS_AREA: ${{ inputs.focus_area || '' }} SOURCES_FILE_PATH: ${{ inputs.sources_file }} RESEARCH_DEPTH: ${{ inputs.research_depth }} - uses: anthropics/claude-code-action@476e359e6203e73dad705c8b322e333fabbd7416 # v1.0.119 + uses: anthropics/claude-code-action@51ea8ea73a139f2a74ff649e3092c25a904aed7e # v1.0.123 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} # CRITICAL: pass the workflow's GITHUB_TOKEN explicitly so the diff --git a/.github/workflows/feature-ideation-tests.yml b/.github/workflows/feature-ideation-tests.yml index 91c1a628d..a11f02744 100644 --- a/.github/workflows/feature-ideation-tests.yml +++ b/.github/workflows/feature-ideation-tests.yml @@ -41,7 +41,7 @@ permissions: contents: read concurrency: - group: feature-ideation-tests-${{ github.ref }} + group: feature-ideation-tests-${{ github.ref }}-${{ github.sha }} cancel-in-progress: true jobs: @@ -103,7 +103,7 @@ jobs: - name: Upload bats output on failure if: failure() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: bats-output path: | diff --git a/.github/workflows/feature-ideation.yml b/.github/workflows/feature-ideation.yml index 613a1defc..17ca4b6e2 100644 --- a/.github/workflows/feature-ideation.yml +++ b/.github/workflows/feature-ideation.yml @@ -63,7 +63,7 @@ jobs: discussions: write actions: read id-token: write - uses: petry-projects/.github/.github/workflows/feature-ideation-reusable.yml@ee22b427cbce9ecadcf2b436acb57c3adf0cb63d # v1 + uses: petry-projects/.github/.github/workflows/feature-ideation-reusable.yml@c7104f49cb590c46ae219d9bd677fc68073692b7 # v1 with: project_context: | petry-projects/.github is the org-level standards and tooling repository diff --git a/.github/workflows/org-scorecard.yml b/.github/workflows/org-scorecard.yml index 783f44c77..b3353f8b0 100644 --- a/.github/workflows/org-scorecard.yml +++ b/.github/workflows/org-scorecard.yml @@ -17,13 +17,13 @@ jobs: steps: - name: Install Scorecard CLI run: | - SCORECARD_VERSION="v5.1.1" - SCORECARD_SHA="a894eb7390308069a49a3ace0f68dddf995cdfa1b6e7f8b2e1ea77de131e4962" - curl -sLo scorecard.tar.gz "https://github.com/ossf/scorecard/releases/download/${SCORECARD_VERSION}/scorecard_5.1.1_linux_amd64.tar.gz" + SCORECARD_VERSION="v5.5.0" + SCORECARD_SHA="83b90a05c1540ef1390db1cd5711e5fd04be9c1d8537fb84d39d02092d6a8dff" + curl -sLo scorecard.tar.gz "https://github.com/ossf/scorecard/releases/download/${SCORECARD_VERSION}/scorecard_5.5.0_linux_amd64.tar.gz" echo "${SCORECARD_SHA} scorecard.tar.gz" | sha256sum -c - tar xzf scorecard.tar.gz rm scorecard.tar.gz - sudo mv scorecard-linux-amd64 /usr/local/bin/scorecard + sudo mv scorecard /usr/local/bin/scorecard chmod +x /usr/local/bin/scorecard - name: Run Scorecard across org @@ -51,15 +51,31 @@ jobs: RESULT_FILE="$RESULTS_DIR/${REPO}.json" # Run scorecard - if ! scorecard --repo="github.com/$ORG/$REPO" --format=json > "$RESULT_FILE" 2>/dev/null; then - echo "::warning::Failed to scan $ORG/$REPO" - echo "| $REPO | ERROR | Scan failed |" >> "$SUMMARY_FILE" - echo "::endgroup::" - continue + # We capture stderr to a separate file to detect parsing errors (malformed workflows) + ERROR_LOG="$RESULTS_DIR/${REPO}_error.log" + if ! scorecard --repo="github.com/$ORG/$REPO" --format=json > "$RESULT_FILE" 2> "$ERROR_LOG"; then + # If scorecard failed, check if it was due to a malformed workflow + if grep -q "could not parse as YAML" "$ERROR_LOG"; then + echo "::warning::Detected malformed YAML in $ORG/$REPO. Treating as finding." + # We'll inject a custom finding into the summary and handle issue creation below + AGG_SCORE="0" + MALFORMED_WORKFLOW="true" + else + echo "::warning::Failed to scan $ORG/$REPO" + echo "| $REPO | ERROR | Scan failed |" >> "$SUMMARY_FILE" + echo "::endgroup::" + continue + fi + else + MALFORMED_WORKFLOW="false" fi # Extract aggregate score - AGG_SCORE=$(jq -r '.aggregate_score // "N/A"' "$RESULT_FILE") + if [ "$MALFORMED_WORKFLOW" = "true" ]; then + AGG_SCORE="0" + else + AGG_SCORE=$(jq -r '.score // "N/A"' "$RESULT_FILE") + fi # Ensure scorecard label exists gh label create scorecard --repo "$ORG/$REPO" --description "OpenSSF Scorecard finding" --color "d93f0b" --force 2>/dev/null || true @@ -68,12 +84,29 @@ jobs: CLOSED_COUNT=0 CREATED_COUNT=0 - # Process each check - for ROW in $(jq -c '.checks[]' "$RESULT_FILE"); do - CHECK_NAME=$(echo "$ROW" | jq -r '.name') - SCORE=$(echo "$ROW" | jq -r '.score') - REASON=$(echo "$ROW" | jq -r '.reason') - DOC_URL=$(echo "$ROW" | jq -r '.documentation.url // "https://github.com/ossf/scorecard/blob/main/docs/checks.md"') + if [ "$MALFORMED_WORKFLOW" = "true" ]; then + # Handle malformed workflow finding + CHECK_NAME="Malformed-Workflow" + SCORE=0 + REASON="One or more GitHub workflow files could not be parsed as YAML (see scan logs for details)." + DOC_URL="https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions" + + # TDD: Re-use the existing issue logic by providing the variables it expects + # We need to simulate a loop iteration or just run the logic once + NUM_CHECKS=1 + else + # Process each check directly using jq to avoid large strings in shell loop + NUM_CHECKS=$(jq '.checks | length' "$RESULT_FILE") + fi + + for i in $(seq 0 $((NUM_CHECKS - 1))); do + if [ "$MALFORMED_WORKFLOW" = "false" ]; then + ROW=$(jq -c ".checks[$i]" "$RESULT_FILE") + CHECK_NAME=$(echo "$ROW" | jq -r '.name') + SCORE=$(echo "$ROW" | jq -r '.score') + REASON=$(echo "$ROW" | jq -r '.reason') + DOC_URL=$(echo "$ROW" | jq -r '.documentation.url // "https://github.com/ossf/scorecard/blob/main/docs/checks.md"') + fi # Skip non-actionable checks if echo "$SKIP_CHECKS" | grep -q "$CHECK_NAME"; then diff --git a/.github/workflows/pr-auto-review-reusable.yml b/.github/workflows/pr-auto-review-reusable.yml new file mode 100644 index 000000000..e1ffbd25e --- /dev/null +++ b/.github/workflows/pr-auto-review-reusable.yml @@ -0,0 +1,213 @@ +# Reusable PR Auto-Review — Ready Check workflow. +# Single source of truth for the org: all readiness-gate logic lives here. +# Repo-level pr-auto-review.yml files are thin caller stubs. +# Standard: https://github.com/petry-projects/.github/blob/main/standards/ci-standards.md +# +# Fires a review-agent dispatch when a PR meets ALL of the following criteria: +# 1. PR is open and not a draft +# 2. All CI checks are completed and passing (no pending / failing) +# 3. Effective review decision is not CHANGES_REQUESTED +# 4. No unresolved review threads +# +# Triggered by (events forwarded from the thin caller): +# workflow_run:completed — a named CI workflow finished green +# check_suite:completed — a third-party check suite finished green +# pull_request:opened/reopened/synchronize/ready_for_review +# pull_request_review:submitted/dismissed +# +# Requires: +# GH_PAT_WORKFLOWS — org secret, classic PAT (repo scope) for API calls and dispatch +name: PR Auto-Review — Ready Check (Reusable) + +on: + workflow_call: + secrets: + GH_PAT_WORKFLOWS: + description: "Classic PAT with repo scope used for API calls and dispatching the review agent" + required: true + +jobs: + check-and-dispatch: + runs-on: ubuntu-latest + permissions: + pull-requests: read + checks: read + actions: read + + steps: + - name: Resolve PR URL + id: pr + env: + GH_TOKEN: ${{ secrets.GH_PAT_WORKFLOWS }} + run: | + set -euo pipefail + case "${{ github.event_name }}" in + workflow_run) + # workflow_run fires when a named CI workflow completes (Actions-aware). + CONCLUSION="${{ github.event.workflow_run.conclusion }}" + if [ "$CONCLUSION" != "success" ]; then + echo "Workflow run conclusion is '$CONCLUSION' — skipping" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + HEAD_SHA="${{ github.event.workflow_run.head_sha }}" + PR_URL=$(gh api "/repos/${{ github.repository }}/commits/${HEAD_SHA}/pulls" \ + --jq '[.[] | select(.state == "open" and .draft == false)][0].html_url // empty' \ + 2>/dev/null || true) + ;; + check_suite) + # check_suite covers third-party CI (e.g. SonarCloud). + # NOTE: this event does NOT fire for GitHub Actions runs; use + # workflow_run in the caller for Actions-based CI. + CONCLUSION="${{ github.event.check_suite.conclusion }}" + if [ "$CONCLUSION" != "success" ]; then + echo "Check suite conclusion is '$CONCLUSION' — skipping" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + HEAD_SHA="${{ github.event.check_suite.head_sha }}" + PR_URL=$(gh api "/repos/${{ github.repository }}/commits/${HEAD_SHA}/pulls" \ + --jq '[.[] | select(.state == "open" and .draft == false)][0].html_url // empty' \ + 2>/dev/null || true) + ;; + pull_request) + # Skip draft PRs early to avoid unnecessary API calls. + if [ "${{ github.event.pull_request.draft }}" = "true" ]; then + echo "PR is a draft — skipping" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + PR_URL="${{ github.event.pull_request.html_url }}" + ;; + pull_request_review) + PR_URL="${{ github.event.pull_request.html_url }}" + ;; + *) + echo "Unhandled event '${{ github.event_name }}' — skipping" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + ;; + esac + + if [ -z "${PR_URL:-}" ]; then + echo "No open non-draft PR found for this event — skipping" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT" + echo "skip=false" >> "$GITHUB_OUTPUT" + echo "PR URL: $PR_URL" + + - name: Check PR readiness criteria + id: criteria + if: steps.pr.outputs.skip != 'true' + env: + GH_TOKEN: ${{ secrets.GH_PAT_WORKFLOWS }} + PR_URL: ${{ steps.pr.outputs.pr_url }} + run: | + set -euo pipefail + + # Derive the base repository (owner/repo) from the PR URL. + # gh pr view --json does not expose baseRepository; parsing the URL + # is simpler and works for both same-repo and fork PRs. + REPO=$(echo "$PR_URL" | sed 's|https://github.com/||; s|/pull/.*||') + + # Fetch PR metadata in one call, including the effective review decision. + PR_META=$(gh pr view "$PR_URL" \ + --json state,isDraft,number,reviewDecision) + STATE=$(echo "$PR_META" | jq -r '.state') + IS_DRAFT=$(echo "$PR_META" | jq -r '.isDraft') + PR_NUMBER=$(echo "$PR_META" | jq -r '.number') + REVIEW_DECISION=$(echo "$PR_META" | jq -r '.reviewDecision // ""') + + # 1. PR must be open and not a draft. + if [ "$STATE" != "OPEN" ] || [ "$IS_DRAFT" = "true" ]; then + echo "PR is $STATE (draft=$IS_DRAFT) — skipping" + echo "ready=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "PR is open and not a draft ✓" + + # 2. All CI checks must be completed and passing. + # gh pr checks --json may exit non-zero when checks are + # failing/pending but still writes the JSON payload to stdout; + # use || true so set -e doesn't discard that output. + CHECKS=$(gh pr checks "$PR_URL" --json bucket,name 2>/dev/null || true) + if [ -z "${CHECKS}" ]; then + CHECKS="[]" + fi + TOTAL=$(echo "$CHECKS" | jq 'length') + if [ "$TOTAL" -eq 0 ]; then + echo "No CI checks found on this PR — skipping" + echo "ready=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Get the name of this workflow's own check run so it can be excluded + # from the gate — an in-progress run shows as "pending" and would + # otherwise block itself on every trigger. + SELF_CHECK=$(gh api \ + "/repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/jobs" \ + --jq '.jobs[0].name // empty' 2>/dev/null || echo "") + + # Use double-quoted jq expression with \$self so the shell produces + # a literal "$self" for jq without triggering SC2016 (which flags + # shell variables in single-quoted strings). $self is a jq variable. + NOT_PASSING=$(echo "$CHECKS" | jq \ + --arg self "$SELF_CHECK" \ + "map(select(.name != \$self)) | map(select(.bucket != \"pass\" and .bucket != \"skipping\")) | length") + + if [ "$NOT_PASSING" -gt 0 ]; then + echo "$NOT_PASSING of $TOTAL check(s) are not yet passing — skipping" + echo "ready=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "All $TOTAL CI check(s) passing ✓" + + # 3. Effective review decision must not be CHANGES_REQUESTED. + # reviewDecision reflects the aggregate current state (accounts for + # dismissals and superseding reviews), unlike the REST reviews list + # which returns full history and can produce false positives. + if [ "$REVIEW_DECISION" = "CHANGES_REQUESTED" ]; then + echo "Effective review decision is CHANGES_REQUESTED — skipping" + echo "ready=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "No CHANGES_REQUESTED review decision ✓" + + # 4. No unresolved review threads. + # REST API has no resolved field on review comments; GraphQL is + # required. \$owner/\$repo/\$number are GraphQL variable references; + # the backslash-dollar escaping prevents shell expansion while + # keeping the literal $ that GraphQL expects. + # TODO: paginate reviewThreads for PRs with >100 threads. + UNRESOLVED=$(gh api graphql \ + -f "query=query(\$owner:String!,\$repo:String!,\$number:Int!){repository(owner:\$owner,name:\$repo){pullRequest(number:\$number){reviewThreads(first:100){nodes{isResolved}}}}}" \ + -f owner="${REPO%%/*}" \ + -f repo="${REPO##*/}" \ + -F number="${PR_NUMBER}" \ + --jq "[.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false)] | length") + if [ "$UNRESOLVED" -gt 0 ]; then + echo "$UNRESOLVED unresolved review thread(s) — skipping" + echo "ready=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "No unresolved review threads ✓" + + echo "All readiness criteria met — dispatching review agent" + echo "ready=true" >> "$GITHUB_OUTPUT" + + - name: Dispatch review agent + if: steps.criteria.outputs.ready == 'true' + env: + GH_TOKEN: ${{ secrets.GH_PAT_WORKFLOWS }} + PR_URL: ${{ steps.pr.outputs.pr_url }} + run: | + gh api \ + --method POST \ + --header "Accept: application/vnd.github+json" \ + /repos/petry-projects/.github-private/dispatches \ + --field event_type=pr-review-mention \ + --field "client_payload[pr_url]=$PR_URL" + echo "::notice::Auto-review dispatched for $PR_URL" diff --git a/.github/workflows/pr-auto-review.yml b/.github/workflows/pr-auto-review.yml new file mode 100644 index 000000000..f3e9781b5 --- /dev/null +++ b/.github/workflows/pr-auto-review.yml @@ -0,0 +1,52 @@ +# ───────────────────────────────────────────────────────────────────────────── +# SOURCE OF TRUTH: petry-projects/.github/standards/workflows/pr-auto-review.yml +# Standard: petry-projects/.github/standards/ci-standards.md +# Reusable: petry-projects/.github/.github/workflows/pr-auto-review-reusable.yml +# +# AGENTS — READ BEFORE EDITING: +# • This file is a THIN CALLER STUB. All readiness-gate logic lives in the +# reusable workflow above. +# • You MAY change: nothing in normal use. NOTE: this file intentionally uses +# a LOCAL ref (`./`) instead of a pinned SHA — this repo IS the source of +# truth, so a local ref is always current. Other repos use @v2 +# (see standards/workflows/pr-auto-review.yml). +# • You MUST NOT change: trigger events or the job-level `permissions:` block — +# reusable workflows can be granted no more permissions than the calling job, +# so removing the stanza breaks the reusable's gh API calls. +# • If you need different behaviour, open a PR against the reusable in the +# central repo. +# ───────────────────────────────────────────────────────────────────────────── +# +# PR Auto-Review — thin caller for the org-level reusable. +# To adopt: copy standards/workflows/pr-auto-review.yml to your repo. +# Requires: GH_PAT_WORKFLOWS org secret (already present in petry-projects org). +name: PR Auto-Review — Ready Check + +on: + # workflow_run fires when a named GitHub Actions workflow completes. + # check_suite does NOT trigger for GitHub Actions runs, so this is required + # to catch CI turning green on a PR. + workflow_run: + workflows: ["CI"] + types: [completed] + # check_suite covers third-party CI checks (e.g. SonarCloud, external apps). + check_suite: + types: [completed] + # Re-evaluate readiness after review state changes. + pull_request_review: + types: [submitted, dismissed] + # Re-evaluate when the PR is first opened, updated, or comes out of draft. + pull_request: + types: [opened, reopened, synchronize, ready_for_review] + +permissions: {} + +jobs: + pr-auto-review: + permissions: + pull-requests: read + checks: read + actions: read + uses: ./.github/workflows/pr-auto-review-reusable.yml # local ref — always current + secrets: + GH_PAT_WORKFLOWS: ${{ secrets.GH_PAT_WORKFLOWS }} diff --git a/.github/workflows/pr-review-mention-reusable.yml b/.github/workflows/pr-review-mention-reusable.yml index 4602093ce..df74fdc7a 100644 --- a/.github/workflows/pr-review-mention-reusable.yml +++ b/.github/workflows/pr-review-mention-reusable.yml @@ -8,14 +8,19 @@ # repository_dispatch event to petry-projects/.github-private to trigger the # pr-review agent cascade. # -# Requires: GH_PAT_WORKFLOWS org secret (classic PAT with repo scope). +# Requires: +# GH_PAT_WORKFLOWS — org secret, classic PAT (repo scope) for API calls and dispatch +# DON_PETRY_BOT_GH_PAT — org secret, classic PAT owned by donpetry-bot (repo scope) for posting comments name: PR Review — Mention Trigger (Reusable) on: workflow_call: secrets: GH_PAT_WORKFLOWS: - description: "Classic PAT with repo scope used to post comments and dispatch the review agent" + description: "Classic PAT with repo scope used for API calls and dispatching the review agent" + required: true + DON_PETRY_BOT_GH_PAT: + description: "Classic PAT owned by donpetry-bot, used to post acknowledgement comments as the bot" required: true jobs: @@ -105,7 +110,7 @@ jobs: - name: Post acknowledgement comment if: steps.trust.outputs.trusted == 'true' env: - GH_TOKEN: ${{ secrets.GH_PAT_WORKFLOWS }} + GH_TOKEN: ${{ secrets.DON_PETRY_BOT_GH_PAT }} PR_URL: ${{ steps.pr.outputs.pr_url }} run: | if [ "${{ github.event_name }}" = "pull_request" ]; then diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 000000000..6548cdb86 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,10 @@ +title = "gitleaks config" + +# Add repo-specific allowlists below. +# Common false-positive paths: +# '''_bmad/''' — BMAD knowledge/config files (not application secrets) +[allowlist] +description = "Allowlisted paths" +paths = [ + '''_bmad/''', +] diff --git a/scripts/compliance-audit.sh b/scripts/compliance-audit.sh index 6a71d0466..57187ba8f 100755 --- a/scripts/compliance-audit.sh +++ b/scripts/compliance-audit.sh @@ -8,8 +8,9 @@ # standards/push-protection.md # # Outputs: -# $REPORT_DIR/findings.json — machine-readable findings -# $REPORT_DIR/summary.md — human-readable report +# $REPORT_DIR/findings.json — machine-readable findings +# $REPORT_DIR/summary.md — human-readable report +# $REPORT_DIR/issue-counts.json — issue management counts (added/existing/removed) # # Environment variables: # GH_TOKEN — GitHub token with repo/org scope (required) @@ -33,8 +34,14 @@ CREATE_ISSUES="${CREATE_ISSUES:-true}" FINDINGS_FILE="$REPORT_DIR/findings.json" SUMMARY_FILE="$REPORT_DIR/summary.md" ISSUES_FILE="$REPORT_DIR/issues.json" +ISSUE_COUNTS_FILE="$REPORT_DIR/issue-counts.json" -REQUIRED_WORKFLOWS=(ci.yml sonarcloud.yml claude.yml dependabot-automerge.yml dependency-audit.yml agent-shield.yml pr-review-mention.yml) +# Issue management counters (incremented by create_issue_for_finding / close_resolved_issues) +ISSUES_ADDED=0 +ISSUES_EXISTING=0 +ISSUES_REMOVED=0 + +REQUIRED_WORKFLOWS=(ci.yml sonarcloud.yml dev-lead.yml dependabot-automerge.yml dependency-audit.yml agent-shield.yml pr-review-mention.yml) # Note: codeql.yml is intentionally NOT in REQUIRED_WORKFLOWS. CodeQL is now # configured via GitHub-managed default setup (Settings → Code security → # Code scanning), not a per-repo workflow file. The check_codeql_default_setup @@ -93,6 +100,13 @@ log_end() { echo "::endgroup::" >&2; } info() { echo "[INFO] $*" >&2; } warn() { echo "::warning::$*" >&2; } +# escape_ere escapes ERE metacharacters in a string for literal matching in grep -E. +# This ensures that version tags (e.g. v2.1) and reusable basenames are treated +# as literal strings even if they contain regex metacharacters. +escape_ere() { + printf '%s' "$1" | sed 's/[][\.^$*+?(){}|\\/{}]/\\&/g' +} + # Retry wrapper for gh api calls (handles rate limits) gh_api() { local retries=3 @@ -523,23 +537,43 @@ check_sonarcloud() { check_codeql_default_setup() { local repo="$1" - # Query the default-setup state. The endpoint returns 200 with a JSON body - # describing the state, OR a 4xx if the repo has no code scanning capability - # (e.g. private without GHAS, archived). Treat any non-"configured" state - # as a finding so the audit surfaces what needs remediation. - local state - state=$(gh_api "repos/$ORG/$repo/code-scanning/default-setup" --jq '.state' 2>/dev/null || echo "") - - if [ "$state" != "configured" ]; then - local detail - if [ -z "$state" ]; then - detail="CodeQL default setup query returned no state — either the repo has code scanning disabled or the API call failed. Enable via \`gh api -X PATCH repos/$ORG/$repo/code-scanning/default-setup -F state=configured -F query_suite=default\`." + # Query the default-setup state. + # IMPORTANT: Do NOT use the gh_api() retry wrapper here. When gh api gets a + # 403 it outputs the error JSON body to stdout before exiting non-zero. The + # retry wrapper loops 3 times without suppressing stdout, so the captured + # output ends up as 3 concatenated error bodies — indistinguishable from a + # real "not-configured" state and causing persistent false-positive findings. + # We capture the response body and exit code separately so we can detect a + # 403 permission error and skip without filing a spurious finding. + local raw_response="" + local api_ok=0 + raw_response=$(gh api "repos/$ORG/$repo/code-scanning/default-setup" 2>/dev/null) || api_ok=$? + + if [ "$api_ok" -ne 0 ]; then + # Distinguish a 403 permission error from other failures (404, 500, …). + # The gh api error body contains `"status": "403"` (a JSON string) for + # "Resource not accessible by personal access token" responses. + if echo "$raw_response" | jq -e '.status == "403"' > /dev/null 2>&1; then + # ORG_SCORECARD_TOKEN lacks the security_events scope required by this + # endpoint. We cannot determine the CodeQL default-setup state, so we + # skip without adding a finding — a 403 from the audit token must not be + # misreported as "not configured". To verify state manually run: + # gh api repos/$ORG/$repo/code-scanning/default-setup + # with a token that carries security_events (or repo-admin) scope. + info " CodeQL default setup check skipped for $repo — audit token lacks required permissions (403)" else - detail="CodeQL default setup is in state \`$state\` (expected \`configured\`). Run \`apply-repo-settings.sh $repo\` or \`gh api -X PATCH repos/$ORG/$repo/code-scanning/default-setup -F state=configured -F query_suite=default\`." + add_finding "$repo" "ci-workflows" "codeql-default-setup-not-configured" "error" \ + "CodeQL default setup query returned no state — either the repo has code scanning disabled or the API call failed. Enable via \`gh api -X PATCH repos/$ORG/$repo/code-scanning/default-setup -F state=configured -F query_suite=default\`." \ + "standards/ci-standards.md#2-codeql-analysis-github-managed-default-setup" + fi + else + local state + state=$(echo "$raw_response" | jq -r '.state // ""') + if [ "$state" != "configured" ]; then + add_finding "$repo" "ci-workflows" "codeql-default-setup-not-configured" "error" \ + "CodeQL default setup is in state \`$state\` (expected \`configured\`). Run \`apply-repo-settings.sh $repo\` or \`gh api -X PATCH repos/$ORG/$repo/code-scanning/default-setup -F state=configured -F query_suite=default\`." \ + "standards/ci-standards.md#2-codeql-analysis-github-managed-default-setup" fi - add_finding "$repo" "ci-workflows" "codeql-default-setup-not-configured" "error" \ - "$detail" \ - "standards/ci-standards.md#2-codeql-analysis-github-managed-default-setup" fi # Stray workflow check: any codeql.yml under .github/workflows is drift. @@ -637,17 +671,53 @@ check_claude_workflow_checkout() { } # --------------------------------------------------------------------------- -# Check: Tier 1 centralized workflows must be thin caller stubs pinned to @v1 +# Check: ci.yml uses SHA-scoped concurrency group +# +# Per-ref concurrency groups (`ci-${{ github.ref }}`) with cancel-in-progress +# can leave the HEAD commit without CI results when a rapid push arrives while +# the previous cancellation is in flight. The standard requires the group to +# include github.sha so every commit gets its own slot. +# +# See standards/ci-standards.md#1-ci-pipeline-ciyml for the rationale. +# --------------------------------------------------------------------------- +check_ci_concurrency() { + local repo="$1" + + local content + content=$(gh_api "repos/$ORG/$repo/contents/.github/workflows/ci.yml" --jq '.content' 2>/dev/null || echo "") + [ -z "$content" ] && return # missing ci.yml is caught by check_required_workflows + + local decoded + decoded=$(echo "$content" | base64 -d 2>/dev/null || echo "") + [ -z "$decoded" ] && return + + # Only flag workflows that have a concurrency block but are missing github.sha. + # Workflows with no concurrency block at all are not flagged here (they may be + # intentionally unbounded for reasons outside this check's scope). + if echo "$decoded" | grep -qE '^concurrency:'; then + if ! echo "$decoded" | grep -qE 'group:.*github\.sha'; then + add_finding "$repo" "ci-workflows" "ci-concurrency-missing-sha" "warning" \ + "The \`ci.yml\` concurrency group does not include \`github.sha\`. A per-ref group with \`cancel-in-progress: true\` can leave the HEAD commit with no CI results when pushes arrive in quick succession. Update to: \`group: ci-\${{ github.ref }}-\${{ github.sha }}\`." \ + "standards/ci-standards.md#1-ci-pipeline-ciyml" + fi + fi +} + +# --------------------------------------------------------------------------- +# Check: Tier 1 centralized workflows must be thin caller stubs pinned to the +# canonical version tag for their reusable. # # For each workflow that the org has centralized into a reusable workflow, # verify the downstream repo's copy is a stub that delegates via: -# uses: petry-projects/.github/.github/workflows/.yml@v1 +# uses: petry-projects/.github/.github/workflows/.yml@ # # This prevents drift: a repo that copies the inline pre-centralization -# version (or pins to @main, or pins to an older tag) is flagged so it -# can be re-synced from the standard. The central .github repo itself is +# version (or pins to @main, or pins to a non-canonical tag) is flagged so +# it can be re-synced from the standard. The central .github repo itself is # exempt because it owns the reusables and may legitimately reference # its own workflows by @main during release prep. +# +# Array format: "workflow-filename:expected-reusable-basename:version-tag" # --------------------------------------------------------------------------- check_centralized_workflow_stubs() { local repo="$1" @@ -656,16 +726,16 @@ check_centralized_workflow_stubs() { # own reusables by @main; skip the stub check for it. [ "$repo" = ".github" ] && return - # workflow-filename:expected-reusable-basename + # workflow-filename:expected-reusable-basename:version-tag local centralized=( - "claude.yml:claude-code-reusable" - "auto-rebase.yml:auto-rebase-reusable" - "dependency-audit.yml:dependency-audit-reusable" - "dependabot-automerge.yml:dependabot-automerge-reusable" - "dependabot-rebase.yml:dependabot-rebase-reusable" - "agent-shield.yml:agent-shield-reusable" - "feature-ideation.yml:feature-ideation-reusable" - "pr-review-mention.yml:pr-review-mention-reusable" + "dev-lead.yml:dev-lead-reusable:v1" + "auto-rebase.yml:auto-rebase-reusable:v1" + "dependency-audit.yml:dependency-audit-reusable:v1" + "dependabot-automerge.yml:dependabot-automerge-reusable:v1" + "dependabot-rebase.yml:dependabot-rebase-reusable:v1" + "agent-shield.yml:agent-shield-reusable:v1" + "feature-ideation.yml:feature-ideation-reusable:v1" + "pr-review-mention.yml:pr-review-mention-reusable:v2" ) # List the repo's workflow directory once instead of probing each file. @@ -674,9 +744,10 @@ check_centralized_workflow_stubs() { workflow_list=$(gh_api "repos/$ORG/$repo/contents/.github/workflows" --jq '.[].name' 2>/dev/null || echo "") [ -z "$workflow_list" ] && return - local entry wf reusable + local entry wf reusable version for entry in "${centralized[@]}"; do - IFS=':' read -r wf reusable <<< "$entry" + IFS=':' read -r wf reusable version <<< "$entry" + [ -z "$version" ] && { echo "::error::centralized entry '$entry' missing version tag — expected format 'wf:reusable:version'" >&2; exit 1; } # Skip workflows that don't exist in this repo. Required workflows are # checked separately by check_required_workflows; conditional ones @@ -694,19 +765,22 @@ check_centralized_workflow_stubs() { [ -z "$decoded" ] && continue # Required pattern: a non-comment line whose `uses:` value is exactly - # petry-projects/.github/.github/workflows/.yml@v1 + # petry-projects/.github/.github/workflows/.yml@ # Anchor to start-of-line + optional indent so a `# uses: ...` comment # cannot satisfy the check. - local expected="petry-projects/\\.github/\\.github/workflows/${reusable}\\.yml@v1" + local esc_reusable esc_version + esc_reusable=$(escape_ere "$reusable") + esc_version=$(escape_ere "$version") + local expected="petry-projects/\\.github/\\.github/workflows/${esc_reusable}\\.yml@${esc_version}" if echo "$decoded" | grep -qE "^[[:space:]]*uses:[[:space:]]*${expected}([[:space:]]|$)"; then - continue # stub is correctly pinned to @v1 — compliant + continue # stub is correctly pinned to the canonical version — compliant fi # Determine why it's non-compliant for a more actionable message. local why - if echo "$decoded" | grep -qE "^[[:space:]]*uses:[[:space:]]*petry-projects/\\.github/\\.github/workflows/${reusable}\\.yml@"; then - why="references the reusable but is not pinned to \`@v1\` (org standard)" + if echo "$decoded" | grep -qE "^[[:space:]]*uses:[[:space:]]*petry-projects/\\.github/\\.github/workflows/${esc_reusable}\\.yml@"; then + why="references the reusable but is not pinned to \`@${version}\` (org standard)" elif echo "$decoded" | grep -qF "petry-projects/.github/.github/workflows/${reusable}"; then why="references the reusable but the \`uses:\` line does not match the canonical stub" else @@ -714,7 +788,7 @@ check_centralized_workflow_stubs() { fi add_finding "$repo" "ci-workflows" "non-stub-$wf" "error" \ - "Centralized workflow \`$wf\` $why. Replace with the canonical stub from \`standards/workflows/${wf}\` which delegates to \`petry-projects/.github/.github/workflows/${reusable}.yml@v1\`." \ + "Centralized workflow \`$wf\` $why. Replace with the canonical stub from \`standards/workflows/${wf}\` which delegates to \`petry-projects/.github/.github/workflows/${reusable}.yml@${version}\`." \ "standards/ci-standards.md#centralization-tiers" done } @@ -991,7 +1065,8 @@ create_issue_for_finding() { 2>/dev/null | head -1 || echo "") if [ -n "$existing" ]; then - # Update existing issue with a comment + # Update existing issue with a comment; only count as existing if the update succeeds + local update_ok=true gh issue comment "$existing" --repo "$ORG/$repo" \ --body "**Weekly Compliance Audit** ($(date -u +%Y-%m-%d)) @@ -999,18 +1074,24 @@ This finding is still open. **Detail:** $detail -**Standard:** [$standard_ref](https://github.com/$ORG/.github/blob/main/$standard_ref)" 2>/dev/null || true - # Ensure claude label is present on pre-existing issues +**Standard:** [$standard_ref](https://github.com/$ORG/.github/blob/main/$standard_ref)" 2>/dev/null || update_ok=false + # Ensure claude label is present on pre-existing issues regardless gh issue edit "$existing" --repo "$ORG/$repo" --add-label "claude" 2>/dev/null || true - info "Updated existing issue #$existing in $repo for: $check" + if [ "$update_ok" = "true" ]; then + info "Updated existing issue #$existing in $repo for: $check" + ISSUES_EXISTING=$((ISSUES_EXISTING + 1)) + else + warn "Failed to update existing issue #$existing in $repo for: $check" + fi # Record existing issue for umbrella jq --null-input \ --arg repo "$repo" \ --arg category "$category" \ --arg check "$check" \ + --arg severity "$severity" \ --arg number "$existing" \ --arg url "https://github.com/$ORG/$repo/issues/$existing" \ - '{repo:$repo,category:$category,check:$check,number:$number,url:$url}' \ + '{repo:$repo,category:$category,check:$check,severity:$severity,number:$number,url:$url}' \ >> "$ISSUES_FILE" return fi @@ -1051,6 +1132,7 @@ See the [full standards documentation](https://github.com/${ORG}/.github/tree/ma local new_issue new_issue=$(echo "$issue_url" | grep -oE '[0-9]+$' || echo "") info "Created issue #$new_issue in $repo for: $check ($issue_url)" + ISSUES_ADDED=$((ISSUES_ADDED + 1)) # Record created issue for umbrella if [ -n "$new_issue" ]; then @@ -1058,9 +1140,10 @@ See the [full standards documentation](https://github.com/${ORG}/.github/tree/ma --arg repo "$repo" \ --arg category "$category" \ --arg check "$check" \ + --arg severity "$severity" \ --arg number "$new_issue" \ --arg url "$issue_url" \ - '{repo:$repo,category:$category,check:$check,number:$number,url:$url}' \ + '{repo:$repo,category:$category,check:$check,severity:$severity,number:$number,url:$url}' \ >> "$ISSUES_FILE" fi else @@ -1218,10 +1301,14 @@ close_resolved_issues() { # If this check is no longer in findings, close the issue if ! echo "$current_checks" | grep -qx "$check_name"; then - gh issue close "$issue_num" --repo "$ORG/$repo" \ - --comment "Resolved! This check is now passing as of $(date -u +%Y-%m-%d). Closing automatically." \ - 2>/dev/null || true - info "Closed resolved issue #$issue_num in $repo: $issue_title" + if gh issue close "$issue_num" --repo "$ORG/$repo" \ + --comment "Resolved! This check is now passing as of $(date -u +%Y-%m-%d). Closing automatically." \ + 2>/dev/null; then + info "Closed resolved issue #$issue_num in $repo: $issue_title" + ISSUES_REMOVED=$((ISSUES_REMOVED + 1)) + else + warn "Failed to close resolved issue #$issue_num in $repo: $issue_title" + fi fi done <<< "$open_issues" } @@ -1251,40 +1338,61 @@ generate_summary() { | Errors (must fix) | $error_count | | Warnings (should fix) | $warning_count | -## Findings by Repository - HEREDOC - # Group findings by repo - local repos_with_findings - repos_with_findings=$(jq -r '[.[].repo] | unique[]' "$FINDINGS_FILE") - - if [ -z "$repos_with_findings" ]; then + if [ "$total_findings" -eq 0 ]; then echo "All repositories are fully compliant! No findings." >> "$SUMMARY_FILE" return fi - for repo in $repos_with_findings; do - local repo_findings - repo_findings=$(jq -r --arg repo "$repo" \ - '.[] | select(.repo == $repo) | "| `\(.severity)` | \(.category) | \(.check) | \(.detail) |"' \ - "$FINDINGS_FILE") - - local repo_count - repo_count=$(jq --arg repo "$repo" '[.[] | select(.repo == $repo)] | length' "$FINDINGS_FILE") + # ── Findings by Check Type ────────────────────────────────────────────────── + # Group by check name (not repo) to surface cross-repo patterns at a glance. + # Errors first, then warnings; within each severity sorted by category then check. + cat >> "$SUMMARY_FILE" <<'HEREDOC' +## Findings by Check Type - cat >> "$SUMMARY_FILE" <> "$SUMMARY_FILE" + + # ── Per-Repo Scorecard ─────────────────────────────────────────────────────── + cat >> "$SUMMARY_FILE" <<'HEREDOC' + +## Per-Repo Scorecard + +| Repo | Errors | Warnings | Total | +|------|--------|----------|-------| HEREDOC - done - # Category breakdown - cat >> "$SUMMARY_FILE" <> "$SUMMARY_FILE" + + # ── Category breakdown ─────────────────────────────────────────────────────── + cat >> "$SUMMARY_FILE" <<'HEREDOC' + ## Findings by Category HEREDOC @@ -1296,12 +1404,109 @@ HEREDOC echo "- **$category:** $cat_count finding(s)" >> "$SUMMARY_FILE" fi done + # Footer appended by main() after issue links are added +} - cat >> "$SUMMARY_FILE" < "$pr_data_file" + + local repos_in_issues + repos_in_issues=$(jq -rn '[inputs | .repo] | unique[]' "$ISSUES_FILE" 2>/dev/null || echo "") + + for repo in $repos_in_issues; do + local repo_prs + repo_prs=$(gh api graphql \ + -f owner="$ORG" -f name="$repo" \ + -f query='query($owner:String!,$name:String!){ + repository(owner:$owner,name:$name){ + pullRequests(states:OPEN,first:100){ + nodes{ + number url + closingIssuesReferences(first:10){nodes{number}} + } + } + } + }' 2>/dev/null \ + | jq --arg repo "$repo" '[ + .data.repository.pullRequests.nodes[] | { + repo: $repo, + pr_number: .number, + pr_url: .url, + closes: [.closingIssuesReferences.nodes[].number] + } + ]' 2>/dev/null || echo '[]') + + jq -n \ + --argjson existing "$(cat "$pr_data_file")" \ + --argjson new_prs "$repo_prs" \ + '$existing + $new_prs' > "$pr_data_file.tmp" \ + && mv "$pr_data_file.tmp" "$pr_data_file" + done + + cat >> "$SUMMARY_FILE" <<'HEREDOC' + +## Issues & Related PRs + +Grouped by compliance check type. Each entry links to the GitHub Issue for the +finding in each affected repo; **Related PRs** shows open pull requests that +close that issue. ---- -*Generated by the [weekly compliance audit](https://github.com/$ORG/.github/blob/main/.github/workflows/compliance-audit.yml) on $(date -u "+%Y-%m-%d %H:%M UTC").* HEREDOC + + # Iterate checks in severity then alphabetical order + local checks_ordered + checks_ordered=$(jq -rn '[inputs] + | group_by(.check) + | map({check: .[0].check, severity: .[0].severity}) + | sort_by([(if .severity == "error" then 0 else 1 end), .check]) + | .[].check + ' "$ISSUES_FILE" 2>/dev/null || echo "") + + while IFS= read -r check; do + [ -z "$check" ] && continue + + local check_issues severity category issue_count + check_issues=$(jq -cn --arg c "$check" '[inputs | select(.check == $c)] | sort_by(.repo)' "$ISSUES_FILE") + severity=$(jq -r '.[0].severity' <<< "$check_issues") + category=$(jq -r '.[0].category' <<< "$check_issues") + issue_count=$(jq 'length' <<< "$check_issues") + + printf '\n### `%s`\n' "$check" >> "$SUMMARY_FILE" + printf '**Severity:** `%s` | **Category:** %s | **%d repo(s) affected**\n\n' \ + "$severity" "$category" "$issue_count" >> "$SUMMARY_FILE" + printf '| Repo | Issue | Related PRs |\n' >> "$SUMMARY_FILE" + printf '|------|-------|-------------|\n' >> "$SUMMARY_FILE" + + while IFS= read -r issue_entry; do + local repo issue_num issue_url pr_links + repo=$(jq -r '.repo' <<< "$issue_entry") + issue_num=$(jq -r '.number' <<< "$issue_entry") + issue_url=$(jq -r '.url' <<< "$issue_entry") + + pr_links=$(jq -r \ + --arg repo "$repo" \ + --argjson inum "$issue_num" \ + '[.[] | select(.repo == $repo and (.closes | map(. == $inum) | any)) + | "[#\(.pr_number)](\(.pr_url))"] + | if length > 0 then join(", ") else "—" end' \ + "$pr_data_file") + + printf '| [%s](https://github.com/%s/%s) | [#%s](%s) | %s |\n' \ + "$repo" "$ORG" "$repo" "$issue_num" "$issue_url" "$pr_links" \ + >> "$SUMMARY_FILE" + done < <(jq -c '.[]' <<< "$check_issues") + done <<< "$checks_ordered" + + rm -f "$pr_data_file" } # --------------------------------------------------------------------------- @@ -1373,7 +1578,8 @@ main() { check_sonarcloud "$repo" check_codeql_default_setup "$repo" check_workflow_permissions "$repo" - check_claude_workflow_checkout "$repo" + # check_claude_workflow_checkout "$repo" # removed: claude.yml retired 2026-05 + check_ci_concurrency "$repo" check_centralized_workflow_stubs "$repo" check_centralized_check_names "$repo" check_claude_md "$repo" @@ -1417,10 +1623,45 @@ main() { # Create one umbrella issue per audit run grouping all findings by remediation category. # Both individual issues and the umbrella get the `claude` label for agent pickup. create_umbrella_issue + + # Append per-check issue links and related open PRs to the step summary + info "Fetching linked PRs for issue summary..." + append_issue_pr_links else info "Skipping issue creation (DRY_RUN=$DRY_RUN, CREATE_ISSUES=$CREATE_ISSUES)" fi + # Write issue-management counts and append to summary (conditional on issue management running) + if [ "$CREATE_ISSUES" = "true" ] && [ "$DRY_RUN" != "true" ]; then + printf '{"added":%d,"existing":%d,"removed":%d}\n' \ + "$ISSUES_ADDED" "$ISSUES_EXISTING" "$ISSUES_REMOVED" > "$ISSUE_COUNTS_FILE" + cat >> "$SUMMARY_FILE" < "$ISSUE_COUNTS_FILE" + cat >> "$SUMMARY_FILE" <> "$SUMMARY_FILE" <&2 ORG_REPOS=$(gh repo list petry-projects --json name --limit 1000 | jq -r '.[].name' | sort) -PERSONAL_REPOS=$(gh repo list don-petry --json name --limit 1000 | jq -r '.[].name' | sort) -echo "petry-projects: $(echo "$ORG_REPOS" | wc -l | tr -d ' ') repos | don-petry: $(echo "$PERSONAL_REPOS" | wc -l | tr -d ' ') repos" >&2 +echo "petry-projects: $(echo "$ORG_REPOS" | wc -l | tr -d ' ') repos" >&2 echo "::endgroup::" >&2 # ── PR Collection + Classification ─────────────────────────────────────────── @@ -108,15 +107,6 @@ for repo in $ORG_REPOS; do ALL_PR_NDJSON+=$'\n' fi done -for repo in $PERSONAL_REPOS; do - prs=$(collect_classify_prs "don-petry" "$repo") - count=$(jq 'length' <<< "$prs") - [ "$count" -gt 0 ] && echo " don-petry/$repo: $count open PRs" >&2 - if [ "$count" -gt 0 ]; then - ALL_PR_NDJSON+=$(jq -c '.[]' <<< "$prs") - ALL_PR_NDJSON+=$'\n' - fi -done ALL_PRS=$(jq -cs '.' <<< "$ALL_PR_NDJSON") echo "Total open PRs: $(echo "$ALL_PRS" | jq 'length')" >&2 echo "::endgroup::" >&2 @@ -180,30 +170,26 @@ ISSUE_PR_MAP=$(echo "$ALL_PRS" | jq ' echo "::group::Collecting merge activity" >&2 ORG_MERGES=$(gh search prs --owner=petry-projects --merged --merged-at=">=$SINCE" \ --json number,repository,closedAt --limit 1000 2>/dev/null || echo '[]') -PERSONAL_MERGES=$(gh search prs --owner=don-petry --merged --merged-at=">=$SINCE" \ - --json number,repository,closedAt --limit 1000 2>/dev/null || echo '[]') # Write merges to a temp file so subsequent jq calls read from a file descriptor # rather than shell arguments — avoids the same ARG_MAX risk at high merge volumes. -printf '{"org":%s,"personal":%s}\n' "$ORG_MERGES" "$PERSONAL_MERGES" > "$DATA_DIR/merges.json" +printf '{"org":%s}\n' "$ORG_MERGES" > "$DATA_DIR/merges.json" MERGE_DAILY=$(jq --arg since "$SINCE" --arg today "$TODAY" ' - .org as $org | .personal as $personal | + .org as $org | # Build 8-day date list (since through today inclusive) def dates: [range(8) | ($since | strptime("%Y-%m-%d") | mktime) + (. * 86400) | strftime("%Y-%m-%d")]; # Capture $date before entering the generator so . refers to the right scope dates | map(. as $date | { date: $date, - org: ([$org[] | select(.closedAt[:10] == $date)] | length), - personal: ([$personal[] | select(.closedAt[:10] == $date)] | length) + org: ([$org[] | select(.closedAt[:10] == $date)] | length) })' "$DATA_DIR/merges.json") # Per-repo per-day merge counts (for the enhanced merge activity table) MERGE_BY_REPO_DAY=$(jq --arg since "$SINCE" ' - .org as $org | .personal as $personal | + .org as $org | def dates: [range(8) | ($since | strptime("%Y-%m-%d") | mktime) + (. * 86400) | strftime("%Y-%m-%d")]; - (($org | map({repo: ("petry-projects/" + .repository.name), date: .closedAt[:10]})) + - ($personal | map({repo: ("don-petry/" + .repository.name), date: .closedAt[:10]}))) | + ($org | map({repo: ("petry-projects/" + .repository.name), date: .closedAt[:10]})) | sort_by(.repo) | group_by(.repo) | map( . as $items | { @@ -227,16 +213,6 @@ for repo in $ORG_REPOS; do ISSUES_NDJSON+=$'\n' fi done -for repo in $PERSONAL_REPOS; do - issues=$(gh issue list --repo "don-petry/$repo" --state open \ - --json number,title,createdAt,labels,url --limit 1000 2>/dev/null || echo '[]') - count=$(jq 'length' <<< "$issues") - if [ "$count" -gt 0 ]; then - echo " don-petry/$repo: $count open issues" >&2 - ISSUES_NDJSON+=$(jq -c --arg repo "don-petry/$repo" '{repo: $repo, count: length, issues: .}' <<< "$issues") - ISSUES_NDJSON+=$'\n' - fi -done ISSUES_BY_REPO=$(jq -cs '.' <<< "$ISSUES_NDJSON") echo "::endgroup::" >&2 @@ -287,11 +263,11 @@ ISSUES_BY_REPO_TRIMMED=$(echo "$ISSUES_BY_REPO" | jq --argjson limit "$ISSUE_LIM })') cat > "$DATA_DIR/prompt.txt" << PROMPT -Generate a daily GitHub org status report for $TODAY. +Generate a daily GitHub org status report for petry-projects on $TODAY. Use ONLY the data below. Output ONLY the markdown report — no preamble, no commentary. -CRITICAL: You MUST output ALL sections listed in REPORT FORMAT, in order. Do NOT skip or abbreviate any section. The PR summary table MUST appear first. +CRITICAL: You MUST output ALL sections listed in REPORT FORMAT, in order. Do NOT skip or abbreviate any section. --- @@ -312,7 +288,7 @@ $(echo "$MERGE_DAILY" | jq -c '.') ### Merge Activity — Per-Repo Per-Day (repo, total, by_date map keyed by YYYY-MM-DD) $(echo "$MERGE_BY_REPO_DAY" | jq -c '.') -### Open Issues by Repo (each issue has url field; truncated:true means more exist beyond the 25 shown) +### Open Issues by Repo (each issue has url field; truncated:true means more exist beyond the $ISSUE_LIMIT shown) $(echo "$ISSUES_BY_REPO_TRIMMED" | jq -c '.') ### Open Discussions (each discussion has url field) @@ -325,30 +301,88 @@ $(echo "$DISCUSSIONS" | jq -c '.') Begin the report with this exact line (replace nothing): @org-leads -Then produce these sections in order. IMPORTANT: output each \`##\` section header before its table content — do NOT skip any section header or table header row. +Then produce ALL of these sections in EXACTLY this order. Output each \`##\` section header before its content — do NOT skip any header, table header row, or section. + +--- + +### \`## Org Summary — $TODAY\` + +A single compact table with one row per metric: +| Metric | Value | +|---|---| +| Total open PRs | _sum all repos_ | +| PRs needing rebase | _sum needs_rebase across all repos_ | +| Total open issues | _sum all repos_ | +| PR merges (last 8 days) | _sum .org across all dates in Merge Activity — Daily Counts_ | +| Open discussions | _count all discussions_ | + +Then immediately after the table, a mermaid pie chart of open PRs by category (use the org-wide totals): +\`\`\`mermaid +pie title Open PRs by Status + "Awaiting Review" : + ... +\`\`\` +Replace each with the actual org-wide count. Omit zero-count categories. Sort slices from largest to smallest count. + +--- ### \`## Open PRs — Why They're Unmerged (N total)\` (Replace N with the actual total count.) -Org-wide blocker summary table (sum all repos). You MUST include the header row and separator row: -| Category | Count | % of Total | -|---|---|---| -Rows in this order: Awaiting Review, CI Failing, CI Pending, Changes Requested, Approved, Draft, No CI / No Policy, **TOTAL** - -Per-repo breakdown table (omit repos with 0 total PRs). You MUST include the header row and separator row: -| Repo | Total | Awaiting Review | CI Failing | CI Pending | Changes Req | Approved | No CI/Policy | Draft | Needs Rebase | -|---|---|---|---|---|---|---|---|---|---| -- Repo name as a link to the repo: [owner/repo](https://github.com/owner/repo) -- Add ⚠ next to repo name if CI Failing > 5 or Awaiting Review > 10 -- Needs Rebase column: render the \`needs_rebase\` count from the data. If the count is 0 render —. If > 0 render the number followed by 🔄 (e.g. \`3 🔄\`). + +First, an xychart-beta bar chart of org-wide PR counts by blocker category. Omit zero-count categories. Sort x-axis from highest to lowest count: +\`\`\`mermaid +xychart-beta + title "Open PRs by Blocker Category" + x-axis [] + y-axis "Count" + bar [] +\`\`\` + +Then, a grouped bar chart for per-repo breakdown using multiple bar series (one per key category). Omit repos with 0 total PRs. Sort repos by total PRs descending. Use short repo names (e.g. "broodly"). Include only the 4 most actionable categories as separate bar series: No CI/Policy, Awaiting Review, CI Failing, Approved. Note: xychart-beta does not support stacked bars — multiple bar lines render as grouped/overlapping series: +\`\`\`mermaid +xychart-beta + title "Open PRs per Repo by Category" + x-axis [] + y-axis "PRs" + bar [] + bar [] + bar [] + bar [] +\`\`\` + +--- + +### \`## PR Merge Activity — Last 8 Days\` +A mermaid bar chart of daily org merge counts (use Merge Activity — Daily Counts): +\`\`\`mermaid +xychart-beta + title "petry-projects Merges — Last 8 Days" + x-axis [] + y-axis "Merges" + bar [] +\`\`\` + +Per-repo-per-day table using Merge Activity — Per-Repo Per-Day data (omit repos with 0 total): +| Repo | Mon-DD | … | Total | +- One column per date in chronological order (all 8 dates) +- Date headers: short format Mon-DD (e.g. Apr-26) +- Repo as link: [owner/repo](https://github.com/owner/repo) +- Last column is Total (bold the number) +- Add a **TOTAL** row summing each date column and grand total +Grand total and trend sentence (immediately after the per-repo table). Trend: Increasing if avg(last 3 days) > avg(first 3 days), Decreasing if opposite, Flat otherwise. + +--- ### \`## Open PRs — Needs Human Review\` -Full table for PRs with needsHumanReview == true: +Full table for PRs with needsHumanReview == true, sorted by Opened ascending (oldest first): | Repo | PR | Opened | CI | Approvals | |---|---|---|---|---| - PR cell: single markdown link combining number and title, e.g. \`[#42 — Fix the thing](url)\` - CI: PASS (SUCCESS) / FAIL (FAILURE or ERROR) / PENDING / N/A (null) If none: _none_ +--- + ### \`## Open PRs — Automation (Dependency Bumps)\` Counts only per repo (dep_bumps > 0): | Repo | # Dep PRs | @@ -356,30 +390,22 @@ Counts only per repo (dep_bumps > 0): - Repo as link: [owner/repo](https://github.com/owner/repo) If none: _none_ +--- + ### \`## Open Issues (N total)\` Render as a per-repo subsection list (NOT a single flat table). For each repo with issues, in the order provided: \`### [owner/repo](https://github.com/owner/repo) (N issues)\` - If truncated:true, replace the suffix with " (showing $ISSUE_LIMIT of N issues)". -Then a small table (Repo column omitted — it's in the heading): +Then a table (Repo column omitted — it's in the heading): | Issue | Opened | Labels | Linked PR | |---|---|---|---| - Issue cell: single markdown link combining number and title, e.g. \`[#123 — Compliance: foo](url)\` - Opened = createdAt date only (YYYY-MM-DD) - Linked PR: look up "owner/repo#N" in the Issue→Linked PR Map; if found render as [#M](pr_url); if multiple, comma-separate; if none render — -### \`## PR Merge Activity — Last 8 Days\` -Per-repo-per-day table using the Merge Activity — Per-Repo Per-Day data (omit repos with 0 total): -| Repo | Mon-DD | Mon-DD | … | Total | -- One column per date in chronological order (all 8 dates, even if 0 across all repos) -- Date headers: short format Mon-DD (e.g. Apr-26) -- Repo as link: [owner/repo](https://github.com/owner/repo) -- Last column is Total (bold the number) -- Add a **TOTAL** row summing each date column and grand total -Daily org-level summary table (include zero rows): -| Date | petry-projects | don-petry | Grand Total | -Grand total and trend sentence. Trend: Increasing if avg(last 3 days) > avg(first 3 days), Decreasing if opposite, Flat otherwise. +--- ### \`## Open Discussions\` | Repo | Discussion | Opened | Replies | @@ -394,12 +420,30 @@ OUTPUT CONTRACT - Section headers include total counts: \`## Open Issues (47 total)\` - Empty sections show _none_, never omit them - Every item with a url must be rendered as a markdown hyperlink +- Output sections in EXACTLY the order listed above — do not reorder them PROMPT # ── Generate Report ─────────────────────────────────────────────────────────── # --disallowedTools: block all action tools so Claude cannot act on untrusted PR/issue content +# --output-format json: capture the full final result as JSON and extract the text with jq. +# In text mode, output preceding disallowed tool call attempts is silently dropped; json +# mode always includes the complete response in .result regardless of tool call filtering. +# Write to a temp file to avoid large bash variable assignments. # Pipe prompt via stdin rather than a shell argument to avoid ARG_MAX (~1MB) with large orgs +REPORT_JSON="$DATA_DIR/report.json" echo "Generating report with Claude..." >&2 claude -p \ + --output-format json \ --disallowedTools "Bash,Read,Write,Edit,Grep,Glob,WebFetch,WebSearch,Task,TodoWrite,NotebookEdit" \ - < "$DATA_DIR/prompt.txt" + < "$DATA_DIR/prompt.txt" > "$REPORT_JSON" +echo "JSON lines: $(wc -l < "$REPORT_JSON")" >&2 +echo "JSON first 600 chars:" >&2 +head -c 600 "$REPORT_JSON" >&2 +echo "" >&2 +if ! jq -e '(.result // "") | type == "string" and length > 0' "$REPORT_JSON" > /dev/null 2>&1; then + echo "ERROR: claude returned missing or empty .result field — raw output:" >&2 + cat "$REPORT_JSON" >&2 + exit 1 +fi +jq '{stop_reason,num_turns,total_cost_usd,result_len:((.result//"")|length),result_start:((.result//"")|.[0:120])}' "$REPORT_JSON" >&2 || true +jq -r '.result' "$REPORT_JSON" diff --git a/standards/ci-standards.md b/standards/ci-standards.md index d45f8d3ec..2166beaa6 100644 --- a/standards/ci-standards.md +++ b/standards/ci-standards.md @@ -22,7 +22,7 @@ where to send a fix when behavior needs to change. | Tier | Examples | What lives in `standards/workflows/` | Where logic lives | Edits allowed in adopting repo | |---|---|---|---|---| -| **1. Stub** | `claude.yml`, `dependency-audit.yml`, `dependabot-automerge.yml`, `dependabot-rebase.yml`, `agent-shield.yml`, `feature-ideation.yml` | A thin caller stub that delegates via `uses: petry-projects/.github/.github/workflows/-reusable.yml@v1` | The matching `*-reusable.yml` in this repo (single source of truth) | **None** in normal use. May tune `with:` inputs where the reusable exposes them (e.g. `agent-shield` accepts `min-severity`, `required-files`; `feature-ideation` requires `project_context`). To change behavior, open a PR against the reusable in this repo — repos on `@v1` pick it up after the `v1` tag is bumped; repos on `@main` pick it up on their next run. | +| **1. Stub** | `dev-lead.yml`, `dependency-audit.yml`, `dependabot-automerge.yml`, `dependabot-rebase.yml`, `agent-shield.yml`, `feature-ideation.yml`, `pr-review-mention.yml` | A thin caller stub that delegates via `uses: petry-projects/.github/.github/workflows/-reusable.yml@`, where `` is the canonical tag for that reusable (see `check_centralized_workflow_stubs` in `scripts/compliance-audit.sh`; most are `@v1`, `pr-review-mention` is `@v2`) | The matching `*-reusable.yml` in this repo (single source of truth) | **None** in normal use. May tune `with:` inputs where the reusable exposes them (e.g. `agent-shield` accepts `min-severity`, `required-files`; `feature-ideation` requires `project_context`). To change behavior, open a PR against the reusable in this repo — the canonical tag is bumped deliberately when a release is ready. | | **2. Per-repo template** | `ci.yml`, `sonarcloud.yml` | _(no template — see the patterns documented below)_ | In each repo, because the workflow is tech-stack-specific (language matrix, build tool, test framework) | **Limited.** Each adopting repo carries its own copy. Stay within the patterns in this document; do not change action SHAs, permission scopes, trigger events, or job names without raising a standards PR first. | | **GitHub-managed** | CodeQL default setup | _(no workflow file — managed via repo Settings → Code security)_ | GitHub | None. Configured via `apply-repo-settings.sh`; per-repo `codeql.yml` files are treated as drift by the compliance audit. See [§2 CodeQL Analysis](#2-codeql-analysis-github-managed-default-setup). | | **3. Free per-repo** | `release.yml`, project-specific automation | _(out of scope for this standard)_ | Per-repo | Free, but must still comply with the [Action Pinning Policy](#action-pinning-policy) and the [Required Workflows](#required-workflows) constraints. | @@ -33,18 +33,20 @@ file with that header, **stop and read the header first** — if the change isn't allowed by the contract, the right move is a PR against the central reusable, not a local edit. -> **Why pin to `@v1`?** Stubs reference reusables by tag, not `@main`, so a +> **Why pin to a canonical tag?** Stubs reference reusables by tag, not `@main`, so a > bad commit on the central repo's `main` branch cannot break every -> downstream repo simultaneously. The `v1` tag is bumped deliberately when -> a backward-compatible release is ready; breaking changes will publish a -> `v2` tag that downstream repos opt into explicitly. +> downstream repo simultaneously. Each reusable has its own canonical tag +> (most are `@v1`; `pr-review-mention` is `@v2`). A tag is bumped deliberately +> when a backward-compatible release is ready; breaking changes publish a new +> tag that downstream repos opt into explicitly. ### Available templates | Template | Tier | Purpose | |----------|------|---------| | [`agent-shield.yml`](workflows/agent-shield.yml) | 1 | Deep agent-config security scan via `ecc-agentshield` | -| [`claude.yml`](workflows/claude.yml) | 1 | Thin caller delegating to the org-level reusable Claude Code workflow (PR reviews, issue automation, CI failure fixes) | +| [`dev-lead.yml`](workflows/dev-lead.yml) | 1 | Event-driven AI automation (PR fixes, CI relay, review responses, issue handling) — replaced `claude.yml` 2026-05 | +| ~~`claude.yml`~~ | ~~1~~ | **Deprecated 2026-05.** Replaced by `dev-lead.yml`. See [§5 Migration](#migration-from-claudeyml). | | [`dependabot-automerge.yml`](workflows/dependabot-automerge.yml) | 1 | Auto-approve and squash-merge eligible Dependabot PRs | | [`auto-rebase.yml`](workflows/auto-rebase.yml) | 1 | Keep non-Dependabot PRs up-to-date with the base branch on every push to `main` | | [`dependabot-rebase.yml`](workflows/dependabot-rebase.yml) | 1 | Update and auto-merge eligible Dependabot PRs on every push to `main` | @@ -70,7 +72,7 @@ gh api repos/petry-projects/.github/contents/standards/workflows/.yml \ Every repository MUST have these 7 workflows. Reusable templates for Dependabot and AgentShield workflows are in [`standards/workflows/`](workflows/). The CI, -SonarCloud, and Claude Code workflows are documented as patterns +SonarCloud, and Dev-Lead Agent workflows are documented as patterns below — copy and adapt the examples to each repo's tech stack. CodeQL is **not** a workflow file: it is configured via GitHub-managed default setup (see [§2](#2-codeql-analysis-github-managed-default-setup)). @@ -111,10 +113,23 @@ on: permissions: {} # Reset top-level; set per-job (see Permissions Policy below) concurrency: - group: ci-${{ github.ref }} + group: ci-${{ github.ref }}-${{ github.sha }} cancel-in-progress: true ``` +> **Why SHA-scoped concurrency?** Per-ref groups (`ci-${{ github.ref }}`) with +> `cancel-in-progress: true` create a race: if the final push arrives while the +> previous cancellation is in flight, GitHub may not fire a new +> `pull_request: synchronize` event, leaving the HEAD commit with no CI results +> and blocking the PR indefinitely. Scoping the group to the commit SHA gives +> every commit its own concurrency slot so CI always runs to completion. +> +> **Why keep `cancel-in-progress: true`?** With SHA-scoped groups, no two +> pushes share a slot, so the setting is a no-op in practice. It is kept +> explicitly to signal intent — if someone later changes the group formula back +> to a per-ref pattern, the cancellation behaviour they expect is already +> declared and will take effect immediately without a separate edit. + ### 2. CodeQL Analysis (GitHub-managed default setup) Static Application Security Testing (SAST) via GitHub's CodeQL, configured @@ -283,10 +298,15 @@ If omitted, gitleaks runs in open-source mode (free, no license needed). | `missing gitleaks license` | License not passed to action | Ensure env includes `GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}` | | Secrets found | Legitimate secrets in the code | Use `.gitleaksignore` to allowlist false positives, or remove the secret | -### 5. Claude Code (`claude.yml`) +### 5. Claude Code (`claude.yml`) — *Deprecated 2026-05* + +> **Deprecated.** `claude.yml` has been removed from all `petry-projects` repos and replaced by +> `dev-lead.yml`. See [Adopting the Dev-Lead Agent](#adopting-the-dev-lead-agent) and +> [Migration from `claude.yml`](#migration-from-claudeyml). The content below is preserved for +> historical reference only. AI-assisted code review on PRs and issue automation via Claude Code Action. -A copy-paste ready template is available at [`standards/workflows/claude.yml`](workflows/claude.yml). +The template at [`standards/workflows/claude.yml`](workflows/claude.yml) is preserved for historical reference. > **OIDC security constraint — `claude.yml` is immutable on PR branches.** > Anthropic's token endpoint validates that `.github/workflows/claude.yml` on @@ -334,7 +354,9 @@ The workflow has three jobs: not GitHub Copilot premium requests. This is distinct from the "Assign to Agent" UI feature which consumes Copilot premium requests. -**Standard configuration:** +**Archived configuration — do not adopt.** The YAML below is a read-only historical reference; +`claude.yml` is no longer deployed in org repos. For the current AI-automation implementation, +see [Adopting the Dev-Lead Agent](#adopting-the-dev-lead-agent). ```yaml name: Claude Code @@ -442,39 +464,28 @@ jobs: comment tagging the relevant code owners to review and merge. ``` -**Required secrets:** `CLAUDE_CODE_OAUTH_TOKEN` +*Historical secrets: `CLAUDE_CODE_OAUTH_TOKEN`* -**Required labels:** The `claude` label (color: `7c3aed`) must exist on every -repository. The weekly compliance audit ensures this label is present. It can -also be applied manually to any issue to trigger Claude. +*Historical labels: `claude` (color: `7c3aed`) — was required on every repo for issue-triggered automation.* -**How Claude follows org standards:** `claude-code-action` automatically reads -`CLAUDE.md` from the repository root. The org-level `.github/CLAUDE.md` is -inherited by repos without their own. Each repo's `CLAUDE.md` references -`AGENTS.md` for cross-cutting development standards (TDD, SOLID, pre-commit -checks, etc.). The `claude-issue` job adds an automation `prompt` for the -issue-to-PR lifecycle, but Claude still reads `CLAUDE.md` and `AGENTS.md` -for project-specific context. +**How Claude followed org standards:** `claude-code-action` read `CLAUDE.md` from the repository +root. The org-level `.github/CLAUDE.md` was inherited by repos without their own. Each repo's +`CLAUDE.md` referenced `AGENTS.md` for cross-cutting standards. The `claude-issue` job added an +automation `prompt` for the issue-to-PR lifecycle. These behaviors are now handled by `dev-lead.yml`. -**Permissions note:** Both jobs use the same permission set. `contents: write` -is required for issue-triggered work where Claude creates branches and pushes -commits. `actions: read` and `checks: read` enable Claude to monitor CI status -via the GitHub MCP tools (`get_ci_status`, `get_workflow_run_details`, -`download_job_log`). +**Permissions note:** Both jobs used the same permission set: `contents: write` for branch/push +operations, `actions: read` and `checks: read` for CI monitoring via GitHub MCP tools. -**Dependabot behavior:** The Claude Code step in the `claude` job is skipped -for Dependabot PRs (the `if` condition on the step). The job still runs and -reports SUCCESS to satisfy required status checks. See -[AGENTS.md](../AGENTS.md#claude-code-workflow-on-dependabot-prs). +**Dependabot behavior:** The Claude Code step in the `claude` job was skipped +for Dependabot PRs. The job still ran and reported SUCCESS to satisfy required +status checks. See [AGENTS.md](../AGENTS.md#claude-code-workflow-on-dependabot-prs). -**Issue trigger security:** The `issues: [labeled]` event fires when any user -with triage or write access applies a label. The label name check in the `if:` -condition ensures only the `claude` label triggers the workflow — other labels -are ignored. Apply the `claude` label manually to any issue to trigger Claude. +**Issue trigger security:** The `issues: [labeled]` event fired when any user +with triage or write access applied a label. Only the `claude` label triggered +the workflow — other labels were ignored. -**Maintainer notification:** The `claude-issue` prompt reads `CODEOWNERS` at -runtime to determine who to tag. No per-repo customization is needed as long -as `CODEOWNERS` is present (checked by the compliance audit). +**Maintainer notification:** The `claude-issue` prompt read `CODEOWNERS` at +runtime to determine who to tag. ### 6. Dependabot Auto-Merge (`dependabot-automerge.yml`) @@ -508,9 +519,15 @@ On each run the workflow: 1. Lists all open same-repo PRs excluding `dependabot[bot]` and fork PRs. 2. For each PR that is behind the base branch, calls `PUT /pulls/{n}/update-branch` with `merge` method to fast-forward it. 3. On `workflows` permission error: posts an idempotent comment (sentinel ``) asking the author to rebase manually. -4. On merge conflict (422): posts an idempotent comment (sentinel ``) asking the author to resolve conflicts. +4. On merge conflict (422): deletes any prior sentinel and posts a fresh comment + (sentinel ``), which triggers the `claude-rebase` + job in `claude-code-reusable.yml` to automatically resolve the conflict. + If Claude cannot resolve it, it posts a clear failure comment with manual instructions. -**No secrets required** — uses `GITHUB_TOKEN` only. Dependabot PRs are excluded because `dependabot-rebase.yml` handles those. +**Secrets:** `GH_PAT_WORKFLOWS` is optional but **required for `claude-rebase` to be triggered** — +comments posted with `GITHUB_TOKEN` do not fire `issue_comment` workflow runs (GitHub limitation). +Without it the sentinel comment still appears but no automatic resolution will run. +Dependabot PRs are excluded because `dependabot-rebase.yml` handles those. **Compliance:** The compliance audit (`check_centralized_workflow_stubs`) verifies that repos adopting `auto-rebase.yml` use the canonical thin caller stub delegating to `petry-projects/.github/.github/workflows/auto-rebase-reusable.yml@v1`. @@ -540,7 +557,7 @@ present as an org-level secret; no per-repo setup needed. **Compliance:** The compliance audit (`check_centralized_workflow_stubs`) verifies that repos have `pr-review-mention.yml` as a thin caller stub delegating to -`petry-projects/.github/.github/workflows/pr-review-mention-reusable.yml@v1`. +`petry-projects/.github/.github/workflows/pr-review-mention-reusable.yml@v2`. --- @@ -839,26 +856,26 @@ incorrect pinned one. ### Exception: Internal Reusable Workflow References Calls to `petry-projects/.github` reusable workflows use tag references -(`@v1`, `@main`) — **not SHA pins** — and are exempt from this policy. +(`@v1`, `@v2`, or `@main`) — **not SHA pins** — and are exempt from this policy. ```yaml # CORRECT — tag ref for internal reusable workflow -uses: petry-projects/.github/.github/workflows/claude-code-reusable.yml@v1 +uses: petry-projects/.github/.github/workflows/dev-lead-reusable.yml@v1 +uses: petry-projects/.github/.github/workflows/pr-review-mention-reusable.yml@v2 # WRONG — do not SHA-pin internal reusable workflow refs -uses: petry-projects/.github/.github/workflows/claude-code-reusable.yml@ee22b427cbce9ecadcf2b436acb57c3adf0cb63d +uses: petry-projects/.github/.github/workflows/dev-lead-reusable.yml@ee22b427cbce9ecadcf2b436acb57c3adf0cb63d ``` **Why:** Pinning the `uses:` line in a Tier 1 caller stub creates a diff from -the default branch. Anthropic's OIDC token endpoint validates that -`.github/workflows/claude.yml` on a PR branch is identical to the default -branch — any diff causes `401 Workflow validation failed` and Claude Code -cannot run on that PR. +the default branch. Anthropic's OIDC token endpoint validates that the +workflow file on a PR branch is identical to the default branch — any diff +causes `401 Workflow validation failed` and the agent cannot run on that PR. -The `@v1` tag on `petry-projects/.github` is managed deliberately (bumped only -on backward-compatible releases) and is not subject to tag-force-push risk -because the org controls the tag. **Do not open compliance PRs to pin these -references.** +The canonical tags (e.g. `@v1`, `@v2`) on `petry-projects/.github` are managed +deliberately (bumped only on backward-compatible releases) and are not subject +to tag-force-push risk because the org controls the tag. **Do not open +compliance PRs to pin these references.** --- @@ -1008,7 +1025,7 @@ autofix: 2. **Create `ci.yml`** with lint, format, typecheck, and test stages 3. **Enable CodeQL default setup** via `apply-repo-settings.sh` (or `gh api -X PATCH repos///code-scanning/default-setup -F state=configured`) — do **not** add a `codeql.yml` workflow file 4. **Add `sonarcloud.yml`** and configure `sonar-project.properties` -5. **Add `claude.yml`** for AI code review +5. **Add `dev-lead.yml`** from [`standards/workflows/`](workflows/) for AI-driven PR automation 6. **Add `dependabot.yml`** from the appropriate template in [`standards/dependabot/`](dependabot/) 7. **Add `dependabot-automerge.yml`** from [`standards/workflows/`](workflows/) 8. **Add `dependency-audit.yml`** from [`standards/workflows/`](workflows/) @@ -1069,3 +1086,49 @@ All repos MUST align to the latest version of each action: > **`github/codeql-action` is no longer pinned per repo** because the > standard no longer ships a `codeql.yml` workflow. GitHub manages the > analyzer version internally for default-setup repos. + +--- + +## Dev-Lead Agent + +The dev-lead agent is a reactive, write-enabled automation that keeps pull requests in a clean, approvable, and mergeable state. +It responds to CI failures, bot reviews, human `@mentions`, and labeled issues. + +### Adopting the Dev-Lead Agent + +1. Copy `standards/workflows/dev-lead.yml` verbatim to `.github/workflows/dev-lead.yml` in your repo. +2. Set `CLAUDE_CODE_OAUTH_TOKEN` as an org or repo secret (required). +3. Set `GH_PAT_WORKFLOWS` — a PAT with read access to `petry-projects/.github-private` — as an org or repo secret (required for cross-repo script access). +4. Optionally set `vars.DEV_LEAD_ENGINE` to `claude` (default), `gemini`, or `copilot`. +5. Optionally set `vars.DEV_LEAD_DRY_RUN=true` during the initial rollout period. + +### Required secrets + +| Secret | Required | Purpose | +|--------|----------|---------| +| `CLAUDE_CODE_OAUTH_TOKEN` | Yes | Primary LLM engine | +| `GH_PAT_WORKFLOWS` | Yes (cross-repo) | Read access to `.github-private` scripts; push workflow files | +| `GOOGLE_API_KEY` | No | Gemini engine fallback | +| `GH_PAT` | No | Copilot engine | + +### Migration from `claude.yml` + +The dev-lead agent supersedes `claude.yml`. Migration steps: + +1. Add `dev-lead.yml` (from this standard). +2. Run both in parallel for at least 2 weeks (shadow period). +3. Confirm no regressions via the Actions run history. +4. Delete `claude.yml`. + +See tracking issue petry-projects/.github-private#180 for the shadow period status of `.github-private` itself. + +### Key differences from `claude.yml` + +| Feature | `claude.yml` | `dev-lead.yml` | +|---------|-------------|----------------| +| OIDC byte-for-byte constraint | Yes | No | +| Engine-agnostic | No (Claude only) | Yes (claude/gemini/copilot) | +| Dry-run mode | No | Yes (`vars.DEV_LEAD_DRY_RUN`) | +| Anti-loop guard | No | Yes | +| Idempotency markers | No | Yes (SHA-based) | +| CI relay deduplication | No | Yes | diff --git a/standards/github-settings.md b/standards/github-settings.md index cc0414c18..4da69e6db 100644 --- a/standards/github-settings.md +++ b/standards/github-settings.md @@ -407,8 +407,8 @@ When creating a new repository in `petry-projects`: ## Current Compliance Status -**Repository settings:** All 7 repos are fully compliant as of 2026-05-08 -(check-suite auto-trigger preferences re-applied for `.github` via API; +**Repository settings:** All 7 repos are fully compliant as of 2026-05-13 +(check-suite auto-trigger preferences re-applied for `.github` via API — issue #274; last full remediation via `scripts/apply-repo-settings.sh --all` on 2026-04-05). **Ruleset status (as of 2026-05-04):** diff --git a/standards/workflows/dev-lead.yml b/standards/workflows/dev-lead.yml new file mode 100644 index 000000000..02f5ada0d --- /dev/null +++ b/standards/workflows/dev-lead.yml @@ -0,0 +1,59 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Dev-Lead Agent — thin caller stub +# Standard: petry-projects/.github/standards/ci-standards.md#5-dev-lead-agent +# Reusable: petry-projects/.github-private/.github/workflows/dev-lead-reusable.yml +# +# ADOPTING THIS WORKFLOW: +# 1. Copy this file verbatim to .github/workflows/dev-lead.yml in your repo. +# 2. Ensure CLAUDE_CODE_OAUTH_TOKEN is set as an org or repo secret. +# 3. Optionally set GH_PAT_WORKFLOWS (required if Claude pushes workflow files). +# 4. Optionally set vars.DEV_LEAD_ENGINE = "claude" | "gemini" | "copilot". +# +# UNLIKE claude.yml, this file has NO OIDC byte-for-byte constraint and may be +# freely modified on PR branches to adjust triggers for repo-specific needs. +# +# REQUIRED secrets: CLAUDE_CODE_OAUTH_TOKEN +# OPTIONAL secrets: GH_PAT_WORKFLOWS, GOOGLE_API_KEY, GH_PAT +# ───────────────────────────────────────────────────────────────────────────── + +name: Dev-Lead Agent + +on: + pull_request: + branches: [main] + types: [opened, reopened, synchronize] + pull_request_review: + types: [submitted] + pull_request_review_comment: + types: [created] + issue_comment: + types: [created] + issues: + types: [labeled] + check_run: + types: [completed] + repository_dispatch: + types: [dev-lead-ci-failure] + +permissions: {} + +concurrency: + # One active run per repo; ci-relay (check_run) keeps an ephemeral per-SHA slot + # so it can fire immediately without blocking or being blocked by the dispatch queue. + group: >- + ${{ + github.event_name == 'check_run' && format('dev-lead-ci-relay-{0}', github.event.check_run.head_sha) || + 'dev-lead' + }} + cancel-in-progress: false + +jobs: + dev-lead: + uses: petry-projects/.github-private/.github/workflows/dev-lead-reusable.yml@main + secrets: inherit + permissions: + contents: write + pull-requests: write + issues: write + actions: read + checks: read diff --git a/standards/workflows/pr-auto-review.yml b/standards/workflows/pr-auto-review.yml new file mode 100644 index 000000000..7409476ed --- /dev/null +++ b/standards/workflows/pr-auto-review.yml @@ -0,0 +1,55 @@ +# ───────────────────────────────────────────────────────────────────────────── +# SOURCE OF TRUTH: petry-projects/.github/standards/workflows/pr-auto-review.yml +# Standard: petry-projects/.github/standards/ci-standards.md +# Reusable: petry-projects/.github/.github/workflows/pr-auto-review-reusable.yml +# +# AGENTS — READ BEFORE EDITING: +# • This file is a THIN CALLER STUB. All readiness-gate logic lives in the +# reusable workflow above. +# • You MAY change: the tag in the `uses:` line when upgrading the reusable +# workflow version (e.g. bump `@v2` → `@v3` when petry-projects/.github cuts +# a new release), and the workflow name(s) in `workflow_run.workflows` to +# match your repository's CI workflow name(s). +# • You MUST NOT change: trigger event types or the job-level `permissions:` +# block — reusable workflows can be granted no more permissions than the +# calling job, so removing the stanza breaks the reusable's gh API calls. +# • If you need different behaviour, open a PR against the reusable in the +# central repo. +# • When publishing a new version of this reusable, also update this template +# and open a fanout PR across all caller repos. +# ───────────────────────────────────────────────────────────────────────────── +# +# PR Auto-Review — thin caller for the org-level reusable. +# To adopt: copy this file to .github/workflows/pr-auto-review.yml in your repo. +# Requires: GH_PAT_WORKFLOWS org secret (already present in petry-projects org). +name: PR Auto-Review — Ready Check + +on: + # workflow_run fires when a named GitHub Actions workflow completes. + # check_suite does NOT trigger for GitHub Actions runs, so this is required + # to catch CI turning green on a PR. + # TODO: replace "CI" with your repository's CI workflow name(s). + workflow_run: + workflows: ["CI"] + types: [completed] + # check_suite covers third-party CI checks (e.g. SonarCloud, external apps). + check_suite: + types: [completed] + # Re-evaluate readiness after review state changes. + pull_request_review: + types: [submitted, dismissed] + # Re-evaluate when the PR is first opened, updated, or comes out of draft. + pull_request: + types: [opened, reopened, synchronize, ready_for_review] + +permissions: {} + +jobs: + pr-auto-review: + permissions: + pull-requests: read + checks: read + actions: read + uses: petry-projects/.github/.github/workflows/pr-auto-review-reusable.yml@v2 + secrets: + GH_PAT_WORKFLOWS: ${{ secrets.GH_PAT_WORKFLOWS }} diff --git a/standards/workflows/pr-review-mention.yml b/standards/workflows/pr-review-mention.yml index 8d92fe2a3..0ec9d5128 100644 --- a/standards/workflows/pr-review-mention.yml +++ b/standards/workflows/pr-review-mention.yml @@ -13,6 +13,8 @@ # so removing the stanza breaks the reusable's gh API calls. # • If you need different behaviour, open a PR against the reusable in the # central repo. +# • When publishing a new version of this reusable, also update this template and +# open a fanout PR across all caller repos. # ───────────────────────────────────────────────────────────────────────────── # # PR Review Mention — thin caller for the org-level reusable. @@ -34,5 +36,5 @@ jobs: pr-review-mention: permissions: pull-requests: write - uses: petry-projects/.github/.github/workflows/pr-review-mention-reusable.yml@v1 + uses: petry-projects/.github/.github/workflows/pr-review-mention-reusable.yml@v2 secrets: inherit