diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f64e69b42..7bd52f2fd 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -97,7 +97,8 @@ jobs: tests/test_engine_unavailable_notice.bats \ tests/test_batch_skip_reporting.bats \ tests/test_review_one_pr_ci_pending.bats \ - tests/test_release_registry.bats + tests/test_release_registry.bats \ + tests/test_readme_refresh_helpers.bats template-drift: # Drift guard (#969): fail when a file committed in petry-projects/repo-template diff --git a/.github/workflows/readme-refresh.yml b/.github/workflows/readme-refresh.yml new file mode 100644 index 000000000..7a9a48855 --- /dev/null +++ b/.github/workflows/readme-refresh.yml @@ -0,0 +1,69 @@ +name: README Refresh + +# Weekly agent that refreshes the org "meta-repo" READMEs from live org state and +# opens (or updates) one rolling PR per repo for human review. Documented repo-specific +# workflow — no corresponding org template in standards/workflows/ (see AGENTS.md). + +on: + schedule: + # Weekly — Monday at 09:00 UTC + - cron: '0 9 * * 1' + workflow_dispatch: + inputs: + dry_run: + description: "Generate and diff only; do not create/update branches or PRs" + required: false + default: false + type: boolean + +permissions: + contents: read + +concurrency: + group: readme-refresh + cancel-in-progress: false + +jobs: + refresh: + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + ORG: ${{ vars.ORG || 'petry-projects' }} + DRY_RUN: ${{ inputs.dry_run || 'false' }} + # Pin via vars.CLAUDE_CODE_VERSION for reproducible caching; mirrors docs-health-check.yml. + CLAUDE_CODE_VERSION: ${{ vars.CLAUDE_CODE_VERSION || '2.1.138' }} + + steps: + - name: Checkout agent repo + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Set up Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '20' + + - name: Cache claude-code CLI + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + 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 README refresh + env: + GH_TOKEN: ${{ secrets.DON_PETRY_BOT_GH_PAT }} + run: bash scripts/aw-readme-refresh.sh diff --git a/AGENTS.md b/AGENTS.md index beb2808ab..3578beda5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,6 +57,13 @@ This is the `.github-private` org infrastructure repo for `petry-projects`. It c ≤15-min backstop) and a `workflow_run: completed` fast path (#898) that scopes the sweep to the completing CI run's PR(s) for near-instant re-review. It must not be removed by template syncs. If the org template gains an equivalent re-trigger sweep, remove this exception and defer to the template instead. +- **Exception:** `readme-refresh.yml` (weekly org README refresh) is a documented repo-specific workflow + with no corresponding org template in `standards/workflows/`. It regenerates the four org "meta-repo" + READMEs (public + member-only org profiles, and the `.github` / `.github-private` repo landing pages) + from live org state via `scripts/aw-readme-refresh.sh` + `prompts/aw/readme-refresh.md`, then opens or + updates one rolling PR (`chore/readme-refresh`, label `readme-refresh`) per repo for human review. It + never direct-pushes. It must not be removed by template syncs. If the org template gains an equivalent, + remove this exception and defer to the template instead. - **Exception:** The `bats` test list in `lint.yml` is extended with repo-specific test files (e.g. `tests/test_push_protection.bats`) beyond the org template baseline. When adding new test files to this repo, add them to this list. Template syncs must not reset it to the base template list. This includes diff --git a/prompts/aw/readme-refresh.md b/prompts/aw/readme-refresh.md new file mode 100644 index 000000000..627cdb690 --- /dev/null +++ b/prompts/aw/readme-refresh.md @@ -0,0 +1,59 @@ + +You are the README Refresh agent for the `petry-projects` GitHub organization. Your job is to +produce an accurate, well-structured Markdown body for **${TARGET_LABEL}**. + +## Authoritative facts (live org state) + +${FACTS_BUNDLE} + +## Current content of the target file + +The current content is between the markers below. It is **empty** if the file does not yet exist. + +<< + ===README-END=== + ``` + + Put nothing except the file body between the markers, and nothing after `===README-END===`. +9. If the current content is already fully accurate and needs no change, output exactly `SKIP` + (with no markers, no other text). diff --git a/scripts/aw-readme-refresh.sh b/scripts/aw-readme-refresh.sh new file mode 100644 index 000000000..12e9e02a1 --- /dev/null +++ b/scripts/aw-readme-refresh.sh @@ -0,0 +1,367 @@ +#!/usr/bin/env bash +set -euo pipefail +# scripts/aw-readme-refresh.sh +# Weekly README Refresh agent. Reads live org state, regenerates the four org +# "meta-repo" READMEs via Claude, and opens (or updates) one rolling PR per repo. +# +# Targets: +# petry-projects/.github profile/README.md (public org profile) +# petry-projects/.github README.md (repo landing page) +# petry-projects/.github-private README.md (repo landing page) +# petry-projects/.github-private profile/README.md (member-only org profile) +# +# Environment: +# ORG — org login (default: petry-projects) +# DRY_RUN — if true, generate + diff only; no branch/PR (default: false) +# GH_TOKEN — GitHub PAT with repo write on both target repos +# CLAUDE_CODE_OAUTH_TOKEN — Claude auth (read by the claude CLI) +# CLAUDE_MODEL — model id (default: claude-sonnet-4-6) +# ACTION_TIMEOUT_SEC — per-target claude timeout (default: 300) +# README_REFRESH_OUTPUT_DIR — if set, copy each generated file here (debug/dry-run inspection) + +ORG="${ORG:-petry-projects}" +DRY_RUN="${DRY_RUN:-false}" +CLAUDE_MODEL="${CLAUDE_MODEL:-claude-sonnet-4-6}" +ACTION_TIMEOUT_SEC="${ACTION_TIMEOUT_SEC:-300}" +BRANCH="chore/readme-refresh" +LABEL="readme-refresh" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PROMPT_TEMPLATE="$REPO_ROOT/prompts/aw/readme-refresh.md" + +# shellcheck source=scripts/lib/git-identity.sh +source "$SCRIPT_DIR/lib/git-identity.sh" + +log() { echo "[readme-refresh] $*" >&2; } +notice() { echo "::notice::readme-refresh: $*"; } +warn() { echo "::warning::readme-refresh: $*"; } + +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "$WORK_DIR"' EXIT + +if [ -z "${GH_TOKEN:-}" ]; then + echo "::error::GH_TOKEN must be set" >&2 + exit 1 +fi +if [ ! -f "$PROMPT_TEMPLATE" ]; then + echo "::error::Prompt template not found at $PROMPT_TEMPLATE" >&2 + exit 1 +fi + +# ── Facts bundle ────────────────────────────────────────────────────────────── +# Assemble authoritative live org state so Claude never invents names. + +EMPTY_DESC_REPOS="" # populated by gather_facts; surfaced in the PR body + +gather_facts() { + local facts_file="$WORK_DIR/facts.md" + local repo_json standards_list + + repo_json=$(gh repo list "$ORG" --limit 1000 \ + --json name,description,primaryLanguage,isArchived,isPrivate \ + --jq 'sort_by(.name)' 2>/dev/null || echo "[]") + + EMPTY_DESC_REPOS=$(echo "$repo_json" \ + | jq -r '[.[] | select((.description // "") == "") | .name] | join(", ")' 2>/dev/null || echo "") + # gather_facts runs in a $(...) subshell, so persist this for the parent via a sidecar file. + printf '%s' "$EMPTY_DESC_REPOS" > "$WORK_DIR/empty_desc.txt" + + # Standards docs live in the sibling .github repo; fetch via API (not checked out here). + standards_list=$(gh api "repos/$ORG/.github/contents/standards" \ + --jq '[.[] | select((.name // "") | endswith(".md")) | .name | sub("\\.md$";"")] | sort | .[]' \ + 2>/dev/null || echo "(unavailable)") + + { + echo "### Live repositories in \`$ORG\` (source of truth for the projects table)" + echo "" + echo '```json' + echo "$repo_json" + echo '```' + echo "" + if [ -n "$EMPTY_DESC_REPOS" ]; then + echo "Repos with an EMPTY GitHub description (keep any existing curated text; do not blank): ${EMPTY_DESC_REPOS}" + echo "" + fi + echo "### Standards docs in \`$ORG/.github/standards/\`" + echo "" + if [ -n "$standards_list" ]; then + echo "$standards_list" | sed 's/^/- /' + fi + echo "" + echo "### Custom agents in \`.github-private/agents/\`" + echo "" + local f name desc + for f in "$REPO_ROOT"/agents/*.md; do + [ -e "$f" ] || continue + name="$(basename "$f" .md)" + desc="$(awk '/^description:/{sub(/^description:[[:space:]]*/,"");gsub(/^["'"'"']|["'"'"']$/,"");print;exit}' "$f")" + printf -- '- `%s`: %s\n' "$name" "$desc" + done + echo "" + echo "### Installed frameworks in \`.github-private/frameworks/\` (from each VENDOR.md)" + echo "" + local d src + for d in "$REPO_ROOT"/frameworks/*/; do + [ -d "$d" ] || continue + name="$(basename "$d")" + src="$(grep -iE '^(source|upstream|tag|version)' "$d/VENDOR.md" 2>/dev/null | head -2 | tr '\n' '; ' | sed 's/; *$//' || true)" + printf -- '- `%s` (%s)\n' "$name" "${src:-vendored}" + done + } > "$facts_file" + + echo "$facts_file" +} + +# ── Prompt rendering ────────────────────────────────────────────────────────── +# Scalars via envsubst; multiline blocks (facts, current content) injected by awk +# to avoid ARG_MAX limits (same technique as release-notes.sh). + +render_prompt() { + if [ $# -lt 5 ]; then return 1; fi + local facts_file="$1" current_file="$2" target_label="$3" target_type="$4" maxlen="$5" + TARGET_LABEL="$target_label" TARGET_TYPE="$target_type" LINT_MAXLEN="$maxlen" \ + envsubst '${TARGET_LABEL} ${TARGET_TYPE} ${LINT_MAXLEN}' < "$PROMPT_TEMPLATE" \ + | awk -v ff="$facts_file" -v cf="$current_file" ' + /\$\{FACTS_BUNDLE\}/ { while ((getline l < ff) > 0) print l; close(ff); next } + /\$\{CURRENT_CONTENT\}/ { while ((getline l < cf) > 0) print l; close(cf); next } + { print }' +} + +# ── Content generation ──────────────────────────────────────────────────────── + +generate_content() { + local prompt_file="$1" + local err_file="$WORK_DIR/claude-stderr.$$" + timeout "$ACTION_TIMEOUT_SEC" claude --print \ + --model "$CLAUDE_MODEL" \ + --disallowed-tools "Bash,Read,Write,Edit,Grep,Glob,WebFetch,WebSearch,Task,TodoWrite,NotebookEdit" \ + < "$prompt_file" 2>"$err_file" || { warn "claude invocation failed: $(tail -n5 "$err_file" 2>/dev/null)"; echo ""; } +} + +# Extract the file body between the sentinel markers the prompt requires. This is robust +# against reasoning preambles / trailing commentary the model may add around the body. +extract_readme() { + printf '%s\n' "$1" | awk ' + /^===README-END===[[:space:]]*$/ { f=0 } + f { print } + /^===README-BEGIN===[[:space:]]*$/ { f=1 }' +} + +# Remove leading and trailing blank lines (interior blanks preserved). +trim_blank_edges() { + printf '%s\n' "$1" | sed '/./,$!d' | tac | sed '/./,$!d' | tac +} + +# Comma-list of body line numbers whose length exceeds maxlen, excluding table rows and +# fenced code blocks (both exempt from markdownlint MD013). Empty if all lines fit. +overlong_lines() { + local content="$1" maxlen="$2" + printf '%s\n' "$content" | awk -v max="$maxlen" ' + /^[[:space:]]*```/ { infence = !infence; next } + infence { next } + /^[[:space:]]*\|/ { next } + length > max { printf "%s%d", (c++ ? "," : ""), NR }' +} + +# Reject obviously-malformed output before writing (protects lint + prevents junk PRs). +valid_content() { + local content="$1" target_type="$2" + [ -n "$content" ] || return 1 + [ "$content" = "SKIP" ] && return 1 + case "$target_type" in + org-profile-public|github-repo-readme) + # .github repo requires MD041: first line is a top-level heading. + printf '%s\n' "$content" | head -n1 | grep -q '^# ' || return 1 + ;; + esac + return 0 +} + +# ── Per-repo processing ─────────────────────────────────────────────────────── +# Target matrix, grouped by repo. Fields: ||