name: Propagate Changes Between Branches on: workflow_run: workflows: ["Docker Build, Publish & Test"] types: [completed] branches: [ main, development ] concurrency: group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.ref }} cancel-in-progress: false env: NODE_VERSION: '24.12.0' permissions: contents: write pull-requests: write issues: write jobs: propagate: name: Create PR to synchronize branches runs-on: ubuntu-latest if: >- github.actor != 'github-actions[bot]' && github.event.workflow_run.conclusion == 'success' && (github.event.workflow_run.head_branch == 'main' || github.event.workflow_run.head_branch == 'development') steps: - name: Set up Node (for github-script) uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version: ${{ env.NODE_VERSION }} - name: Propagate Changes uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: CURRENT_BRANCH: ${{ github.event.workflow_run.head_branch || github.ref_name }} CURRENT_SHA: ${{ github.event.workflow_run.head_sha || github.sha }} with: script: | const currentBranch = process.env.CURRENT_BRANCH || context.ref.replace('refs/heads/', ''); let excludedBranch = null; // Loop Prevention: Identify if this commit is from a merged PR try { const associatedPRs = await github.rest.repos.listPullRequestsAssociatedWithCommit({ owner: context.repo.owner, repo: context.repo.repo, commit_sha: process.env.CURRENT_SHA || context.sha, }); // If the commit comes from a PR, we identify the source branch // so we don't try to merge changes back into it immediately. if (associatedPRs.data.length > 0) { excludedBranch = associatedPRs.data[0].head.ref; core.info(`Commit ${process.env.CURRENT_SHA || context.sha} is associated with PR #${associatedPRs.data[0].number} coming from '${excludedBranch}'. This branch will be excluded from propagation to prevent loops.`); } } catch (err) { core.warning(`Failed to check associated PRs: ${err.message}`); } async function createPR(src, base) { if (src === base) return; core.info(`Checking propagation from ${src} to ${base}...`); // Check for existing open PRs const { data: pulls } = await github.rest.pulls.list({ owner: context.repo.owner, repo: context.repo.repo, state: 'open', head: `${context.repo.owner}:${src}`, base: base, }); if (pulls.length > 0) { core.info(`Existing PR found for ${src} -> ${base}. Skipping.`); return; } // Compare commits to see if src is ahead of base try { const compare = await github.rest.repos.compareCommits({ owner: context.repo.owner, repo: context.repo.repo, base: base, head: src, }); // If src is not ahead, nothing to merge if (compare.data.ahead_by === 0) { core.info(`${src} is not ahead of ${base}. No propagation needed.`); return; } // If files changed include history-rewrite or other sensitive scripts, // avoid automatic propagation. This prevents bypassing checklist validation // and manual review for potentially destructive changes. let files = (compare.data.files || []).map(f => (f.filename || '').toLowerCase()); // Fallback: if compare.files is empty/truncated, aggregate files from the commit list if (files.length === 0 && Array.isArray(compare.data.commits) && compare.data.commits.length > 0) { for (const commit of compare.data.commits) { const commitData = await github.rest.repos.getCommit({ owner: context.repo.owner, repo: context.repo.repo, ref: commit.sha }); for (const f of (commitData.data.files || [])) { files.push((f.filename || '').toLowerCase()); } } files = Array.from(new Set(files)); } // Load propagation config (list of sensitive paths) from .github/propagate-config.yml when available // NOTE: .github/workflows/ was removed from defaults - workflow updates SHOULD propagate // to ensure downstream branches have correct CI/CD configurations let configPaths = ['scripts/history-rewrite/', 'data/backups', 'docs/plans/history_rewrite.md']; try { const configResp = await github.rest.repos.getContent({ owner: context.repo.owner, repo: context.repo.repo, path: '.github/propagate-config.yml', ref: src }); const contentStr = Buffer.from(configResp.data.content, 'base64').toString('utf8'); const lines = contentStr.split(/\r?\n/); let inSensitive = false; const parsedPaths = []; for (const line of lines) { const trimmed = line.trim(); if (!inSensitive && trimmed.startsWith('sensitive_paths:')) { inSensitive = true; continue; } if (inSensitive) { if (trimmed.startsWith('-')) parsedPaths.push(trimmed.substring(1).trim()); else if (trimmed.length === 0) continue; else break; } } if (parsedPaths.length > 0) configPaths = parsedPaths.map(p => p.toLowerCase()); } catch (err) { core.info('No .github/propagate-config.yml or parse failure; using defaults.'); } const sensitive = files.some(fn => configPaths.some(sp => fn.startsWith(sp) || fn.includes(sp))); if (sensitive) { core.info(`${src} -> ${base} contains sensitive changes (${files.join(', ')}). Skipping automatic propagation.`); return; } } catch (error) { // If base branch doesn't exist, etc. core.warning(`Error comparing ${src} to ${base}: ${error.message}`); return; } // Create PR try { const pr = await github.rest.pulls.create({ owner: context.repo.owner, repo: context.repo.repo, title: `Propagate changes from ${src} into ${base}`, head: src, base: base, body: `Automated PR to propagate changes from ${src} into ${base}.\n\nTriggered by push to ${currentBranch}.`, draft: true, }); core.info(`Created PR #${pr.data.number} to merge ${src} into ${base}`); // Add an 'auto-propagate' label to the created PR and create the label if missing try { try { await github.rest.issues.getLabel({ owner: context.repo.owner, repo: context.repo.repo, name: 'auto-propagate' }); } catch (e) { await github.rest.issues.createLabel({ owner: context.repo.owner, repo: context.repo.repo, name: 'auto-propagate', color: '7dd3fc', description: 'Automatically created propagate PRs' }); } await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: pr.data.number, labels: ['auto-propagate'] }); } catch (labelErr) { core.warning('Failed to ensure or add auto-propagate label: ' + labelErr.message); } } catch (error) { core.warning(`Failed to create PR from ${src} to ${base}: ${error.message}`); } } if (currentBranch === 'main') { // Main -> Development // Only propagate if development is not the source (loop prevention) if (excludedBranch !== 'development') { await createPR('main', 'development'); } else { core.info('Push originated from development (excluded). Skipping propagation back to development.'); } } else if (currentBranch === 'development') { // Development -> Feature/Hotfix branches (The Pittsburgh Model) // We propagate changes from dev DOWN to features/hotfixes so they stay up to date. const branches = await github.paginate(github.rest.repos.listBranches, { owner: context.repo.owner, repo: context.repo.repo, }); // Filter for feature/* and hotfix/* branches using regex // AND exclude the branch that just got merged in (if any) const targetBranches = branches .map(b => b.name) .filter(name => { const isTargetType = /^feature\/|^hotfix\//.test(name); const isExcluded = (name === excludedBranch); return isTargetType && !isExcluded; }); core.info(`Found ${targetBranches.length} target branches (excluding '${excludedBranch || 'none'}'): ${targetBranches.join(', ')}`); for (const targetBranch of targetBranches) { await createPR('development', targetBranch); } } env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CHARON_TOKEN: ${{ secrets.CHARON_TOKEN }}