209 lines
9.9 KiB
YAML
209 lines
9.9 KiB
YAML
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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
|
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 }}
|