fix(automerge): close the pr-classify TOCTOU race — classify against base-branch risk-paths.yml at decision time #144
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Regression Convention | |
| # Two roles for this workflow: | |
| # | |
| # 1. Self-test: triggers on pull_request + push to main of *this repo*. Wired | |
| # with `lessons_files: ""` (no Lessons section tracked in this repo); the | |
| # workflow no-ops gracefully. Synthetic FAIL-path testing happens during | |
| # Phase 3 install on dotclaude (which DOES have a `## Lessons` section). | |
| # | |
| # 2. Reusable: callers do | |
| # jobs: | |
| # regression-convention: | |
| # uses: topcoder1/ci-workflows/.github/workflows/regression-convention.yml@main | |
| # with: | |
| # lessons_files: "MEMORY.md,CLAUDE.md" # whichever holds `## Lessons` | |
| # See callers/regression-convention.yml for the installer-generated caller. | |
| # | |
| # What it does: | |
| # - For each path in `lessons_files`, extract the `## Lessons` section | |
| # (from `^## Lessons` to the next `^## ` or EOF) in BOTH base and head. | |
| # If they differ, the PR modified the Lessons section. | |
| # - If ANY configured lessons file's Lessons section changed AND the PR | |
| # did NOT add or modify a file under `regression_dir` (default | |
| # `tests/regression/`), FAIL with a clear message. | |
| # - Empty `lessons_files` → no-op (no Lessons section tracked yet). | |
| # - Deletions of Lessons bullets are NOT a violation (pruning is fine); | |
| # only additions/modifications count. (Implementation: compare hashes of | |
| # the *additive* changes — simplest: any inequality in section content | |
| # triggers the check, but the message hints at "add a test OR remove | |
| # the lesson edit" so deletions are explicitly fine.) | |
| # | |
| # Why the per-repo config (no default lessons file): the fleet convention is | |
| # split. Global CLAUDE.md says "append to MEMORY.md", but `wxa_vpn` keeps | |
| # lessons in CLAUDE.md, `wxa-jake-ai` has both, several repos have neither | |
| # file with Lessons. A single default would either fire on a file most repos | |
| # don't use or miss the file they do. The installer (Phase 2 Task 2.4) | |
| # auto-detects per repo and wires the explicit path(s) into the caller. | |
| on: | |
| pull_request: | |
| push: | |
| branches: [main] | |
| workflow_call: | |
| inputs: | |
| lessons_files: | |
| description: "REQUIRED for callers. Comma-separated list of paths to files holding `## Lessons` (e.g. 'MEMORY.md,CLAUDE.md'). Empty string = no-op (workflow installs but doesn't fire until a Lessons section is added). The installer auto-detects per-repo and writes the resolved path(s) here." | |
| required: false | |
| type: string | |
| default: "" | |
| lessons_section_header: | |
| description: "Markdown header line that begins the lessons section. Default '## Lessons'." | |
| required: false | |
| type: string | |
| default: "## Lessons" | |
| regression_dir: | |
| description: "Directory that must contain a new/modified file when the lessons section changes. Default 'tests/regression'." | |
| required: false | |
| type: string | |
| default: "tests/regression" | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| jobs: | |
| check: | |
| name: Lessons must have tests | |
| runs-on: ubuntu-latest | |
| # Only meaningful on pull_request events; push events to main run as a | |
| # no-op (no diff to enforce against). | |
| if: github.event_name == 'pull_request' | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| ref: ${{ github.event.pull_request.head.sha }} | |
| - name: Resolve base + head refs | |
| id: refs | |
| env: | |
| BASE_REF: ${{ github.event.pull_request.base.ref }} | |
| run: | | |
| set -euo pipefail | |
| git fetch --quiet origin "$BASE_REF" | |
| # Use merge-base, not base tip, so we only see what THIS PR changed — | |
| # not what main happened to add since the branch forked. Two-dot diff | |
| # against the moving base tip false-positives on stale-base PRs | |
| # whenever main lands a Lessons edit in the interim (dotclaude#90). | |
| BASE_TIP=$(git rev-parse "origin/$BASE_REF") | |
| HEAD_SHA=$(git rev-parse HEAD) | |
| BASE_SHA=$(git merge-base "$BASE_TIP" "$HEAD_SHA") | |
| echo "base_sha=$BASE_SHA" >> "$GITHUB_OUTPUT" | |
| echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT" | |
| echo "base_tip=$BASE_TIP merge_base=$BASE_SHA head=$HEAD_SHA" | |
| - name: Check lessons-vs-regression-tests contract | |
| env: | |
| LESSONS_FILES_RAW: ${{ inputs.lessons_files || '' }} | |
| LESSONS_HEADER: ${{ inputs.lessons_section_header || '## Lessons' }} | |
| REGRESSION_DIR: ${{ inputs.regression_dir || 'tests/regression' }} | |
| BASE_SHA: ${{ steps.refs.outputs.base_sha }} | |
| HEAD_SHA: ${{ steps.refs.outputs.head_sha }} | |
| PR_AUTHOR: ${{ github.event.pull_request.user.login }} | |
| PR_HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} | |
| run: | | |
| set -euo pipefail | |
| # Skip for bot-authored PRs — bots can't add paired regression tests. | |
| # Covers: github-actions[bot], dependabot[bot], renovate[bot], etc. | |
| # Also skip for the coverage-floor seed branch: it diffs against a | |
| # potentially-stale base, which can make CLAUDE.md Lessons bullets | |
| # appear removed then re-added as a false-positive diff. | |
| if [[ "$PR_AUTHOR" == *"[bot]" ]]; then | |
| echo "skipping convention check: bot-authored PR (${PR_AUTHOR}) — bots can't add paired regression tests" | |
| exit 0 | |
| fi | |
| if [[ "$PR_HEAD_BRANCH" == chore/coverage-floor-seed* ]]; then | |
| echo "skipping convention check: coverage-floor seed branch (${PR_HEAD_BRANCH}) — stale-base diffs are expected" | |
| exit 0 | |
| fi | |
| if [[ -z "$LESSONS_FILES_RAW" ]]; then | |
| echo "lessons_files is empty — no Lessons section tracked in this repo. Skipping." | |
| exit 0 | |
| fi | |
| # Extract the section from `^${header}` to the next `^## ` or EOF. | |
| extract_section() { | |
| local content="$1" | |
| local header="$2" | |
| awk -v h="$header" ' | |
| $0 == h { p = 1; next } | |
| /^## / { if (p) exit } | |
| p { print } | |
| ' <<<"$content" | |
| } | |
| IFS=, read -ra FILES <<<"$LESSONS_FILES_RAW" | |
| LESSONS_CHANGED_IN=() | |
| for raw_path in "${FILES[@]}"; do | |
| # Trim whitespace around comma-split entries. | |
| path=$(echo "$raw_path" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') | |
| [[ -z "$path" ]] && continue | |
| # `git show <sha>:<path>` errors if file doesn't exist at that sha. | |
| # Treat "not present at base" as empty (new file = additive change). | |
| BASE_CONTENT=$(git show "$BASE_SHA:$path" 2>/dev/null || echo "") | |
| HEAD_CONTENT=$(git show "$HEAD_SHA:$path" 2>/dev/null || echo "") | |
| BASE_SECTION=$(extract_section "$BASE_CONTENT" "$LESSONS_HEADER") | |
| HEAD_SECTION=$(extract_section "$HEAD_CONTENT" "$LESSONS_HEADER") | |
| if [[ "$BASE_SECTION" != "$HEAD_SECTION" ]]; then | |
| LESSONS_CHANGED_IN+=("$path") | |
| echo "lessons section changed in: $path" | |
| fi | |
| done | |
| if [[ ${#LESSONS_CHANGED_IN[@]} -eq 0 ]]; then | |
| echo "PASS: no lessons-section changes detected in [${LESSONS_FILES_RAW}]" | |
| exit 0 | |
| fi | |
| # Lessons changed. Now check whether a regression test was added or | |
| # modified under $REGRESSION_DIR. | |
| CHANGED_TEST_FILES=$(git diff --name-only --diff-filter=AM "$BASE_SHA..$HEAD_SHA" -- "$REGRESSION_DIR/" || true) | |
| if [[ -n "$CHANGED_TEST_FILES" ]]; then | |
| echo "PASS: lessons changed in [${LESSONS_CHANGED_IN[*]}], and PR adds/modifies regression test(s):" | |
| echo "$CHANGED_TEST_FILES" | sed 's/^/ /' | |
| exit 0 | |
| fi | |
| # FAIL. | |
| { | |
| echo "::error::PR modifies '${LESSONS_HEADER}' in [${LESSONS_CHANGED_IN[*]}] but does NOT add or modify a file under ${REGRESSION_DIR}/." | |
| echo "::error::Either:" | |
| echo "::error:: (a) add a regression test in ${REGRESSION_DIR}/ that would have caught the bug the lesson is about, or" | |
| echo "::error:: (b) remove the lesson edit from this PR and ship it separately with the test." | |
| echo "::error::Pure deletions/prunings of lessons bullets are fine — but this PR adds or modifies bullet content." | |
| } | |
| exit 1 |