-
-
Notifications
You must be signed in to change notification settings - Fork 0
202 lines (182 loc) · 10 KB
/
Copy pathpropagate-changes.yml
File metadata and controls
202 lines (182 loc) · 10 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
name: Propagate Changes Between Branches
on:
workflow_run:
workflows: ["Docker Build, Publish & Test"]
types: [completed]
branches: [ main, development ]
# NOTE for future contributors: do not add a direct `push:` trigger here as
# a workflow_run-reliability fallback without ALSO updating the job-level
# `if:` below. The CURRENT_BRANCH/CURRENT_SHA env fallbacks (`|| github.ref_name`
# / `|| github.sha`) already resolve correctly on a `push` event, but the
# `if:` only recognizes `workflow_dispatch` or a matching `workflow_run` —
# it has no `github.event_name == 'push'` clause, so a bare `push:` addition
# would silently no-op the job on every push (worse than today: it would
# show as a registered trigger in the Actions UI while never actually
# running). workflow_run is also semantically correct here regardless: this
# job should only propagate a commit Docker Build has already validated as
# successful, which a raw `push` event can't guarantee. The existing
# `workflow_dispatch: {}` below (added in 5245858a) is the intended manual
# fallback if workflow_run ever silently fails to fire again.
workflow_dispatch: {}
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.ref }}
cancel-in-progress: false
env:
NODE_VERSION: '24.19.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_name == 'workflow_dispatch' ||
(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@820762786026740c76f36085b0efc47a31fe5020 # v7
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 }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CHARON_TOKEN: ${{ secrets.CHARON_TOKEN }}
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) {
const preview = files.slice(0, 25).join(', ');
const suffix = files.length > 25 ? ` …(+${files.length - 25} more)` : '';
core.info(`${src} -> ${base} contains sensitive changes (${preview}${suffix}). 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') {
core.info('Push to development detected. No downstream propagation configured.');
}