name: Propagate Changes Between Branches on: push: branches: - main - development concurrency: group: ${{ github.workflow }}-${{ 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.pusher != null steps: - name: Set up Node (for github-script) uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 with: node-version: ${{ env.NODE_VERSION }} - name: Propagate Changes uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: script: | const currentBranch = context.ref.replace('refs/heads/', ''); 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 let configPaths = ['scripts/history-rewrite/', 'data/backups', 'docs/plans/history_rewrite.md', '.github/workflows/']; 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 await createPR('main', 'development'); } else if (currentBranch === 'development') { // Development -> Feature branches (direct, no nightly intermediary) const branches = await github.paginate(github.rest.repos.listBranches, { owner: context.repo.owner, repo: context.repo.repo, }); const featureBranches = branches .map(b => b.name) .filter(name => name.startsWith('feature/')); core.info(`Found ${featureBranches.length} feature branches: ${featureBranches.join(', ')}`); for (const featureBranch of featureBranches) { await createPR('development', featureBranch); } } env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CHARON_TOKEN: ${{ secrets.CHARON_TOKEN }}