Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
287 changes: 287 additions & 0 deletions .github/workflows/maint-71-merge-sync-prs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
# Automate merging sync PRs in consumer repos
#
# This workflow handles the common cycle of:
# 1. Check sync PR status in all consumer repos
# 2. Merge PRs that pass validation
# 3. Clean up stale branches
#
# Triggers:
# - Manual dispatch (default)
# - Can be called from sync workflow
#
# Safety:
# - Only merges PRs with sync/workflows-* branch names
# - Requires all checks to pass
# - Uses merge commit (not squash) to preserve sync history

name: Merge Sync PRs

on:
workflow_dispatch:
inputs:
repos:
description: "Repos to check (comma-separated, or 'all')"
required: false
type: string
default: "all"
auto_merge:
description: "Auto-merge if checks pass"
required: false
type: boolean
default: true
dry_run:
description: "Dry run (report only, no merge)"
required: false
type: boolean
default: false
workflow_call:
inputs:
repos:
description: "Repos to check (comma-separated, or 'all')"
required: false
type: string
default: "all"
auto_merge:
description: "Auto-merge if checks pass"
required: false
type: boolean
default: true
dry_run:
description: "Dry run (report only, no merge)"
required: false
type: boolean
default: false

permissions:
contents: read

jobs:
merge_sync_prs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Extract consumer repos from sync workflow
id: repos
run: |
# Read REGISTERED_CONSUMER_REPOS from maint-68-sync-consumer-repos.yml
repos=$(yq eval '.env.REGISTERED_CONSUMER_REPOS' \
.github/workflows/maint-68-sync-consumer-repos.yml | \
grep -v '^#' | grep -v '^$' | sed 's/stranske\///' | tr '\n' ',' | sed 's/,$//')
echo "list=${repos}" >> "$GITHUB_OUTPUT"
echo "Extracted repos: ${repos}"

- name: Check and merge sync PRs
uses: actions/github-script@v7
with:
github-token: ${{ secrets.CODESPACES_WORKFLOWS || secrets.GITHUB_TOKEN }}
script: |
const owner = context.repo.owner;
const inputRepos = '${{ inputs.repos }}' || 'all';
const autoMerge = ${{ inputs.auto_merge }};
const dryRun = ${{ inputs.dry_run }};

// Parse repos from previous step
const registeredRepos = '${{ steps.repos.outputs.list }}'.split(',').map(r => r.trim());

// Determine which repos to process
const targetRepos = inputRepos === 'all'
? registeredRepos
: inputRepos.split(',').map(r => r.trim());

console.log(`Registered consumer repos: ${registeredRepos.join(', ')}`);
console.log(`Processing repos: ${targetRepos.join(', ')}`);
console.log(`Auto-merge: ${autoMerge}, Dry run: ${dryRun}\n`);

const results = [];

for (const repo of targetRepos) {
console.log(`\n=== ${repo} ===`);

try {
// Find open sync PRs
const { data: prs } = await github.rest.pulls.list({
owner,
repo,
state: 'open',
per_page: 20
});

const syncPRs = prs.filter(pr => pr.head.ref.startsWith('sync/workflows-'));

if (syncPRs.length === 0) {
console.log('No sync PRs found');
results.push({ repo, status: 'no_prs' });
continue;
}

// Sort by created date, oldest first (for stale cleanup)
syncPRs.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));

// If multiple sync PRs exist, close older ones as stale
if (syncPRs.length > 1) {
console.log(`Found ${syncPRs.length} sync PRs - closing ${syncPRs.length - 1} stale PRs`);

for (let i = 0; i < syncPRs.length - 1; i++) {
const stalePR = syncPRs[i];
console.log(`\nClosing stale PR #${stalePR.number}: ${stalePR.title}`);
console.log(`Branch: ${stalePR.head.ref}, Created: ${stalePR.created_at}`);

if (!dryRun) {
// Close PR
await github.rest.pulls.update({
owner,
repo,
pull_number: stalePR.number,
state: 'closed'
});
console.log('✓ Closed');

// Delete branch
try {
await github.rest.git.deleteRef({
owner,
repo,
ref: `heads/${stalePR.head.ref}`
});
console.log('✓ Branch deleted');
} catch (delErr) {
console.log(`⚠ Branch delete failed: ${delErr.message}`);
}
} else {
console.log('[DRY RUN] Would close and delete branch');
}
}
}

// Process the most recent PR
const pr = syncPRs[syncPRs.length - 1];
console.log(`\nProcessing most recent PR #${pr.number}: ${pr.title}`);
console.log(`Branch: ${pr.head.ref}`);
console.log(`Created: ${pr.created_at}`);

// Check PR status
const { data: combinedStatus } = await github.rest.repos.getCombinedStatusForRef({
owner,
repo,
ref: pr.head.sha
});

// Check check runs
const { data: checkRuns } = await github.rest.checks.listForRef({
owner,
repo,
ref: pr.head.sha
});

const allChecks = checkRuns.check_runs || [];
const requiredChecks = allChecks.filter(c =>
!c.name.includes('Detect keepalive') &&
!c.name.includes('pr_meta') &&
!c.name.includes('resolve_pr') &&
!c.name.includes('Cleanup') &&
c.conclusion !== 'skipped' &&
c.conclusion !== 'neutral'
);

const failedChecks = requiredChecks.filter(c =>
c.conclusion !== 'success' && c.conclusion !== null
);

const pendingChecks = requiredChecks.filter(c =>
c.status !== 'completed'
);

console.log(`Checks: ${requiredChecks.length} total, ${failedChecks.length} failed, ${pendingChecks.length} pending`);

if (failedChecks.length > 0) {
console.log('Failed checks:');
failedChecks.forEach(c => console.log(` - ${c.name}: ${c.conclusion}`));
results.push({ repo, pr: pr.number, status: 'checks_failed' });
continue;
}

if (pendingChecks.length > 0) {
console.log('Waiting for checks to complete');
results.push({ repo, pr: pr.number, status: 'checks_pending' });
continue;
}

// All checks passed
if (!autoMerge) {
console.log('✓ Ready to merge (auto-merge disabled)');
results.push({ repo, pr: pr.number, status: 'ready' });
continue;
}

if (dryRun) {
console.log('✓ Would merge (dry run)');
results.push({ repo, pr: pr.number, status: 'dry_run_merge' });
continue;
}

// Merge the PR
try {
await github.rest.pulls.merge({
owner,
repo,
pull_number: pr.number,
merge_method: 'merge',
commit_title: pr.title,
commit_message: `Automated merge of sync PR\n\nSync hash: ${pr.head.ref.split('-').pop()}`
});

console.log('✓ Merged successfully');

// Delete the branch
try {
await github.rest.git.deleteRef({
owner,
repo,
ref: `heads/${pr.head.ref}`
});
console.log('✓ Branch deleted');
} catch (e) {
console.log(`⚠ Could not delete branch: ${e.message}`);
}

results.push({ repo, pr: pr.number, status: 'merged' });
} catch (e) {
console.log(`✗ Merge failed: ${e.message}`);
results.push({ repo, pr: pr.number, status: 'merge_failed', error: e.message });
}
} catch (e) {
console.log(`✗ Error processing ${repo}: ${e.message}`);
results.push({ repo, status: 'error', error: e.message });
}
}

// Summary
console.log('\n=== Summary ===');
console.log(JSON.stringify(results, null, 2));

const merged = results.filter(r => r.status === 'merged').length;
const stale = results.filter(r => r.status === 'stale_closed').length;
const failed = results.filter(r => r.status === 'checks_failed' || r.status === 'merge_failed').length;
const pending = results.filter(r => r.status === 'checks_pending').length;
const ready = results.filter(r => r.status === 'ready').length;

console.log(`\nMerged: ${merged}`);
console.log(`Stale closed: ${stale}`);
console.log(`Failed: ${failed}`);
console.log(`Pending: ${pending}`);
console.log(`Ready (not auto-merged): ${ready}`);

if (failed > 0) {
core.setFailed(`${failed} PRs failed to merge`);
}

- name: Post summary
if: always()
run: |
echo "### Sync PR Merge Summary" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Processed repos: ${{ inputs.repos || 'all' }}" >> "$GITHUB_STEP_SUMMARY"
echo "Auto-merge: ${{ inputs.auto_merge }}" >> "$GITHUB_STEP_SUMMARY"
echo "Dry run: ${{ inputs.dry_run }}" >> "$GITHUB_STEP_SUMMARY"
79 changes: 79 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,55 @@ Secrets use **lowercase** in `workflow_call` definitions but reference org secre
4. **Run pre-sync validation** to ensure files pass consumer lint rules
5. **Sync to ALL consumer repos** to maintain consistency

## ⚠️ CRITICAL: Agent Bot Review Comments

**BEFORE merging any PR, check for and address ALL agent bot comments.**

Agent bots (like `copilot-pull-request-reviewer`) analyze code and flag:
- Standards violations (line length, encoding, etc.)
- Logic errors (redundant conditions, incorrect defaults)
- Best practices (cross-platform compatibility, error handling)
- Repository inconsistencies (mismatched configs)

**These comments are NOT suggestions - they are issues that MUST be fixed.**

### Process for Bot Comments

```bash
# 1. Check PR for bot comments
gh pr view <PR_NUMBER> --repo stranske/Workflows --comments

# 2. For each unresolved comment:
# - Evaluate if valid (assume yes unless proven wrong)
# - Implement the fix
# - Test the change
# - Commit with clear explanation

# 3. Do NOT merge until all bot comments are resolved or explicitly dismissed with justification

# 4. If bot suggests code change, USE the suggested code unless there's a technical reason not to
```

### Why This Matters

- Bot comments often catch issues that break consumer repos
- One ignored comment = potential bugs in 7+ repos after sync
- Bots enforce repository standards (line-length, encoding, etc.)
- Standards violations fail CI in consumer repos

### Examples of Critical Bot Catches

- Wrong default parameter values that don't match repo config
- Missing encoding specifications that cause Windows failures
- Redundant logic that indicates misunderstanding
- Line length violations that fail linter checks

**If you disagree with a bot comment:**
1. Explain why in PR comment
2. Tag the bot comment as "won't fix" with justification
3. Document the decision in code comments if needed
4. Do NOT silently ignore

## ⚠️ CRITICAL: Template Changes (READ THIS!)

**If you modify `templates/consumer-repo/` YOU WILL SYNC TO ALL CONSUMER REPOS.**
Expand All @@ -177,6 +226,36 @@ Repo standards (from pyproject.toml):
- Format: black, ruff, isort
- All templates must pass validation before commit

## ⚠️ CRITICAL: New Workflow Artifact Check

**BEFORE adding any new workflow, check if it creates files that should be in .gitignore:**

```bash
# 1. Review workflow for file creation
grep -E "write|create|output|artifact" .github/workflows/your-workflow.yml

# 2. Check if workflow writes to working directory
# Look for: >, >>, tee, echo >>, python open(), write_file, etc.

# 3. Common artifacts that MUST be in .gitignore:
# - Status files: *-status.json, *-report.json, *-summary.json
# - Temp files: *.tmp, *.temp, .cache/, tmp/
# - Agent files: codex-output.md, verifier-context.md
# - Build artifacts: dist/, build/, .artifacts/

# 4. Test the workflow and check git status
gh workflow run your-workflow.yml
# Wait for completion, then:
git status --ignored

# 5. Add any new artifacts to .gitignore BEFORE syncing templates
```

**Why this matters:**
- Workflows that create tracked files → merge conflicts in consumer repos
- Auto-generated files in git → hours of debugging conflict resolution
- One forgotten artifact file → 7+ repos with conflicts

## Quick Commands

```bash
Expand Down
Loading
Loading