From 41a34734ef6996133547ce1df4b40ea44b2f04a8 Mon Sep 17 00:00:00 2001 From: donpetry-bot Date: Wed, 20 May 2026 04:13:16 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20implement=20issue=20#236=20?= =?UTF-8?q?=E2=80=94=20Phase=205:=20Remaining=20gh-aw=20workflows=20(Docs?= =?UTF-8?q?=20Health,=20Dependency=20Advisory,=20Standards=20Sync,=20Conte?= =?UTF-8?q?ntTwin=20Audit)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/content-twin-audit.yml | 80 ++++++++ .github/workflows/dependency-advisory.yml | 76 ++++++++ .github/workflows/docs-health-check.yml | 103 +++++++++++ .github/workflows/standards-sync.yml | 35 ++++ .github/workflows/test-aw.yml | 52 ++++++ docs/aw/content-twin-audit.md | 132 +++++++++++++ docs/aw/dependency-advisory.md | 135 ++++++++++++++ docs/aw/docs-health-check.md | 111 +++++++++++ docs/aw/standards-sync.md | 137 ++++++++++++++ scripts/aw-content-twin-audit.sh | 210 +++++++++++++++++++++ scripts/aw-dependency-advisory.sh | 144 +++++++++++++++ scripts/aw-docs-health-check.sh | 175 ++++++++++++++++++ scripts/aw-standards-sync.sh | 215 ++++++++++++++++++++++ tests/aw/content-twin-audit/scenarios.md | 100 ++++++++++ tests/aw/dependency-advisory/scenarios.md | 98 ++++++++++ tests/aw/docs-health-check/scenarios.md | 84 +++++++++ tests/aw/standards-sync/scenarios.md | 100 ++++++++++ tests/aw/validate-specs.sh | 146 +++++++++++++++ 18 files changed, 2133 insertions(+) create mode 100644 .github/workflows/content-twin-audit.yml create mode 100644 .github/workflows/dependency-advisory.yml create mode 100644 .github/workflows/docs-health-check.yml create mode 100644 .github/workflows/standards-sync.yml create mode 100644 .github/workflows/test-aw.yml create mode 100644 docs/aw/content-twin-audit.md create mode 100644 docs/aw/dependency-advisory.md create mode 100644 docs/aw/docs-health-check.md create mode 100644 docs/aw/standards-sync.md create mode 100644 scripts/aw-content-twin-audit.sh create mode 100644 scripts/aw-dependency-advisory.sh create mode 100644 scripts/aw-docs-health-check.sh create mode 100644 scripts/aw-standards-sync.sh create mode 100644 tests/aw/content-twin-audit/scenarios.md create mode 100644 tests/aw/dependency-advisory/scenarios.md create mode 100644 tests/aw/docs-health-check/scenarios.md create mode 100644 tests/aw/standards-sync/scenarios.md create mode 100644 tests/aw/validate-specs.sh diff --git a/.github/workflows/content-twin-audit.yml b/.github/workflows/content-twin-audit.yml new file mode 100644 index 000000000..65a2f031e --- /dev/null +++ b/.github/workflows/content-twin-audit.yml @@ -0,0 +1,80 @@ +name: ContentTwin Content Audit + +on: + schedule: + # Daily at 07:00 UTC + - cron: '0 7 * * *' + workflow_dispatch: + inputs: + stale_days: + description: "Days without a content commit before flagging as stale" + required: false + default: "7" + type: string + +permissions: + contents: read + issues: write + +concurrency: + group: content-twin-audit + cancel-in-progress: true + +jobs: + audit: + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + CONTENT_TWIN_REPO: ${{ vars.CONTENT_TWIN_REPO || 'petry-projects/ContentTwin' }} + STALE_DAYS: ${{ inputs.stale_days || vars.CONTENT_STALE_DAYS || '7' }} + PUBLISH_WORKFLOW: ${{ vars.CONTENT_TWIN_PUBLISH_WORKFLOW || 'publish.yml' }} + # Pin via vars.CLAUDE_CODE_VERSION for reproducible caching; mirrors pr-review.yml. + CLAUDE_CODE_VERSION: ${{ vars.CLAUDE_CODE_VERSION || 'latest' }} + + steps: + - name: Checkout agent repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '20' + + - name: Cache claude-code CLI + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.npm-global + key: claude-code-${{ env.CLAUDE_CODE_VERSION }}-${{ runner.os }} + + - name: Install Claude Code CLI + run: | + mkdir -p "$HOME/.npm-global" + npm config set prefix "$HOME/.npm-global" + echo "$HOME/.npm-global/bin" >> "$GITHUB_PATH" + export PATH="$HOME/.npm-global/bin:$PATH" + if command -v claude >/dev/null 2>&1; then + echo "claude-code cache hit: $(claude --version)" + else + npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" # NOSONAR + fi + + - name: Run content audit + env: + GH_TOKEN: ${{ secrets.DON_PETRY_BOT_GH_PAT }} + run: bash scripts/aw-content-twin-audit.sh + + - name: Truncate report if needed + run: | + if [ -f content_twin_audit_report.md ]; then + if [ "$(wc -c < content_twin_audit_report.md)" -gt 60000 ]; then + head -c 60000 content_twin_audit_report.md > content_twin_audit_report.md.tmp + mv content_twin_audit_report.md.tmp content_twin_audit_report.md + printf '\n\n---\n_Report truncated at 60 000 bytes._\n' >> content_twin_audit_report.md + fi + fi + + - name: Annotate clean run + if: env.HAS_CONTENT_ISSUES != 'true' + run: | + echo "::notice::ContentTwin audit passed — all signals healthy." diff --git a/.github/workflows/dependency-advisory.yml b/.github/workflows/dependency-advisory.yml new file mode 100644 index 000000000..a712b9635 --- /dev/null +++ b/.github/workflows/dependency-advisory.yml @@ -0,0 +1,76 @@ +name: Dependency Advisory + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'package.json' + - 'package-lock.json' + - 'yarn.lock' + - 'go.mod' + - 'go.sum' + - 'requirements.txt' + - 'Pipfile' + - 'Pipfile.lock' + - 'Gemfile' + - 'Gemfile.lock' + - 'Cargo.toml' + - 'Cargo.lock' + - 'pyproject.toml' + - 'pom.xml' + - 'build.gradle' + - 'build.gradle.kts' + +permissions: + contents: read + pull-requests: write + +concurrency: + group: dependency-advisory-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + advisory: + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + SKIP_BOT_PRS: ${{ vars.DEP_ADVISORY_SKIP_BOT_PRS || 'true' }} + # Pin via vars.CLAUDE_CODE_VERSION for reproducible caching; mirrors pr-review.yml. + CLAUDE_CODE_VERSION: ${{ vars.CLAUDE_CODE_VERSION || 'latest' }} + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + token: ${{ secrets.GH_PAT_WORKFLOWS || secrets.GITHUB_TOKEN }} + + - name: Set up Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '20' + + - name: Cache claude-code CLI + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.npm-global + key: claude-code-${{ env.CLAUDE_CODE_VERSION }}-${{ runner.os }} + + - name: Install Claude Code CLI + run: | + mkdir -p "$HOME/.npm-global" + npm config set prefix "$HOME/.npm-global" + echo "$HOME/.npm-global/bin" >> "$GITHUB_PATH" + export PATH="$HOME/.npm-global/bin:$PATH" + if command -v claude >/dev/null 2>&1; then + echo "claude-code cache hit: $(claude --version)" + else + npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" # NOSONAR + fi + + - name: Run dependency advisory + env: + GH_TOKEN: ${{ secrets.GH_PAT_WORKFLOWS || secrets.GITHUB_TOKEN }} + run: bash scripts/aw-dependency-advisory.sh diff --git a/.github/workflows/docs-health-check.yml b/.github/workflows/docs-health-check.yml new file mode 100644 index 000000000..ed3c90c27 --- /dev/null +++ b/.github/workflows/docs-health-check.yml @@ -0,0 +1,103 @@ +name: Docs Health Check + +on: + schedule: + # Weekly — Sunday at 00:00 UTC + - cron: '0 0 * * 0' + workflow_dispatch: + inputs: + stale_days: + description: "Days without update before a doc is considered stale" + required: false + default: "90" + type: string + +permissions: + contents: read + issues: write + +concurrency: + group: docs-health-check + cancel-in-progress: true + +jobs: + health-check: + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + ORG: ${{ vars.ORG || 'petry-projects' }} + STALE_DAYS: ${{ inputs.stale_days || vars.DOCS_STALE_DAYS || '90' }} + DOCS_STALENESS_IGNORE: ${{ vars.DOCS_STALENESS_IGNORE || '' }} + REPORT_REPO: ${{ vars.ORG || 'petry-projects' }}/.github-private + # Pin via vars.CLAUDE_CODE_VERSION for reproducible caching; mirrors pr-review.yml. + CLAUDE_CODE_VERSION: ${{ vars.CLAUDE_CODE_VERSION || 'latest' }} + + steps: + - name: Checkout agent repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '20' + + - name: Cache claude-code CLI + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.npm-global + key: claude-code-${{ env.CLAUDE_CODE_VERSION }}-${{ runner.os }} + + - name: Install Claude Code CLI + run: | + mkdir -p "$HOME/.npm-global" + npm config set prefix "$HOME/.npm-global" + echo "$HOME/.npm-global/bin" >> "$GITHUB_PATH" + export PATH="$HOME/.npm-global/bin:$PATH" + if command -v claude >/dev/null 2>&1; then + echo "claude-code cache hit: $(claude --version)" + else + npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" # NOSONAR + fi + + - name: Run docs health check + env: + GH_TOKEN: ${{ secrets.DON_PETRY_BOT_GH_PAT }} + run: bash scripts/aw-docs-health-check.sh + + - name: Truncate report if needed + run: | + if [ -f docs_health_report.md ]; then + if [ "$(wc -c < docs_health_report.md)" -gt 60000 ]; then + head -c 60000 docs_health_report.md > docs_health_report.md.tmp + mv docs_health_report.md.tmp docs_health_report.md + printf '\n\n---\n_Report truncated at 60 000 bytes._\n' >> docs_health_report.md + fi + fi + + - name: Create issue with findings + if: env.HAS_STALE_DOCS == 'true' && hashFiles('docs_health_report.md') != '' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ github.token }} + script: | + const fs = require('fs'); + const report = fs.readFileSync('docs_health_report.md', 'utf8'); + if (!report.trim()) { + core.setFailed('Report file is empty'); + return; + } + const today = new Date().toISOString().split('T')[0]; + const issue = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `Docs Health Check — stale files detected ${today}`, + body: report, + labels: ['health-check', 'automated-report'], + }); + core.notice(`Issue created: ${issue.data.html_url}`); + + - name: Annotate clean run + if: env.HAS_STALE_DOCS != 'true' + run: | + echo "::notice::Docs health check passed — no stale files found (threshold: ${STALE_DAYS} days)." diff --git a/.github/workflows/standards-sync.yml b/.github/workflows/standards-sync.yml new file mode 100644 index 000000000..f0ddf04f2 --- /dev/null +++ b/.github/workflows/standards-sync.yml @@ -0,0 +1,35 @@ +name: Standards Sync + +on: + schedule: + # First Monday of each month at 09:00 UTC + # Runs on days 1–7 of the month that fall on Monday (day-of-week 1) + - cron: '0 9 1-7 * 1' + workflow_dispatch: {} + +permissions: + contents: read + issues: write + +concurrency: + group: standards-sync + cancel-in-progress: false + +jobs: + sync: + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + ORG: ${{ vars.ORG || 'petry-projects' }} + STANDARDS_REPO: ${{ vars.STANDARDS_REPO || 'petry-projects/.github' }} + SYNC_LABEL: ${{ vars.STANDARDS_SYNC_LABEL || 'standards-sync' }} + SKIP_REPOS: ${{ vars.STANDARDS_SYNC_SKIP_REPOS || '' }} + + steps: + - name: Checkout agent repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Run standards sync + env: + GH_TOKEN: ${{ secrets.DON_PETRY_BOT_GH_PAT }} + run: bash scripts/aw-standards-sync.sh diff --git a/.github/workflows/test-aw.yml b/.github/workflows/test-aw.yml new file mode 100644 index 000000000..6bc271b10 --- /dev/null +++ b/.github/workflows/test-aw.yml @@ -0,0 +1,52 @@ +name: Test AW Workflows + +on: + pull_request: + paths: + - 'tests/aw/**' + - 'docs/aw/**' + - 'scripts/aw-*.sh' + - '.github/workflows/docs-health-check.yml' + - '.github/workflows/dependency-advisory.yml' + - '.github/workflows/standards-sync.yml' + - '.github/workflows/content-twin-audit.yml' + - '.github/workflows/test-aw.yml' + push: + branches: [main] + paths: + - 'tests/aw/**' + - 'docs/aw/**' + - 'scripts/aw-*.sh' + workflow_dispatch: {} + +permissions: + contents: read + +jobs: + validate-specs: + name: Validate AW specs, docs, and workflows + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install PyYAML + run: pip install --quiet pyyaml + + - name: Run spec validator + run: bash tests/aw/validate-specs.sh + + shellcheck-aw-scripts: + name: shellcheck aw-*.sh scripts + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: shellcheck + run: | + # SC2016: single-quoted strings with $vars inside heredocs are intentional + shellcheck --severity=warning scripts/aw-docs-health-check.sh \ + scripts/aw-dependency-advisory.sh \ + scripts/aw-standards-sync.sh \ + scripts/aw-content-twin-audit.sh diff --git a/docs/aw/content-twin-audit.md b/docs/aw/content-twin-audit.md new file mode 100644 index 000000000..33d4e1faf --- /dev/null +++ b/docs/aw/content-twin-audit.md @@ -0,0 +1,132 @@ +# ContentTwin Content Audit + +**Workflow:** `content-twin-audit.yml` +**Priority:** P3 +**Trigger:** `schedule` — daily at 07:00 UTC +**Repo scope:** `petry-projects/ContentTwin` only +**Scenario spec:** [`tests/aw/content-twin-audit/scenarios.md`](../../tests/aw/content-twin-audit/scenarios.md) + +--- + +## Purpose + +Review the ContentTwin content pipeline health daily. Open a GitHub issue in the +`ContentTwin` repo if: + +- content files are stale (no commits in the past 7 days) +- the content queue is empty +- publishing workflow runs have failed recently + +--- + +## Trigger + +```yaml +on: + schedule: + - cron: '0 7 * * *' # daily at 07:00 UTC + workflow_dispatch: + inputs: + stale_days: + description: "Days without a content commit before flagging as stale" + required: false + default: "7" +``` + +--- + +## Architecture + +``` +schedule (daily) + └── audit job + ├── check content directory for last-commit dates + ├── check content queue size (files in queue/ or equivalent) + ├── fetch recent publishing workflow run history + ├── claude --print: analyse all signals, produce audit report + └── if issues detected: + ├── check for existing open content-audit issue (dedup) + └── open (or update) issue in ContentTwin repo +``` + +--- + +## Configuration + +| Variable | Source | Default | Description | +|----------|--------|---------|-------------| +| `CONTENT_TWIN_REPO` | `vars.CONTENT_TWIN_REPO` | `petry-projects/ContentTwin` | Target repo | +| `STALE_DAYS` | `vars.CONTENT_STALE_DAYS` or `inputs.stale_days` | `7` | Days without commit threshold | +| `PUBLISH_WORKFLOW` | `vars.CONTENT_TWIN_PUBLISH_WORKFLOW` | `publish.yml` | Workflow file to check for failures | +| `GH_TOKEN` | `secrets.DON_PETRY_BOT_GH_PAT` | — | PAT with `repo` and `actions:read` scopes | +| `CLAUDE_CODE_OAUTH_TOKEN` | `secrets.CLAUDE_CODE_OAUTH_TOKEN` | — | Claude auth token | + +--- + +## Health signals + +| Signal | Healthy | Unhealthy | +|--------|---------|-----------| +| Content freshness | Commits in last `STALE_DAYS` days | No commits in `STALE_DAYS`+ days | +| Queue state | At least 1 pending item | Queue directory empty | +| Publishing runs | All recent runs succeeded | Any failure in the last 7 days | + +--- + +## Outputs + +### When issues are detected + +A GitHub issue is opened in `petry-projects/ContentTwin`: + +- **Title:** `ContentTwin Audit — content pipeline issues YYYY-MM-DD` +- **Labels:** `content-audit`, `automated-report` +- **Body:** Claude-generated report covering: + - Which health signals are failing + - Stale file paths (if any) + - Links to failed publish runs (if any) + - Recommended actions + +### Deduplication + +Before opening a new issue, the script checks for existing open issues with the +`content-audit` label. If one exists, a comment is added instead of opening a duplicate. + +### When all signals are healthy + +- No issue is opened +- Annotation: `::notice::ContentTwin audit passed — all signals healthy` + +--- + +## Script + +See [`scripts/aw-content-twin-audit.sh`](../../scripts/aw-content-twin-audit.sh). + +--- + +## Permissions + +```yaml +permissions: + contents: read + issues: write +``` + +The `GH_TOKEN` PAT additionally requires `repo` and `actions:read` scopes on the +`ContentTwin` repo to read workflow run history and content file metadata. + +--- + +## Runbook + +**False-positive stale alert over a holiday/break:** +Close the issue and set `CONTENT_STALE_DAYS` to a higher value temporarily, or add +the content directory to `CONTENT_STALENESS_IGNORE`. + +**Workflow fails with 404 on ContentTwin:** +Verify the repo name is correct in `CONTENT_TWIN_REPO` and that the PAT has access. + +**Issue created but queue is intentionally empty (no pending content):** +This is expected operational state — close the issue and note this in the repo README +so future audits can be contextualised. diff --git a/docs/aw/dependency-advisory.md b/docs/aw/dependency-advisory.md new file mode 100644 index 000000000..afeaab4ed --- /dev/null +++ b/docs/aw/dependency-advisory.md @@ -0,0 +1,135 @@ +# Dependency Advisory + +**Workflow:** `dependency-advisory.yml` +**Priority:** P2 +**Trigger:** `pull_request` — touching dependency manifest files +**Scenario spec:** [`tests/aw/dependency-advisory/scenarios.md`](../../tests/aw/dependency-advisory/scenarios.md) + +--- + +## Purpose + +Provide AI-powered risk assessment for dependency changes in pull requests, going beyond +what Dependabot supplies. It flags major version bumps, packages with known CVEs, +newly added packages with poor maintenance signals, and unusual transitive dependency +introductions. + +--- + +## Trigger + +```yaml +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'package.json' + - 'package-lock.json' + - 'yarn.lock' + - 'go.mod' + - 'go.sum' + - 'requirements.txt' + - 'Pipfile' + - 'Pipfile.lock' + - 'Gemfile' + - 'Gemfile.lock' + - 'Cargo.toml' + - 'Cargo.lock' + - 'pyproject.toml' + - 'pom.xml' + - 'build.gradle' + - 'build.gradle.kts' +``` + +--- + +## Architecture + +``` +pull_request (dep file touched) + └── advisory job + ├── checkout PR branch + ├── get PR diff for dep files only (gh pr diff) + ├── extract: added/removed packages and version changes + ├── claude --print: risk assessment of each change + └── post PR comment with findings +``` + +--- + +## Configuration + +| Variable | Source | Default | Description | +|----------|--------|---------|-------------| +| `GH_TOKEN` | `secrets.GH_PAT_WORKFLOWS` or `GITHUB_TOKEN` | — | Token for PR comment | +| `CLAUDE_CODE_OAUTH_TOKEN` | `secrets.CLAUDE_CODE_OAUTH_TOKEN` | — | Claude auth token | +| `SKIP_BOT_PRS` | `vars.DEP_ADVISORY_SKIP_BOT_PRS` | `true` | Skip Dependabot/Renovate PRs | + +--- + +## Outputs + +A PR comment is posted by the bot with the following structure: + +```markdown +## Dependency Advisory + +| Package | Change | Risk | Notes | +|---------|--------|------|-------| +| lodash | 4.17.19 → 4.17.21 | LOW | Patch; no breaking changes | +| express | 4.18.0 → 5.0.0 | HIGH | Major version; review migration guide | + +### Details +... +``` + +### Risk levels + +| Level | Criteria | +|-------|----------| +| `LOW` | Patch or minor bump from a well-maintained package; lockfile-only change | +| `MEDIUM` | Minor bump introducing new APIs or deprecation; new transitive deps | +| `HIGH` | Major version bump; package with recent CVE disclosure | +| `CRITICAL` | Package with active CVE; known supply-chain risk; abandoned package | + +--- + +## Bot PR handling + +When the PR author is `dependabot[bot]` or `renovate[bot]`, the workflow runs with +reduced scope: it skips the comment for `LOW` risk changes and only posts for `MEDIUM` +and above. This reduces noise on routine Dependabot maintenance PRs. + +Set `DEP_ADVISORY_SKIP_BOT_PRS=true` to skip entirely for bot PRs. + +--- + +## Script + +See [`scripts/aw-dependency-advisory.sh`](../../scripts/aw-dependency-advisory.sh). + +--- + +## Permissions + +```yaml +permissions: + contents: read + pull-requests: write +``` + +--- + +## Runbook + +**Comment posted but assessment is wrong:** +The Claude model may lack up-to-date CVE data. Use it as a signal, not a gate. File +a correction as a reply to the advisory comment. + +**Workflow not triggering on a dep change:** +Verify the changed file matches the `paths:` filter. Lockfile-only changes in +subdirectories may not match — extend the `paths:` list if needed. + +**Duplicate comments on re-push:** +The script checks for an existing advisory comment before posting. If a duplicate +appears, the dedup logic may have failed — check `scripts/aw-dependency-advisory.sh`. diff --git a/docs/aw/docs-health-check.md b/docs/aw/docs-health-check.md new file mode 100644 index 000000000..69e35cb62 --- /dev/null +++ b/docs/aw/docs-health-check.md @@ -0,0 +1,111 @@ +# Docs Health Check + +**Workflow:** `docs-health-check.yml` +**Priority:** P2 +**Trigger:** `schedule` — weekly, Sunday 00:00 UTC +**Scenario spec:** [`tests/aw/docs-health-check/scenarios.md`](../../tests/aw/docs-health-check/scenarios.md) + +--- + +## Purpose + +Audit documentation freshness across all repos in the `petry-projects` org. Open a +GitHub issue on `.github-private` listing every documentation file that has not been +updated in 90 or more days, so that doc owners can review and refresh stale content. + +--- + +## Trigger + +```yaml +on: + schedule: + - cron: '0 0 * * 0' # weekly, Sunday midnight UTC + workflow_dispatch: + inputs: + stale_days: + description: "Days without an update before a doc is considered stale" + required: false + default: "90" +``` + +--- + +## Architecture + +``` +schedule (weekly) + └── health-check job + ├── enumerate org repos via gh api + ├── for each repo: list docs/ contents + ├── for each doc file: fetch last commit date + ├── filter: last_commit_date < (today - STALE_DAYS) + ├── claude --print: analyse stale list, produce prioritised report + └── if stale docs found: + └── create GitHub issue in .github-private +``` + +--- + +## Configuration + +| Variable | Source | Default | Description | +|----------|--------|---------|-------------| +| `STALE_DAYS` | `vars.DOCS_STALE_DAYS` or `inputs.stale_days` | `90` | Days without update threshold | +| `ORG` | `vars.ORG` | `petry-projects` | GitHub org to scan | +| `GH_TOKEN` | `secrets.DON_PETRY_BOT_GH_PAT` | — | PAT with `repo` scope | +| `CLAUDE_CODE_OAUTH_TOKEN` | `secrets.CLAUDE_CODE_OAUTH_TOKEN` | — | Claude auth token | + +--- + +## Outputs + +### When stale docs are found + +A GitHub issue is opened in `petry-projects/.github-private`: + +- **Title:** `Docs Health Check — stale files detected YYYY-MM-DD` +- **Labels:** `health-check`, `automated-report` +- **Body:** Claude-generated markdown report with: + - Executive summary (how many files, across how many repos) + - Prioritised list of stale files (oldest first) + - Per-file: path, last-updated date, days stale, suggested action + +### When no stale docs are found + +- No issue is opened +- Workflow annotates: `::notice::Docs health check passed — no stale files found` + +--- + +## Script + +See [`scripts/aw-docs-health-check.sh`](../../scripts/aw-docs-health-check.sh). + +--- + +## Permissions + +```yaml +permissions: + contents: read + issues: write +``` + +The `GH_TOKEN` PAT (used via `gh` CLI) additionally requires `repo` scope to enumerate +repos and read file metadata in other org repos. + +--- + +## Runbook + +**Issue opened but files are intentionally stale:** +Close the issue and add the file path to the `DOCS_STALENESS_IGNORE` repo variable +(comma-separated list). The script skips paths that match any entry in this list. + +**Workflow fails with API rate-limit error:** +The `GH_TOKEN` PAT is rate-limited. Run manually during off-peak hours or request a +higher rate limit via GitHub Support. + +**Claude invocation fails:** +Check `CLAUDE_CODE_OAUTH_TOKEN` is valid. Re-run the workflow after rotating the token. diff --git a/docs/aw/standards-sync.md b/docs/aw/standards-sync.md new file mode 100644 index 000000000..4ee2ec9c1 --- /dev/null +++ b/docs/aw/standards-sync.md @@ -0,0 +1,137 @@ +# Standards Sync + +**Workflow:** `standards-sync.yml` +**Priority:** P3 +**Trigger:** `schedule` — monthly, first Monday at 09:00 UTC +**Scenario spec:** [`tests/aw/standards-sync/scenarios.md`](../../tests/aw/standards-sync/scenarios.md) + +--- + +## Purpose + +Check all repos in the `petry-projects` org against the org standards defined in +`AGENTS.md` and `CODEOWNERS`. For any repo that is out of sync, open a PR with the +required additions. Produce a monthly compliance summary issue in `.github-private`. + +--- + +## Trigger + +```yaml +on: + schedule: + # First Monday of each month at 09:00 UTC + - cron: '0 9 1-7 * 1' + workflow_dispatch: {} +``` + +--- + +## Required files (per repo) + +| File | Description | +|------|-------------| +| `AGENTS.md` | Org-standard agent configuration (references `.github/AGENTS.md`) | +| `.github/CODEOWNERS` | Code ownership definitions | + +--- + +## Architecture + +``` +schedule (first Monday) + └── sync job + ├── enumerate all non-archived org repos via gh api + ├── for each repo: + │ ├── check presence of required files via gh api contents + │ ├── if missing files: build patch (org-standard template content) + │ ├── check for existing open standards-sync PR (idempotency) + │ └── if no open PR: open PR with missing files + ├── collect compliance results + └── open summary issue in .github-private +``` + +--- + +## Configuration + +| Variable | Source | Default | Description | +|----------|--------|---------|-------------| +| `ORG` | `vars.ORG` | `petry-projects` | GitHub org to scan | +| `GH_TOKEN` | `secrets.DON_PETRY_BOT_GH_PAT` | — | PAT with `repo` and `workflow` scopes | +| `STANDARDS_REPO` | `vars.STANDARDS_REPO` | `petry-projects/.github` | Repo containing org standards templates | +| `SYNC_LABEL` | `vars.STANDARDS_SYNC_LABEL` | `standards-sync` | Label applied to opened PRs and issues | +| `SKIP_REPOS` | `vars.STANDARDS_SYNC_SKIP_REPOS` | `` | Comma-separated list of repos to skip | + +--- + +## Outputs + +### PRs (per non-compliant repo) + +- **Title:** `chore: add AGENTS.md (standards-sync)` / `chore: add CODEOWNERS (standards-sync)` +- **Branch:** `standards-sync/YYYY-MM-DD` +- **Labels:** `standards-sync` +- **Body:** explains which file is missing and why it is required + +### Summary issue (in `.github-private`) + +- **Title:** `Standards Sync — YYYY-MM-DD` +- **Labels:** `standards-sync`, `automated-report` +- **Body:** + +```markdown +## Compliance Summary — YYYY-MM-DD + +| Repo | AGENTS.md | CODEOWNERS | Action | +|------|-----------|------------|--------| +| foo/bar | ✅ | ❌ | PR #42 opened | +| foo/baz | ✅ | ✅ | Compliant | + +**Total:** 12 repos scanned · 10 compliant · 2 PRs opened +``` + +--- + +## Script + +See [`scripts/aw-standards-sync.sh`](../../scripts/aw-standards-sync.sh). + +--- + +## Permissions + +```yaml +permissions: + contents: read + issues: write +``` + +The `GH_TOKEN` PAT additionally requires `repo` scope to create branches and PRs on +other org repos. + +--- + +## Idempotency + +Before opening a PR on a repo, the script calls: +```bash +gh pr list --repo "${target_repo}" --label standards-sync --state open +``` +If any open PR exists with the `standards-sync` label, no new PR is created. + +--- + +## Runbook + +**PR opened but repo intentionally deviates from org standard:** +Close the PR and add the repo to `STANDARDS_SYNC_SKIP_REPOS`. Document the exception +in the repo's own `AGENTS.md`. + +**Workflow fails with 403 on a repo:** +The PAT may lack `repo` scope on a private repo. Check the PAT scopes or add the repo +to `SKIP_REPOS`. + +**Summary issue not created:** +Ensure the workflow has `issues: write` permission on `.github-private` and that the +`GH_TOKEN` PAT has access to that repo. diff --git a/scripts/aw-content-twin-audit.sh b/scripts/aw-content-twin-audit.sh new file mode 100644 index 000000000..f868814a6 --- /dev/null +++ b/scripts/aw-content-twin-audit.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash +# ContentTwin Content Audit — checks the ContentTwin content pipeline for +# stale content, empty queue, and publishing workflow failures. +# Opens or updates a GitHub issue in the ContentTwin repo when problems are found. +# +# Env vars consumed: +# GH_TOKEN — PAT with repo + actions:read on ContentTwin +# CLAUDE_CODE_OAUTH_TOKEN — Claude auth token +# CONTENT_TWIN_REPO — target repo (default: petry-projects/ContentTwin) +# STALE_DAYS — days without commit threshold (default: 7) +# PUBLISH_WORKFLOW — workflow file to check for failures (default: publish.yml) +# REPORT_FILE — output markdown file (default: content_twin_audit_report.md) +# GITHUB_ENV — set by Actions runner + +set -euo pipefail + +CONTENT_TWIN_REPO="${CONTENT_TWIN_REPO:-petry-projects/ContentTwin}" +STALE_DAYS="${STALE_DAYS:-7}" +PUBLISH_WORKFLOW="${PUBLISH_WORKFLOW:-publish.yml}" +REPORT_FILE="${REPORT_FILE:-content_twin_audit_report.md}" +AUDIT_LABEL="content-audit" +TODAY=$(date -u +%Y-%m-%d) + +CUTOFF=$(date -u -d "${STALE_DAYS} days ago" +%Y-%m-%d 2>/dev/null \ + || date -u -v-"${STALE_DAYS}"d +%Y-%m-%d) + +echo "=== ContentTwin Content Audit ===" +echo " Repo: ${CONTENT_TWIN_REPO}" +echo " Stale threshold: ${STALE_DAYS} days (since ${CUTOFF})" +echo " Publish workflow: ${PUBLISH_WORKFLOW}" +echo " Date: ${TODAY}" +echo "" + +# --------------------------------------------------------------------------- +# 0. Verify repo access +# --------------------------------------------------------------------------- +if ! gh api "repos/${CONTENT_TWIN_REPO}" --silent 2>/dev/null; then + echo "::error::Cannot access repo ${CONTENT_TWIN_REPO} — check GH_TOKEN scope." + exit 1 +fi + +# --------------------------------------------------------------------------- +# 1. Check content freshness +# --------------------------------------------------------------------------- +echo "Checking content freshness..." +recent_commit=$(gh api "repos/${CONTENT_TWIN_REPO}/commits?per_page=1" \ + --jq '.[0].commit.author.date // ""' 2>/dev/null || true) + +stale_content=false +days_since_commit="unknown" + +if [ -n "$recent_commit" ]; then + last_commit_date="${recent_commit:0:10}" + if [[ "$last_commit_date" < "$CUTOFF" ]]; then + stale_content=true + # Calculate days since last commit + today_epoch=$(date -u -d "$TODAY" +%s 2>/dev/null || date -u -j -f '%Y-%m-%d' "$TODAY" +%s) + commit_epoch=$(date -u -d "$last_commit_date" +%s 2>/dev/null || date -u -j -f '%Y-%m-%d' "$last_commit_date" +%s) + days_since_commit=$(( (today_epoch - commit_epoch) / 86400 )) + echo " Stale: last commit was ${days_since_commit} days ago (${last_commit_date})" + else + echo " Fresh: last commit on ${last_commit_date}" + fi +else + echo " ::warning::Could not determine last commit date." +fi + +# --------------------------------------------------------------------------- +# 2. Check content queue +# --------------------------------------------------------------------------- +echo "Checking content queue..." +queue_empty=false + +queue_files=$(gh api "repos/${CONTENT_TWIN_REPO}/contents/queue" \ + --jq 'length' 2>/dev/null || echo "0") + +if [ "$queue_files" = "0" ] || [ -z "$queue_files" ]; then + queue_empty=true + echo " Queue is empty (0 items in queue/)" +else + echo " Queue has ${queue_files} item(s)" +fi + +# --------------------------------------------------------------------------- +# 3. Check publishing workflow failures +# --------------------------------------------------------------------------- +echo "Checking publishing workflow failures..." +LOOKBACK_CUTOFF=$(date -u -d "7 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \ + || date -u -v-7d +%Y-%m-%dT%H:%M:%SZ) + +publish_runs=$(gh api \ + "repos/${CONTENT_TWIN_REPO}/actions/workflows/${PUBLISH_WORKFLOW}/runs?per_page=20&created=>=${LOOKBACK_CUTOFF}" \ + --jq '.workflow_runs | map({id: .id, conclusion: .conclusion, created_at: .created_at, html_url: .html_url, run_number: .run_number})' \ + 2>/dev/null || echo '[]') + +failed_runs=$(echo "$publish_runs" | jq -r '[.[] | select(.conclusion == "failure")] | length') +total_runs=$(echo "$publish_runs" | jq -r 'length') +failed_run_urls=$(echo "$publish_runs" | jq -r \ + '[.[] | select(.conclusion == "failure") | "- [Run #\(.run_number)](\(.html_url)) — failed at \(.created_at[:10])"] | .[]' \ + 2>/dev/null || true) + +has_publish_failures=false +if [ "$failed_runs" -gt 0 ]; then + has_publish_failures=true + echo " Publish failures: ${failed_runs} of ${total_runs} recent runs failed" +else + echo " Publish runs: ${total_runs} recent runs — no failures" +fi + +# --------------------------------------------------------------------------- +# 4. Determine if action is needed +# --------------------------------------------------------------------------- +has_issues=false +[ "$stale_content" = "true" ] && has_issues=true +[ "$queue_empty" = "true" ] && has_issues=true +[ "$has_publish_failures" = "true" ] && has_issues=true + +if [ "$has_issues" = "false" ]; then + echo "" + echo "::notice::ContentTwin audit passed — all signals healthy." + [ -n "${GITHUB_ENV:-}" ] && echo "HAS_CONTENT_ISSUES=false" >> "$GITHUB_ENV" + exit 0 +fi + +[ -n "${GITHUB_ENV:-}" ] && echo "HAS_CONTENT_ISSUES=true" >> "$GITHUB_ENV" + +# --------------------------------------------------------------------------- +# 5. Invoke Claude for audit report +# --------------------------------------------------------------------------- +echo "" +echo "Issues detected. Invoking Claude for audit report..." + +if ! claude --print --model claude-sonnet-4-6 --no-session-persistence \ + > "$REPORT_FILE" < + +### Failed Runs (if any) + + +### Recommended Actions + + +Output ONLY the markdown report — no preamble outside the sections. +PROMPT +then + echo "::error::Claude invocation failed. CLI output:" + cat "$REPORT_FILE" >&2 + exit 1 +fi + +echo "Report written to ${REPORT_FILE} ($(wc -c < "$REPORT_FILE") bytes)." + +# --------------------------------------------------------------------------- +# 6. Check for existing open issue (dedup) +# --------------------------------------------------------------------------- +existing_issue=$(gh api "repos/${CONTENT_TWIN_REPO}/issues?labels=${AUDIT_LABEL}&state=open&per_page=1" \ + --jq '.[0].number // ""' 2>/dev/null || true) + +report_body=$(cat "$REPORT_FILE") + +if [ -n "$existing_issue" ]; then + echo "Updating existing issue #${existing_issue} with new audit findings..." + gh api "repos/${CONTENT_TWIN_REPO}/issues/${existing_issue}/comments" \ + --method POST \ + --field "body=**Audit update — ${TODAY}** + +${report_body}" \ + --silent + echo " Comment added to issue #${existing_issue}." +else + echo "Opening new content audit issue in ${CONTENT_TWIN_REPO}..." + gh api "repos/${CONTENT_TWIN_REPO}/issues" \ + --method POST \ + --field "title=ContentTwin Audit — content pipeline issues ${TODAY}" \ + --field "body=${report_body}" \ + --field "labels[]=${AUDIT_LABEL}" \ + --field "labels[]=automated-report" \ + --silent && echo " Issue created." +fi + +echo "" +echo "=== ContentTwin audit complete ===" diff --git a/scripts/aw-dependency-advisory.sh b/scripts/aw-dependency-advisory.sh new file mode 100644 index 000000000..1fbb9b839 --- /dev/null +++ b/scripts/aw-dependency-advisory.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# Dependency Advisory — analyses dependency manifest changes in a PR and posts +# a risk-assessment comment via Claude. +# +# Env vars consumed: +# GH_TOKEN — token for gh CLI (PR comment + diff) +# CLAUDE_CODE_OAUTH_TOKEN — Claude auth token +# PR_NUMBER — pull request number +# REPO — owner/repo (e.g. petry-projects/my-repo) +# SKIP_BOT_PRS — "true" to skip Dependabot/Renovate PRs (default: true) +# GITHUB_ENV — set by Actions runner + +set -euo pipefail + +PR_NUMBER="${PR_NUMBER:?PR_NUMBER is required}" +REPO="${REPO:?REPO is required}" +SKIP_BOT_PRS="${SKIP_BOT_PRS:-true}" +BOT_AUTHORS="${BOT_AUTHORS:-dependabot[bot],renovate[bot]}" +COMMENT_MARKER="" + +echo "=== Dependency Advisory ===" +echo " Repo: ${REPO}" +echo " PR: #${PR_NUMBER}" +echo "" + +# --------------------------------------------------------------------------- +# 1. Get PR metadata +# --------------------------------------------------------------------------- +pr_json=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" \ + --jq '{author: .user.login, title: .title, base: .base.ref, head: .head.ref}') + +pr_author=$(echo "$pr_json" | jq -r '.author') +echo " Author: ${pr_author}" + +# Skip bot PRs if configured +if [ "$SKIP_BOT_PRS" = "true" ]; then + IFS=',' read -ra _bot_list <<< "$BOT_AUTHORS" + for bot in "${_bot_list[@]}"; do + bot="${bot#"${bot%%[![:space:]]*}"}" + bot="${bot%"${bot##*[![:space:]]}"}" + if [ "$pr_author" = "$bot" ]; then + echo "::notice::Skipping bot PR by ${pr_author} (SKIP_BOT_PRS=true)" + exit 0 + fi + done +fi + +# --------------------------------------------------------------------------- +# 2. Check for existing advisory comment (dedup) +# --------------------------------------------------------------------------- +existing_comment_id=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \ + --jq "[.[] | select(.body | startswith(\"${COMMENT_MARKER}\"))] | .[0].id // \"\"" \ + 2>/dev/null || true) + +# --------------------------------------------------------------------------- +# 3. Get diff for dependency files only +# --------------------------------------------------------------------------- +dep_diff=$(gh pr diff "$PR_NUMBER" --repo "$REPO" 2>/dev/null | \ + awk ' + /^diff --git/ { in_dep=0; header=$0; next } + /package\.json|package-lock\.json|yarn\.lock|go\.mod|go\.sum|requirements\.txt|Pipfile|Gemfile|Cargo\.toml|pyproject\.toml|pom\.xml|build\.gradle/ { + in_dep=1; print header; print; next + } + in_dep { print } + ' || true) + +if [ -z "$dep_diff" ]; then + echo "::notice::No dependency file changes found in diff — skipping advisory." + exit 0 +fi + +diff_lines=$(echo "$dep_diff" | wc -l | tr -d ' ') +echo " Dep diff: ${diff_lines} lines" + +# --------------------------------------------------------------------------- +# 4. Invoke Claude for risk assessment +# --------------------------------------------------------------------------- +ADVISORY_FILE=$(mktemp) +trap 'rm -f "$ADVISORY_FILE"' EXIT + +echo "Invoking Claude for dependency risk assessment..." +if ! claude --print --model claude-sonnet-4-6 --no-session-persistence \ + > "$ADVISORY_FILE" < | | npm/go/pip/etc | LOW/MEDIUM/HIGH/CRITICAL | | + +### Risk Legend +- **LOW** — Patch/minor bump, lockfile regeneration, well-maintained package +- **MEDIUM** — Minor bump with new APIs, new direct dependency, deprecation notice +- **HIGH** — Major version bump, package with recent CVE, unusual transitive deps +- **CRITICAL** — Active CVE, known supply-chain risk, abandoned package + +### Details + + + + +Output ONLY the comment body — no preamble outside the markdown. +PROMPT +then + echo "::error::Claude invocation failed. CLI output:" + cat "$ADVISORY_FILE" >&2 + exit 1 +fi + +advisory_body=$(cat "$ADVISORY_FILE") + +# --------------------------------------------------------------------------- +# 5. Post or update PR comment +# --------------------------------------------------------------------------- +if [ -n "$existing_comment_id" ]; then + echo "Updating existing advisory comment #${existing_comment_id}..." + gh api "repos/${REPO}/issues/comments/${existing_comment_id}" \ + --method PATCH \ + --field "body=${advisory_body}" \ + --silent +else + echo "Posting new advisory comment on PR #${PR_NUMBER}..." + gh pr comment "$PR_NUMBER" \ + --repo "$REPO" \ + --body "$advisory_body" +fi + +echo "=== Dependency advisory complete ===" diff --git a/scripts/aw-docs-health-check.sh b/scripts/aw-docs-health-check.sh new file mode 100644 index 000000000..7263b93a3 --- /dev/null +++ b/scripts/aw-docs-health-check.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +# Docs Health Check — scans all org repos for docs files not updated in +# STALE_DAYS (default: 90) days and opens a GitHub issue with the findings. +# +# Env vars consumed: +# GH_TOKEN — PAT with repo scope (passed via gh CLI) +# CLAUDE_CODE_OAUTH_TOKEN — Claude auth token +# ORG — GitHub org to scan (default: petry-projects) +# STALE_DAYS — staleness threshold in days (default: 90) +# DOCS_STALENESS_IGNORE — comma-separated path patterns to skip +# REPORT_REPO — repo to open the issue on (default: ORG/.github-private) +# GITHUB_ENV — set by Actions runner + +set -euo pipefail + +ORG="${ORG:-petry-projects}" +STALE_DAYS="${STALE_DAYS:-90}" +REPORT_REPO="${REPORT_REPO:-${ORG}/.github-private}" +TODAY=$(date -u +%Y-%m-%d) + +# Portable date arithmetic (GNU date / macOS date) +CUTOFF=$(date -u -d "${STALE_DAYS} days ago" +%Y-%m-%d 2>/dev/null \ + || date -u -v-"${STALE_DAYS}"d +%Y-%m-%d) + +echo "=== Docs Health Check ===" +echo " Org: ${ORG}" +echo " Stale after: ${STALE_DAYS} days (cutoff: ${CUTOFF})" +echo " Report repo: ${REPORT_REPO}" +echo " Date: ${TODAY}" +echo "" + +# --------------------------------------------------------------------------- +# 1. Build ignore list +# --------------------------------------------------------------------------- +IFS=',' read -ra _IGNORE_RAW <<< "${DOCS_STALENESS_IGNORE:-}" +IGNORE_PATTERNS=() +for _p in "${_IGNORE_RAW[@]}"; do + _p="${_p#"${_p%%[![:space:]]*}"}" # ltrim + _p="${_p%"${_p##*[![:space:]]}"}" # rtrim + [ -n "$_p" ] && IGNORE_PATTERNS+=("$_p") +done + +_is_ignored() { + local path="$1" + for pattern in "${IGNORE_PATTERNS[@]+"${IGNORE_PATTERNS[@]}"}"; do + case "$path" in + *"$pattern"*) return 0 ;; + esac + done + return 1 +} + +# --------------------------------------------------------------------------- +# 2. Enumerate repos +# --------------------------------------------------------------------------- +echo "Enumerating repos in ${ORG}..." +repos=$(gh api "orgs/${ORG}/repos?per_page=100&type=all" \ + --jq '[.[] | select(.archived == false)] | .[].full_name' 2>/dev/null || true) + +if [ -z "$repos" ]; then + echo "::error::Could not enumerate repos for org ${ORG} — check GH_TOKEN scope." + exit 1 +fi + +repo_count=$(echo "$repos" | wc -l | tr -d ' ') +echo " Found ${repo_count} active repos." +echo "" + +# --------------------------------------------------------------------------- +# 3. Scan docs files per repo +# --------------------------------------------------------------------------- +stale_entries="" + +while IFS= read -r repo; do + # Fetch docs/ directory listing; skip repos that have none + docs_listing=$(gh api "repos/${repo}/contents/docs" \ + --jq '.[].name' 2>/dev/null || true) + + [ -z "$docs_listing" ] && continue + + while IFS= read -r doc_name; do + full_path="${repo}/docs/${doc_name}" + + _is_ignored "$full_path" && continue + + # Get last commit date for this file + last_commit=$(gh api \ + "repos/${repo}/commits?path=docs/${doc_name}&per_page=1" \ + --jq '.[0].commit.author.date // ""' 2>/dev/null || true) + + [ -z "$last_commit" ] && continue + + commit_date="${last_commit:0:10}" # YYYY-MM-DD + + if [[ "$commit_date" < "$CUTOFF" ]]; then + days_stale=$(( ($(date -u -d "$TODAY" +%s 2>/dev/null || date -u -j -f '%Y-%m-%d' "$TODAY" +%s) \ + - $(date -u -d "$commit_date" +%s 2>/dev/null || date -u -j -f '%Y-%m-%d' "$commit_date" +%s)) \ + / 86400 )) + stale_entries="${stale_entries}${full_path}\t${commit_date}\t${days_stale}\n" + fi + done <<< "$docs_listing" +done <<< "$repos" + +# --------------------------------------------------------------------------- +# 4. Early exit when no stale docs +# --------------------------------------------------------------------------- +if [ -z "$stale_entries" ]; then + echo "No stale docs found. Health check passed." + [ -n "${GITHUB_ENV:-}" ] && echo "HAS_STALE_DOCS=false" >> "$GITHUB_ENV" + exit 0 +fi + +stale_count=$(printf '%b' "$stale_entries" | wc -l | tr -d ' ') +echo "Found ${stale_count} stale doc file(s)." +[ -n "${GITHUB_ENV:-}" ] && echo "HAS_STALE_DOCS=true" >> "$GITHUB_ENV" + +# --------------------------------------------------------------------------- +# 5. Build stale-files table for Claude +# --------------------------------------------------------------------------- +stale_table="| File | Last Updated | Days Stale |\n|------|-------------|------------|\n" +while IFS=$'\t' read -r path date days; do + stale_table="${stale_table}| \`${path}\` | ${date} | ${days} |\n" +done < <(printf '%b' "$stale_entries" | sort -t$'\t' -k3 -rn) + +# --------------------------------------------------------------------------- +# 6. Invoke Claude for prioritised report +# --------------------------------------------------------------------------- +REPORT_FILE="${REPORT_FILE:-docs_health_report.md}" + +echo "Invoking Claude for stale-docs analysis..." +if ! claude --print --model claude-sonnet-4-6 --no-session-persistence \ + > "$REPORT_FILE" < +- + +Action required: + +### Stale Files by Repo +Group the stale files by repo. For each repo, list files sorted by days-stale descending. + +### Recommendations +Numbered list. For each significant cluster of staleness: what team owns it, suggested +action (refresh, archive, redirect), and urgency: [HIGH | MEDIUM | LOW] based on +days stale (>365=HIGH, 180-365=MEDIUM, 90-180=LOW). + +Output ONLY the markdown report — no preamble or commentary outside the sections. +PROMPT +then + echo "::error::Claude invocation failed. CLI output:" + cat "$REPORT_FILE" >&2 + exit 1 +fi + +echo "Report written to ${REPORT_FILE} ($(wc -c < "$REPORT_FILE") bytes)." +echo "=== Docs health check complete ===" diff --git a/scripts/aw-standards-sync.sh b/scripts/aw-standards-sync.sh new file mode 100644 index 000000000..58f6789a0 --- /dev/null +++ b/scripts/aw-standards-sync.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash +# Standards Sync — checks all org repos for required files (AGENTS.md, CODEOWNERS) +# and opens PRs on non-compliant repos. Opens a summary issue in .github-private. +# +# Env vars consumed: +# GH_TOKEN — PAT with repo scope (create branches and PRs cross-repo) +# ORG — GitHub org to scan (default: petry-projects) +# STANDARDS_REPO — repo containing org standards templates (default: petry-projects/.github) +# REPORT_REPO — repo for summary issue (default: ORG/.github-private) +# SYNC_LABEL — label for opened PRs and summary issue (default: standards-sync) +# SKIP_REPOS — comma-separated list of repos to skip +# GITHUB_ENV — set by Actions runner + +set -euo pipefail + +ORG="${ORG:-petry-projects}" +STANDARDS_REPO="${STANDARDS_REPO:-${ORG}/.github}" +REPORT_REPO="${REPORT_REPO:-${ORG}/.github-private}" +SYNC_LABEL="${SYNC_LABEL:-standards-sync}" +TODAY=$(date -u +%Y-%m-%d) +BRANCH_NAME="standards-sync/${TODAY}" + +# Required files: format "local_path_in_repo|template_path_in_standards_repo" +REQUIRED_FILES=( + "AGENTS.md|standards/AGENTS.md" + ".github/CODEOWNERS|standards/CODEOWNERS" +) + +echo "=== Standards Sync ===" +echo " Org: ${ORG}" +echo " Standards repo: ${STANDARDS_REPO}" +echo " Report repo: ${REPORT_REPO}" +echo " Date: ${TODAY}" +echo "" + +# --------------------------------------------------------------------------- +# 1. Build skip list +# --------------------------------------------------------------------------- +IFS=',' read -ra _SKIP_RAW <<< "${SKIP_REPOS:-}" +SKIP_LIST=() +for _r in "${_SKIP_RAW[@]+"${_SKIP_RAW[@]}"}"; do + _r="${_r#"${_r%%[![:space:]]*}"}" + _r="${_r%"${_r##*[![:space:]]}"}" + [ -n "$_r" ] && SKIP_LIST+=("$_r") +done + +_is_skipped() { + local repo="$1" + for skip in "${SKIP_LIST[@]+"${SKIP_LIST[@]}"}"; do + [ "$repo" = "$skip" ] && return 0 + [ "${repo##*/}" = "$skip" ] && return 0 + done + return 1 +} + +# --------------------------------------------------------------------------- +# 2. Enumerate active repos +# --------------------------------------------------------------------------- +echo "Enumerating repos in ${ORG}..." +repos=$(gh api "orgs/${ORG}/repos?per_page=100&type=all" \ + --jq '[.[] | select(.archived == false)] | .[].full_name' 2>/dev/null || true) + +if [ -z "$repos" ]; then + echo "::error::Could not enumerate repos for org ${ORG}." + exit 1 +fi + +repo_count=$(echo "$repos" | wc -l | tr -d ' ') +echo " Found ${repo_count} active repos." +echo "" + +# --------------------------------------------------------------------------- +# 3. For each required file, fetch template content from standards repo +# --------------------------------------------------------------------------- +declare -A TEMPLATE_CONTENT +for entry in "${REQUIRED_FILES[@]}"; do + _template="${entry#*|}" + content=$(gh api "repos/${STANDARDS_REPO}/contents/${_template}" \ + --jq '.content' 2>/dev/null | base64 -d 2>/dev/null || true) + if [ -z "$content" ]; then + echo "::warning::Template not found in ${STANDARDS_REPO}: ${_template} — skipping this requirement." + fi + TEMPLATE_CONTENT["$_template"]="$content" +done + +# --------------------------------------------------------------------------- +# 4. Scan repos for compliance and open PRs +# --------------------------------------------------------------------------- +prs_opened=0 +compliant_count=0 + +# Collect summary rows for the final issue +summary_rows="" + +while IFS= read -r repo; do + _is_skipped "$repo" && { + echo " [skip] ${repo}" + continue + } + + missing_files=() + + for entry in "${REQUIRED_FILES[@]}"; do + required_path="${entry%%|*}" + # Check existence via contents API; 200 = exists, 404 = missing + if ! gh api "repos/${repo}/contents/${required_path}" --silent 2>/dev/null; then + missing_files+=("$entry") + fi + done + + if [ "${#missing_files[@]}" -eq 0 ]; then + compliant_count=$((compliant_count + 1)) + echo " [ok] ${repo}" + summary_rows="${summary_rows}| \`${repo}\` | ✅ | ✅ | Compliant |\n" + continue + fi + + # Check for existing open standards-sync PR + existing_pr=$(gh pr list \ + --repo "$repo" \ + --label "$SYNC_LABEL" \ + --state open \ + --json number \ + --jq '.[0].number // ""' 2>/dev/null || true) + + if [ -n "$existing_pr" ]; then + echo " [skip] ${repo} — PR #${existing_pr} already open" + summary_rows="${summary_rows}| \`${repo}\` | (see PR) | (see PR) | PR #${existing_pr} already open |\n" + continue + fi + + # Get default branch SHA for creating the branch + default_branch=$(gh api "repos/${repo}" --jq '.default_branch' 2>/dev/null || echo "main") + base_sha=$(gh api "repos/${repo}/git/ref/heads/${default_branch}" \ + --jq '.object.sha' 2>/dev/null || true) + + if [ -z "$base_sha" ]; then + echo " [warn] ${repo} — could not get SHA for ${default_branch}, skipping" + continue + fi + + # Create the sync branch + gh api "repos/${repo}/git/refs" \ + --method POST \ + --field "ref=refs/heads/${BRANCH_NAME}" \ + --field "sha=${base_sha}" \ + --silent 2>/dev/null || { + echo " [warn] ${repo} — branch ${BRANCH_NAME} may already exist, skipping" + continue + } + + # Create each missing file via the Contents API + missing_labels="" + for entry in "${missing_files[@]}"; do + required_path="${entry%%|*}" + template_key="${entry#*|}" + file_content="${TEMPLATE_CONTENT[$template_key]:-}" + + if [ -z "$file_content" ]; then + echo " [warn] ${repo} — no template for ${required_path}, skipping file" + continue + fi + + encoded=$(printf '%s' "$file_content" | base64 -w 0) + gh api "repos/${repo}/contents/${required_path}" \ + --method PUT \ + --field "message=chore: add ${required_path} (standards-sync)" \ + --field "content=${encoded}" \ + --field "branch=${BRANCH_NAME}" \ + --silent 2>/dev/null || \ + echo " [warn] ${repo} — could not create ${required_path}" + + missing_labels="${missing_labels} \`${required_path}\`" + done + + # Open PR + pr_url=$(gh pr create \ + --repo "$repo" \ + --head "$BRANCH_NAME" \ + --base "$default_branch" \ + --title "chore: add missing org standard files (standards-sync)" \ + --body "$(printf 'This PR adds files required by the \`petry-projects\` org standards.\n\n**Missing files added:**%s\n\nOpened by the [standards-sync](%s) workflow.\n' "$missing_labels" "https://github.com/${REPORT_REPO}/actions")" \ + --label "$SYNC_LABEL" \ + 2>/dev/null || true) + + if [ -n "$pr_url" ]; then + prs_opened=$((prs_opened + 1)) + echo " [pr] ${repo} — opened ${pr_url}" + summary_rows="${summary_rows}| \`${repo}\` | ❌ | ❌ | PR opened: ${pr_url} |\n" + else + echo " [warn] ${repo} — could not open PR" + summary_rows="${summary_rows}| \`${repo}\` | ❌ | ❌ | PR creation failed |\n" + fi +done <<< "$repos" + +# --------------------------------------------------------------------------- +# 5. Open summary issue in report repo +# --------------------------------------------------------------------------- +total_scanned=$(echo "$repos" | wc -l | tr -d ' ') + +summary_body="$(printf '## Standards Sync — %s\n\n| Repo | AGENTS.md | CODEOWNERS | Action |\n|------|-----------|------------|--------|\n%b\n**Total:** %d repos scanned · %d compliant · %d PRs opened\n\n---\n_Generated by standards-sync workflow on %s._\n' \ + "$TODAY" "$summary_rows" "$total_scanned" "$compliant_count" "$prs_opened" "$TODAY")" + +echo "" +echo "Opening summary issue in ${REPORT_REPO}..." +gh api "repos/${REPORT_REPO}/issues" \ + --method POST \ + --field "title=Standards Sync — ${TODAY}" \ + --field "body=${summary_body}" \ + --field "labels[]=${SYNC_LABEL}" \ + --field "labels[]=automated-report" \ + --silent && echo " Summary issue created." + +echo "" +echo "=== Standards sync complete: ${total_scanned} scanned, ${compliant_count} compliant, ${prs_opened} PRs opened ===" diff --git a/tests/aw/content-twin-audit/scenarios.md b/tests/aw/content-twin-audit/scenarios.md new file mode 100644 index 000000000..08e86a010 --- /dev/null +++ b/tests/aw/content-twin-audit/scenarios.md @@ -0,0 +1,100 @@ +# Scenarios: content-twin-audit + +## Overview + +The `content-twin-audit` workflow runs daily and monitors the health of the +ContentTwin content pipeline. It opens a GitHub issue if content is stale, +the queue is empty, or publishing errors are detected in recent workflow runs. + +**Trigger:** `schedule` (daily at 07:00 UTC) +**Repo scope:** `petry-projects/ContentTwin` only +**Outputs:** GitHub issue in `petry-projects/ContentTwin` when problems are detected + +--- + +## Scenario 1: Stale content detected + +**Given** the ContentTwin repo has content files with no commits in the past 7 days +**When** the daily schedule fires +**Then** +- the workflow identifies stale content items +- Claude produces an audit report with staleness details +- a GitHub issue is opened titled `ContentTwin Audit — stale content YYYY-MM-DD` +- the issue lists stale file paths and days-since-last-update + +**Assertions:** +- `HAS_CONTENT_ISSUES=true` is set in `GITHUB_ENV` +- A GitHub issue is created in the `ContentTwin` repo +- The issue is labelled `content-audit` and `automated-report` + +--- + +## Scenario 2: Publishing workflow failures detected + +**Given** recent runs of the ContentTwin publishing workflow have `conclusion=failure` +**When** the daily schedule fires +**Then** +- the workflow fetches recent workflow run history from the ContentTwin repo +- failed runs are included in the audit report +- the issue flags publishing errors with links to the failed runs + +**Assertions:** +- Issue body contains links to failed workflow runs +- Error reason is extracted and summarised (not just run URL) + +--- + +## Scenario 3: Content queue is empty + +**Given** the ContentTwin content queue directory or feed contains no pending items +**When** the daily schedule fires +**Then** +- the workflow detects the empty queue condition +- the audit report flags "empty queue" as a potential issue +- an issue is opened if this state persists for more than 1 day + +**Assertions:** +- Issue mentions the queue-empty condition +- The empty-queue check does not fire if queue was recently emptied intentionally + +--- + +## Scenario 4: All healthy + +**Given** ContentTwin has recent commits, no publishing failures, and a non-empty queue +**When** the daily schedule fires +**Then** +- no issue is opened +- the workflow annotates a clean-run notice + +**Assertions:** +- `HAS_CONTENT_ISSUES` is absent or `false` +- No GitHub issue is created +- Workflow conclusion is `success` + +--- + +## Scenario 5: ContentTwin repo inaccessible + +**Given** the `GH_TOKEN` does not have access to the ContentTwin repo +**When** the daily schedule fires +**Then** +- the workflow emits a `::error::` annotation +- the workflow fails so on-call is alerted + +**Assertions:** +- Workflow conclusion is `failure` +- The error message mentions "cannot access ContentTwin repo" or similar + +--- + +## Scenario 6: Duplicate issue suppression + +**Given** an open content-audit issue already exists in the ContentTwin repo +**When** the daily schedule fires and finds the same problems +**Then** +- no duplicate issue is opened +- the workflow updates the existing issue with a new timestamp comment, or skips + +**Assertions:** +- The count of open `content-audit` issues does not increase on repeat runs with the same findings diff --git a/tests/aw/dependency-advisory/scenarios.md b/tests/aw/dependency-advisory/scenarios.md new file mode 100644 index 000000000..e3d00bde5 --- /dev/null +++ b/tests/aw/dependency-advisory/scenarios.md @@ -0,0 +1,98 @@ +# Scenarios: dependency-advisory + +## Overview + +The `dependency-advisory` workflow triggers on pull requests that touch dependency +manifest files. It provides AI-powered risk assessment beyond what Dependabot offers: +flagging major version bumps, known-vulnerable packages, and unusual transitive deps. + +**Trigger:** `pull_request` (opened, synchronize, reopened) touching `package.json`, +`package-lock.json`, `go.mod`, `go.sum`, `requirements.txt`, `Pipfile`, `Pipfile.lock`, +`Gemfile`, `Gemfile.lock`, `Cargo.toml`, `Cargo.lock`, `pyproject.toml`, `pom.xml`, +`build.gradle`, `build.gradle.kts` +**Outputs:** PR comment with risk assessment + +--- + +## Scenario 1: Major version bump detected + +**Given** a PR bumps a dependency from v1.x to v2.x in `package.json` +**When** the PR is opened or updated +**Then** +- the workflow extracts the before/after versions from the diff +- Claude assesses the risk of the major version change +- a PR comment is posted with risk rating (LOW / MEDIUM / HIGH / CRITICAL) +- the comment identifies potential breaking changes and migration notes + +**Assertions:** +- A PR comment is created by the bot +- The comment contains a risk rating section +- The comment references the specific package and version change + +--- + +## Scenario 2: No dependency files changed + +**Given** a PR only modifies source code (no dep manifests) +**When** the PR is opened +**Then** +- the workflow is not triggered (path filter excludes it) + +**Assertions:** +- The `dependency-advisory` check does not appear in the PR checks list + +--- + +## Scenario 3: New direct dependency added + +**Given** a PR adds a new package to `requirements.txt` that was not previously present +**When** the PR is opened +**Then** +- Claude checks whether the package has known CVEs or is unusually new/unvetted +- the comment flags risk level and suggests review of the package's maintenance status + +**Assertions:** +- PR comment mentions the new package by name +- Comment includes a maintenance/reputation note + +--- + +## Scenario 4: Multiple dep files changed in one PR + +**Given** a PR modifies both `package.json` and `go.mod` +**When** the PR is opened +**Then** +- the workflow analyses changes across all modified dep files +- the comment groups findings by file + +**Assertions:** +- PR comment has at least one section per changed dep file +- No duplicate comments are posted on re-push + +--- + +## Scenario 5: Bot PR (Dependabot) + +**Given** a Dependabot PR touches `package.json` +**When** the PR is opened +**Then** +- the advisory skips or runs with a reduced scope +- no blocking comment is posted for routine patch/minor bumps from Dependabot + +**Assertions:** +- Workflow runs without error +- Comment (if posted) is non-blocking for patch/minor bumps + +--- + +## Scenario 6: Dep file changed but diff is only a lockfile regeneration + +**Given** only `package-lock.json` changes (no `package.json` version changes) +**When** the PR is opened +**Then** +- Claude recognises this is a lockfile-only change +- comment notes "lockfile regeneration — no direct version changes detected" + +**Assertions:** +- PR comment is posted and indicates lockfile-only change +- Risk rating is LOW diff --git a/tests/aw/docs-health-check/scenarios.md b/tests/aw/docs-health-check/scenarios.md new file mode 100644 index 000000000..11d93f2ed --- /dev/null +++ b/tests/aw/docs-health-check/scenarios.md @@ -0,0 +1,84 @@ +# Scenarios: docs-health-check + +## Overview + +The `docs-health-check` workflow runs on a weekly schedule and audits documentation +freshness across all org repos. It opens a GitHub issue listing any documentation +files that have not been updated in 90 or more days. + +**Trigger:** `schedule` (weekly, Sunday 00:00 UTC) +**Outputs:** GitHub issue in `.github-private` when stale docs are found + +--- + +## Scenario 1: Stale docs found + +**Given** one or more org repos contain doc files last committed more than 90 days ago +**When** the weekly schedule fires +**Then** +- the script identifies all stale doc files with their last-commit dates +- Claude produces a prioritized markdown report +- a GitHub issue is opened titled `Docs Health Check — stale files detected YYYY-MM-DD` +- the issue body lists each stale file as `repo/path` with the last-updated date +- the issue is labelled `health-check` and `automated-report` + +**Assertions:** +- `HAS_STALE_DOCS=true` is set in `GITHUB_ENV` +- The issue is created (workflow step `create-issue` exits 0) +- The report file (`docs_health_report.md`) is non-empty + +--- + +## Scenario 2: No stale docs + +**Given** all org repos have doc files committed within the last 90 days +**When** the weekly schedule fires +**Then** +- the script finds zero stale files +- no GitHub issue is opened +- the workflow annotates a clean-run notice + +**Assertions:** +- `HAS_STALE_DOCS` is absent or `false` +- the `create-issue` step is skipped +- workflow concludes `success` + +--- + +## Scenario 3: Repo with no docs/ directory + +**Given** an org repo has no `docs/` directory +**When** the weekly schedule fires +**Then** +- the script skips that repo without error +- the repo does not appear in the stale-docs list + +**Assertions:** +- Script exits 0 even when `gh api repos/{repo}/contents/docs` returns 404 + +--- + +## Scenario 4: GitHub API rate limit + +**Given** the GitHub API returns a rate-limit error during repo enumeration +**When** the weekly schedule fires +**Then** +- the script emits a `::warning::` annotation +- the workflow exits non-zero so the run is marked as failed (alerting on-call) + +**Assertions:** +- Workflow conclusion is `failure` +- A `::warning::` or `::error::` annotation is visible in the run log + +--- + +## Scenario 5: Stale docs in this repo (`.github-private`) + +**Given** `.github-private/docs/` contains files not updated in 90+ days +**When** the weekly schedule fires +**Then** +- the issue references the stale file path relative to the repo root +- the issue is opened on the `.github-private` repo itself + +**Assertions:** +- At least one entry in the report matches the pattern `petry-projects/.github-private/docs/*` diff --git a/tests/aw/standards-sync/scenarios.md b/tests/aw/standards-sync/scenarios.md new file mode 100644 index 000000000..5fd796dbd --- /dev/null +++ b/tests/aw/standards-sync/scenarios.md @@ -0,0 +1,100 @@ +# Scenarios: standards-sync + +## Overview + +The `standards-sync` workflow runs on a monthly schedule (first Monday of each month) +and checks all org repos against the org standards defined in `AGENTS.md`, `CODEOWNERS`, +and required workflow files. For any repo that is out of sync, it opens a PR with the +required additions. + +**Trigger:** `schedule` (monthly, first Monday at 09:00 UTC — `0 9 1-7 * 1`) +**Outputs:** PRs opened on out-of-sync repos + +--- + +## Scenario 1: Repo missing AGENTS.md + +**Given** a repo in the `petry-projects` org does not have an `AGENTS.md` file +**When** the monthly schedule fires +**Then** +- the workflow detects the missing file +- a PR is opened on that repo adding `AGENTS.md` with the org-standard content +- the PR title is `chore: add AGENTS.md (standards-sync)` +- the PR is labelled `standards-sync` + +**Assertions:** +- A PR is opened on the out-of-sync repo +- The PR diff contains `AGENTS.md` as a new file +- No PR is opened if the repo already has `AGENTS.md` + +--- + +## Scenario 2: Repo missing CODEOWNERS + +**Given** a repo does not have `.github/CODEOWNERS` +**When** the monthly schedule fires +**Then** +- the workflow detects the missing file +- a PR is opened adding a default `CODEOWNERS` pointing at org-wide owners + +**Assertions:** +- PR contains `.github/CODEOWNERS` as a new file +- File content matches the org-standard template + +--- + +## Scenario 3: Repo already compliant + +**Given** a repo has all required files: `AGENTS.md`, `.github/CODEOWNERS` +**When** the monthly schedule fires +**Then** +- no PR is opened for that repo +- the workflow logs a "compliant" notice for the repo + +**Assertions:** +- No new PRs are created for compliant repos +- Workflow exits 0 + +--- + +## Scenario 4: PR already open for the repo + +**Given** a standards-sync PR is already open for a repo (from a previous run) +**When** the monthly schedule fires again +**Then** +- the workflow detects the existing open PR +- no duplicate PR is opened +- the workflow logs "skipping — PR already open" for that repo + +**Assertions:** +- No duplicate PR is created +- Idempotency: running twice produces the same state + +--- + +## Scenario 5: Archived or private repo + +**Given** a repo in the org is archived +**When** the monthly schedule fires +**Then** +- the workflow skips archived repos +- no PR is opened on an archived repo (GitHub API would reject it anyway) + +**Assertions:** +- Archived repos are excluded from the sync list +- Script does not emit errors for archived repos + +--- + +## Scenario 6: Cross-org compliance summary + +**Given** the workflow has run across all repos +**When** the run completes +**Then** +- a summary issue is opened in `.github-private` listing compliance status +- the issue shows: total repos scanned, compliant count, PRs opened count + +**Assertions:** +- A summary issue is created in `petry-projects/.github-private` +- Issue body contains a compliance table or list +- Issue is labelled `standards-sync` and `automated-report` diff --git a/tests/aw/validate-specs.sh b/tests/aw/validate-specs.sh new file mode 100644 index 000000000..2ce83e626 --- /dev/null +++ b/tests/aw/validate-specs.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# tests/aw/validate-specs.sh +# +# Validates the agentic workflow (aw) specs, docs, and workflow YAML files. +# Each workflow must have: +# 1. A non-empty scenario spec at tests/aw/{name}/scenarios.md +# 2. A non-empty doc at docs/aw/{name}.md +# 3. A valid YAML workflow at .github/workflows/{name}.yml +# (checked via python3 yaml.safe_load) +# 4. The workflow YAML must contain: 'name:', 'on:', 'jobs:' +# 5. An executable shell script at scripts/aw-{name}.sh +# (checked via bash -n syntax validation) +# +# Exit code: 0 = all checks pass; 1 = one or more checks failed. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +PASS=0 +FAIL=0 +ERRORS="" + +ok() { + local name="$1" + PASS=$((PASS + 1)) + printf 'PASS %s\n' "$name" +} + +fail() { + local name="$1" + local msg="$2" + FAIL=$((FAIL + 1)) + ERRORS="${ERRORS}FAIL ${name}: ${msg}\n" + printf 'FAIL %s: %s\n' "$name" "$msg" +} + +# --------------------------------------------------------------------------- +# Workflows to validate +# --------------------------------------------------------------------------- +WORKFLOWS=( + "docs-health-check" + "dependency-advisory" + "standards-sync" + "content-twin-audit" +) + +# --------------------------------------------------------------------------- +# Per-workflow checks +# --------------------------------------------------------------------------- +for wf in "${WORKFLOWS[@]}"; do + echo "" + echo "--- ${wf} ---" + + # 1. Scenario spec exists and is non-empty + spec="${REPO_ROOT}/tests/aw/${wf}/scenarios.md" + if [ -f "$spec" ] && [ -s "$spec" ]; then + ok "${wf}: scenario spec exists (tests/aw/${wf}/scenarios.md)" + elif [ ! -f "$spec" ]; then + fail "${wf}: scenario spec missing" "tests/aw/${wf}/scenarios.md not found" + else + fail "${wf}: scenario spec empty" "tests/aw/${wf}/scenarios.md is zero bytes" + fi + + # Scenario spec must have at least one "## Scenario" heading + if [ -f "$spec" ] && grep -q "^## Scenario" "$spec" 2>/dev/null; then + ok "${wf}: scenario spec has scenario headings" + else + fail "${wf}: scenario spec malformed" "no '## Scenario' headings found in tests/aw/${wf}/scenarios.md" + fi + + # 2. Doc exists and is non-empty + doc="${REPO_ROOT}/docs/aw/${wf}.md" + if [ -f "$doc" ] && [ -s "$doc" ]; then + ok "${wf}: doc exists (docs/aw/${wf}.md)" + elif [ ! -f "$doc" ]; then + fail "${wf}: doc missing" "docs/aw/${wf}.md not found" + else + fail "${wf}: doc empty" "docs/aw/${wf}.md is zero bytes" + fi + + # 3. Workflow YAML exists and is valid YAML + yml="${REPO_ROOT}/.github/workflows/${wf}.yml" + if [ ! -f "$yml" ]; then + fail "${wf}: workflow YAML missing" ".github/workflows/${wf}.yml not found" + else + if python3 -c " +import sys, yaml +try: + yaml.safe_load(open(sys.argv[1])) + sys.exit(0) +except Exception as e: + print(str(e), file=sys.stderr) + sys.exit(1) +" "$yml" 2>/dev/null; then + ok "${wf}: workflow YAML is valid" + else + fail "${wf}: workflow YAML invalid" ".github/workflows/${wf}.yml failed yaml.safe_load" + fi + + # 4. Workflow YAML has required top-level keys + for key in name on jobs; do + if grep -q "^${key}:" "$yml" 2>/dev/null; then + ok "${wf}: workflow YAML has '${key}:'" + else + fail "${wf}: workflow YAML missing '${key}:'" ".github/workflows/${wf}.yml has no top-level '${key}:' key" + fi + done + + # 5. Workflow YAML references the corresponding script + if grep -q "aw-${wf}.sh" "$yml" 2>/dev/null; then + ok "${wf}: workflow YAML references scripts/aw-${wf}.sh" + else + fail "${wf}: workflow YAML does not reference script" ".github/workflows/${wf}.yml has no reference to scripts/aw-${wf}.sh" + fi + fi + + # 6. Shell script exists and passes bash -n syntax check + script="${REPO_ROOT}/scripts/aw-${wf}.sh" + if [ ! -f "$script" ]; then + fail "${wf}: script missing" "scripts/aw-${wf}.sh not found" + else + if bash -n "$script" 2>/dev/null; then + ok "${wf}: scripts/aw-${wf}.sh passes bash -n" + else + fail "${wf}: script has syntax errors" "bash -n scripts/aw-${wf}.sh failed" + fi + + # Script must have set -euo pipefail + if grep -q "set -euo pipefail" "$script" 2>/dev/null; then + ok "${wf}: script uses set -euo pipefail" + else + fail "${wf}: script missing set -euo pipefail" "scripts/aw-${wf}.sh does not call 'set -euo pipefail'" + fi + fi +done + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +echo "" +echo "Results: ${PASS} passed, ${FAIL} failed" +if [ "$FAIL" -gt 0 ]; then + printf '%b' "$ERRORS" + exit 1 +fi From 83d2dc13cb3b96a1a0b5c7cb3e27bab0cbd2d375 Mon Sep 17 00:00:00 2001 From: donpetry-bot Date: Wed, 20 May 2026 05:26:22 +0000 Subject: [PATCH 2/4] fix(reviews): address review comments [skip ci-relay] --- .github/workflows/standards-sync.yml | 19 ++++++++++++++--- scripts/aw-content-twin-audit.sh | 22 +++++++++++++++++--- scripts/aw-dependency-advisory.sh | 31 +++++++++++++++++++++------- scripts/aw-docs-health-check.sh | 25 +++++++++++++++------- scripts/aw-standards-sync.sh | 21 +++++++++++-------- 5 files changed, 88 insertions(+), 30 deletions(-) diff --git a/.github/workflows/standards-sync.yml b/.github/workflows/standards-sync.yml index f0ddf04f2..7998bce67 100644 --- a/.github/workflows/standards-sync.yml +++ b/.github/workflows/standards-sync.yml @@ -2,9 +2,8 @@ name: Standards Sync on: schedule: - # First Monday of each month at 09:00 UTC - # Runs on days 1–7 of the month that fall on Monday (day-of-week 1) - - cron: '0 9 1-7 * 1' + # Every Monday at 09:00 UTC; the guard step below enforces first-Monday-only + - cron: '0 9 * * 1' workflow_dispatch: {} permissions: @@ -26,10 +25,24 @@ jobs: SKIP_REPOS: ${{ vars.STANDARDS_SYNC_SKIP_REPOS || '' }} steps: + - name: Guard — first Monday of month only + id: guard + run: | + day=$(date -u +%-d) + if [ "$day" -gt 7 ]; then + echo "Not the first Monday of the month (day=$day) — skipping." + echo "proceed=false" >> "$GITHUB_OUTPUT" + else + echo "First Monday confirmed (day=$day) — proceeding." + echo "proceed=true" >> "$GITHUB_OUTPUT" + fi + - name: Checkout agent repo + if: steps.guard.outputs.proceed == 'true' uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Run standards sync + if: steps.guard.outputs.proceed == 'true' env: GH_TOKEN: ${{ secrets.DON_PETRY_BOT_GH_PAT }} run: bash scripts/aw-standards-sync.sh diff --git a/scripts/aw-content-twin-audit.sh b/scripts/aw-content-twin-audit.sh index f868814a6..7f0a1764c 100644 --- a/scripts/aw-content-twin-audit.sh +++ b/scripts/aw-content-twin-audit.sh @@ -75,8 +75,24 @@ queue_files=$(gh api "repos/${CONTENT_TWIN_REPO}/contents/queue" \ --jq 'length' 2>/dev/null || echo "0") if [ "$queue_files" = "0" ] || [ -z "$queue_files" ]; then - queue_empty=true - echo " Queue is empty (0 items in queue/)" + echo " Queue appears empty; checking persistence before alerting..." + queue_last_commit=$(gh api "repos/${CONTENT_TWIN_REPO}/commits?path=queue&per_page=1" \ + --jq '.[0].commit.author.date // ""' 2>/dev/null || true) + if [ -n "$queue_last_commit" ]; then + queue_commit_epoch=$(date -u -d "${queue_last_commit:0:10}" +%s 2>/dev/null \ + || date -u -j -f '%Y-%m-%d' "${queue_last_commit:0:10}" +%s) + today_epoch=$(date -u -d "$TODAY" +%s 2>/dev/null || date -u -j -f '%Y-%m-%d' "$TODAY" +%s) + queue_empty_hours=$(( (today_epoch - queue_commit_epoch) / 3600 )) + if [ "$queue_empty_hours" -ge 24 ]; then + queue_empty=true + echo " Queue is empty and has been since ${queue_last_commit:0:10} (${queue_empty_hours}h ago)" + else + echo " Queue is empty but last activity was ${queue_empty_hours}h ago — treating as transient" + fi + else + queue_empty=true + echo " Queue is empty (0 items in queue/)" + fi else echo " Queue has ${queue_files} item(s)" fi @@ -182,7 +198,7 @@ echo "Report written to ${REPORT_FILE} ($(wc -c < "$REPORT_FILE") bytes)." # 6. Check for existing open issue (dedup) # --------------------------------------------------------------------------- existing_issue=$(gh api "repos/${CONTENT_TWIN_REPO}/issues?labels=${AUDIT_LABEL}&state=open&per_page=1" \ - --jq '.[0].number // ""' 2>/dev/null || true) + --jq '[.[] | select(.pull_request == null)] | .[0].number // ""' 2>/dev/null || true) report_body=$(cat "$REPORT_FILE") diff --git a/scripts/aw-dependency-advisory.sh b/scripts/aw-dependency-advisory.sh index 1fbb9b839..79625dec4 100644 --- a/scripts/aw-dependency-advisory.sh +++ b/scripts/aw-dependency-advisory.sh @@ -49,20 +49,28 @@ fi # 2. Check for existing advisory comment (dedup) # --------------------------------------------------------------------------- existing_comment_id=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \ + --paginate \ --jq "[.[] | select(.body | startswith(\"${COMMENT_MARKER}\"))] | .[0].id // \"\"" \ 2>/dev/null || true) # --------------------------------------------------------------------------- # 3. Get diff for dependency files only # --------------------------------------------------------------------------- -dep_diff=$(gh pr diff "$PR_NUMBER" --repo "$REPO" 2>/dev/null | \ - awk ' - /^diff --git/ { in_dep=0; header=$0; next } - /package\.json|package-lock\.json|yarn\.lock|go\.mod|go\.sum|requirements\.txt|Pipfile|Gemfile|Cargo\.toml|pyproject\.toml|pom\.xml|build\.gradle/ { - in_dep=1; print header; print; next - } - in_dep { print } - ' || true) +_diff_tmp=$(mktemp) +if ! gh pr diff "$PR_NUMBER" --repo "$REPO" > "$_diff_tmp"; then + rm -f "$_diff_tmp" + echo "::error::Failed to retrieve diff for PR #${PR_NUMBER} in ${REPO}." + exit 1 +fi +dep_diff=$(awk ' + /^diff --git/ { + in_dep = ($0 ~ /package\.json|package-lock\.json|yarn\.lock|go\.mod|go\.sum|requirements\.txt|Pipfile|Gemfile|Cargo\.toml|pyproject\.toml|pom\.xml|build\.gradle/) + if (in_dep) print + next + } + in_dep { print } +' "$_diff_tmp") +rm -f "$_diff_tmp" if [ -z "$dep_diff" ]; then echo "::notice::No dependency file changes found in diff — skipping advisory." @@ -128,6 +136,13 @@ advisory_body=$(cat "$ADVISORY_FILE") # --------------------------------------------------------------------------- # 5. Post or update PR comment # --------------------------------------------------------------------------- +# Skip comment writes when the token is read-only (e.g. forked PR with GITHUB_TOKEN) +if ! gh api "repos/${REPO}" --jq '.permissions.push' 2>/dev/null | grep -q "^true$"; then + echo "::notice::Token lacks write access to ${REPO} — skipping advisory comment (read-only context)." + echo "=== Dependency advisory complete (comment skipped — no write access) ===" + exit 0 +fi + if [ -n "$existing_comment_id" ]; then echo "Updating existing advisory comment #${existing_comment_id}..." gh api "repos/${REPO}/issues/comments/${existing_comment_id}" \ diff --git a/scripts/aw-docs-health-check.sh b/scripts/aw-docs-health-check.sh index 7263b93a3..700f869c4 100644 --- a/scripts/aw-docs-health-check.sh +++ b/scripts/aw-docs-health-check.sh @@ -70,11 +70,19 @@ echo "" # 3. Scan docs files per repo # --------------------------------------------------------------------------- stale_entries="" +_api_tmpfile=$(mktemp) +trap 'rm -f "$_api_tmpfile"' EXIT while IFS= read -r repo; do - # Fetch docs/ directory listing; skip repos that have none - docs_listing=$(gh api "repos/${repo}/contents/docs" \ - --jq '.[].name' 2>/dev/null || true) + # Fetch docs/ directory listing; 404 means no docs dir (skip), any other error is fatal + if ! docs_listing=$(gh api "repos/${repo}/contents/docs" \ + --jq '.[].name' 2>"$_api_tmpfile"); then + if grep -qi "404\|not found" "$_api_tmpfile" 2>/dev/null; then + continue + fi + echo "::error::API failure listing docs/ for ${repo}: $(cat "$_api_tmpfile")" + exit 1 + fi [ -z "$docs_listing" ] && continue @@ -83,10 +91,13 @@ while IFS= read -r repo; do _is_ignored "$full_path" && continue - # Get last commit date for this file - last_commit=$(gh api \ - "repos/${repo}/commits?path=docs/${doc_name}&per_page=1" \ - --jq '.[0].commit.author.date // ""' 2>/dev/null || true) + # Get last commit date for this file; API failures are fatal (not silently skipped) + if ! last_commit=$(gh api \ + "repos/${repo}/commits?path=docs/${doc_name}&per_page=1" \ + --jq '.[0].commit.author.date // ""' 2>"$_api_tmpfile"); then + echo "::error::API failure fetching commits for ${repo}/docs/${doc_name}: $(cat "$_api_tmpfile")" + exit 1 + fi [ -z "$last_commit" ] && continue diff --git a/scripts/aw-standards-sync.sh b/scripts/aw-standards-sync.sh index 58f6789a0..d7dd34111 100644 --- a/scripts/aw-standards-sync.sh +++ b/scripts/aw-standards-sync.sh @@ -139,15 +139,18 @@ while IFS= read -r repo; do continue fi - # Create the sync branch - gh api "repos/${repo}/git/refs" \ - --method POST \ - --field "ref=refs/heads/${BRANCH_NAME}" \ - --field "sha=${base_sha}" \ - --silent 2>/dev/null || { - echo " [warn] ${repo} — branch ${BRANCH_NAME} may already exist, skipping" - continue - } + # Create the sync branch; if it already exists (failed previous run) reuse it + if ! gh api "repos/${repo}/git/refs" \ + --method POST \ + --field "ref=refs/heads/${BRANCH_NAME}" \ + --field "sha=${base_sha}" \ + --silent 2>/dev/null; then + if ! gh api "repos/${repo}/git/ref/heads/${BRANCH_NAME}" --silent 2>/dev/null; then + echo " [warn] ${repo} — could not create branch ${BRANCH_NAME}, skipping" + continue + fi + echo " [info] ${repo} — branch ${BRANCH_NAME} already exists, reusing for PR creation" + fi # Create each missing file via the Contents API missing_labels="" From 3c5bca432ca597b6ecba25b10dc6aaf4d3bc999d Mon Sep 17 00:00:00 2001 From: donpetry-bot Date: Wed, 20 May 2026 05:38:39 +0000 Subject: [PATCH 3/4] fix(reviews): address review comments [skip ci-relay] --- scripts/aw-content-twin-audit.sh | 11 ++++++----- scripts/aw-dependency-advisory.sh | 15 ++++++++++++--- scripts/aw-standards-sync.sh | 3 ++- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/scripts/aw-content-twin-audit.sh b/scripts/aw-content-twin-audit.sh index 7f0a1764c..4de481e8d 100644 --- a/scripts/aw-content-twin-audit.sh +++ b/scripts/aw-content-twin-audit.sh @@ -79,10 +79,10 @@ if [ "$queue_files" = "0" ] || [ -z "$queue_files" ]; then queue_last_commit=$(gh api "repos/${CONTENT_TWIN_REPO}/commits?path=queue&per_page=1" \ --jq '.[0].commit.author.date // ""' 2>/dev/null || true) if [ -n "$queue_last_commit" ]; then - queue_commit_epoch=$(date -u -d "${queue_last_commit:0:10}" +%s 2>/dev/null \ - || date -u -j -f '%Y-%m-%d' "${queue_last_commit:0:10}" +%s) - today_epoch=$(date -u -d "$TODAY" +%s 2>/dev/null || date -u -j -f '%Y-%m-%d' "$TODAY" +%s) - queue_empty_hours=$(( (today_epoch - queue_commit_epoch) / 3600 )) + queue_commit_epoch=$(date -u -d "${queue_last_commit}" +%s 2>/dev/null \ + || date -u -j -f '%Y-%m-%dT%H:%M:%SZ' "${queue_last_commit}" +%s) + now_epoch=$(date -u +%s) + queue_empty_hours=$(( (now_epoch - queue_commit_epoch) / 3600 )) if [ "$queue_empty_hours" -ge 24 ]; then queue_empty=true echo " Queue is empty and has been since ${queue_last_commit:0:10} (${queue_empty_hours}h ago)" @@ -219,7 +219,8 @@ else --field "body=${report_body}" \ --field "labels[]=${AUDIT_LABEL}" \ --field "labels[]=automated-report" \ - --silent && echo " Issue created." + --silent + echo " Issue created." fi echo "" diff --git a/scripts/aw-dependency-advisory.sh b/scripts/aw-dependency-advisory.sh index 79625dec4..b24fe9415 100644 --- a/scripts/aw-dependency-advisory.sh +++ b/scripts/aw-dependency-advisory.sh @@ -86,6 +86,14 @@ echo " Dep diff: ${diff_lines} lines" ADVISORY_FILE=$(mktemp) trap 'rm -f "$ADVISORY_FILE"' EXIT +# Skip Claude invocation when fork PR secrets are unavailable (GitHub does not +# pass repository secrets to fork PRs, so CLAUDE_CODE_OAUTH_TOKEN will be unset). +if [ -z "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]; then + echo "::notice::CLAUDE_CODE_OAUTH_TOKEN not available (fork PR) — skipping dependency advisory." + echo "=== Dependency advisory complete (skipped — no Claude token) ===" + exit 0 +fi + echo "Invoking Claude for dependency risk assessment..." if ! claude --print --model claude-sonnet-4-6 --no-session-persistence \ > "$ADVISORY_FILE" </dev/null | grep -q "^true$"; then - echo "::notice::Token lacks write access to ${REPO} — skipping advisory comment (read-only context)." +# Skip comment writes when the token cannot create PR/issue comments. +# Commenting needs triage-level access or higher — do not require push. +if ! gh api "repos/${REPO}" --jq '.permissions.triage // false' 2>/dev/null | grep -q "^true$"; then + echo "::notice::Token lacks comment access to ${REPO} — skipping advisory comment (read-only context)." echo "=== Dependency advisory complete (comment skipped — no write access) ===" exit 0 fi diff --git a/scripts/aw-standards-sync.sh b/scripts/aw-standards-sync.sh index d7dd34111..8d06b6f8b 100644 --- a/scripts/aw-standards-sync.sh +++ b/scripts/aw-standards-sync.sh @@ -212,7 +212,8 @@ gh api "repos/${REPORT_REPO}/issues" \ --field "body=${summary_body}" \ --field "labels[]=${SYNC_LABEL}" \ --field "labels[]=automated-report" \ - --silent && echo " Summary issue created." + --silent +echo " Summary issue created." echo "" echo "=== Standards sync complete: ${total_scanned} scanned, ${compliant_count} compliant, ${prs_opened} PRs opened ===" From 15adf01b604a1a443a8d4dc3c3f34da548254758 Mon Sep 17 00:00:00 2001 From: donpetry-bot Date: Wed, 20 May 2026 05:52:01 +0000 Subject: [PATCH 4/4] fix(reviews): address review comments [skip ci-relay] --- .github/workflows/standards-sync.yml | 17 +++++++++++------ scripts/aw-content-twin-audit.sh | 22 ++++++++++++++++++++-- scripts/aw-dependency-advisory.sh | 2 +- scripts/aw-standards-sync.sh | 19 +++++++++++++++++-- 4 files changed, 49 insertions(+), 11 deletions(-) diff --git a/.github/workflows/standards-sync.yml b/.github/workflows/standards-sync.yml index 7998bce67..a9550726f 100644 --- a/.github/workflows/standards-sync.yml +++ b/.github/workflows/standards-sync.yml @@ -28,13 +28,18 @@ jobs: - name: Guard — first Monday of month only id: guard run: | - day=$(date -u +%-d) - if [ "$day" -gt 7 ]; then - echo "Not the first Monday of the month (day=$day) — skipping." - echo "proceed=false" >> "$GITHUB_OUTPUT" - else - echo "First Monday confirmed (day=$day) — proceeding." + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "Manual trigger — bypassing first-Monday guard." echo "proceed=true" >> "$GITHUB_OUTPUT" + else + day=$(date -u +%-d) + if [ "$day" -gt 7 ]; then + echo "Not the first Monday of the month (day=$day) — skipping." + echo "proceed=false" >> "$GITHUB_OUTPUT" + else + echo "First Monday confirmed (day=$day) — proceeding." + echo "proceed=true" >> "$GITHUB_OUTPUT" + fi fi - name: Checkout agent repo diff --git a/scripts/aw-content-twin-audit.sh b/scripts/aw-content-twin-audit.sh index 4de481e8d..46aba1c43 100644 --- a/scripts/aw-content-twin-audit.sh +++ b/scripts/aw-content-twin-audit.sh @@ -71,8 +71,18 @@ fi echo "Checking content queue..." queue_empty=false -queue_files=$(gh api "repos/${CONTENT_TWIN_REPO}/contents/queue" \ - --jq 'length' 2>/dev/null || echo "0") +_queue_err=$(mktemp) +if ! queue_files=$(gh api "repos/${CONTENT_TWIN_REPO}/contents/queue" \ + --jq 'length' 2>"$_queue_err"); then + if grep -qiE 'HTTP 404|Not Found' "$_queue_err" 2>/dev/null; then + queue_files="0" + else + echo "::error::Content queue API error: $(cat "$_queue_err")" + rm -f "$_queue_err" + exit 1 + fi +fi +rm -f "$_queue_err" if [ "$queue_files" = "0" ] || [ -z "$queue_files" ]; then echo " Queue appears empty; checking persistence before alerting..." @@ -200,6 +210,14 @@ echo "Report written to ${REPORT_FILE} ($(wc -c < "$REPORT_FILE") bytes)." existing_issue=$(gh api "repos/${CONTENT_TWIN_REPO}/issues?labels=${AUDIT_LABEL}&state=open&per_page=1" \ --jq '[.[] | select(.pull_request == null)] | .[0].number // ""' 2>/dev/null || true) +# Truncate report before publication to avoid GitHub API payload limits +_max_bytes=60000 +if [ "$(wc -c < "$REPORT_FILE")" -gt "$_max_bytes" ]; then + head -c "$_max_bytes" "$REPORT_FILE" > "${REPORT_FILE}.tmp" + printf '\n\n---\n_Report truncated at %d bytes._\n' "$_max_bytes" >> "${REPORT_FILE}.tmp" + mv "${REPORT_FILE}.tmp" "$REPORT_FILE" +fi + report_body=$(cat "$REPORT_FILE") if [ -n "$existing_issue" ]; then diff --git a/scripts/aw-dependency-advisory.sh b/scripts/aw-dependency-advisory.sh index b24fe9415..b5b13b46a 100644 --- a/scripts/aw-dependency-advisory.sh +++ b/scripts/aw-dependency-advisory.sh @@ -146,7 +146,7 @@ advisory_body=$(cat "$ADVISORY_FILE") # --------------------------------------------------------------------------- # Skip comment writes when the token cannot create PR/issue comments. # Commenting needs triage-level access or higher — do not require push. -if ! gh api "repos/${REPO}" --jq '.permissions.triage // false' 2>/dev/null | grep -q "^true$"; then +if ! gh api "repos/${REPO}" --jq '(.permissions.triage // false) or (.permissions.push // false)' 2>/dev/null | grep -q "^true$"; then echo "::notice::Token lacks comment access to ${REPO} — skipping advisory comment (read-only context)." echo "=== Dependency advisory complete (comment skipped — no write access) ===" exit 0 diff --git a/scripts/aw-standards-sync.sh b/scripts/aw-standards-sync.sh index 8d06b6f8b..8ba7ae3b3 100644 --- a/scripts/aw-standards-sync.sh +++ b/scripts/aw-standards-sync.sh @@ -100,14 +100,29 @@ while IFS= read -r repo; do missing_files=() + _repo_api_error=false for entry in "${REQUIRED_FILES[@]}"; do required_path="${entry%%|*}" - # Check existence via contents API; 200 = exists, 404 = missing - if ! gh api "repos/${repo}/contents/${required_path}" --silent 2>/dev/null; then + _check_err=$(mktemp) + # Check existence via contents API; 200 = exists, 404 = missing, other = API error + if gh api "repos/${repo}/contents/${required_path}" --silent 2>"$_check_err"; then + rm -f "$_check_err" + elif grep -qiE 'HTTP 404|Not Found' "$_check_err" 2>/dev/null; then + rm -f "$_check_err" missing_files+=("$entry") + else + echo " [warn] ${repo}/${required_path} — API error (skipping repo): $(head -1 "$_check_err")" + rm -f "$_check_err" + _repo_api_error=true + break fi done + if [ "$_repo_api_error" = "true" ]; then + summary_rows="${summary_rows}| \`${repo}\` | (skip) | (skip) | API error during compliance check |\n" + continue + fi + if [ "${#missing_files[@]}" -eq 0 ]; then compliant_count=$((compliant_count + 1)) echo " [ok] ${repo}"