Skip to content

Commit cfb3afa

Browse files
committed
reverse-config-label-sync functionality
1 parent 206f8b3 commit cfb3afa

4 files changed

Lines changed: 321 additions & 3 deletions

File tree

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
name: Reverse-Config-Label-Sync
2+
3+
on:
4+
workflow_run:
5+
workflows:
6+
- Validate-Configs
7+
types:
8+
- completed
9+
10+
permissions:
11+
contents: read
12+
13+
jobs:
14+
reverse-sync:
15+
if: >-
16+
${{
17+
github.event.workflow_run.conclusion == 'success' &&
18+
github.event.workflow_run.event == 'push' &&
19+
github.event.workflow_run.head_branch == github.event.repository.default_branch
20+
}}
21+
runs-on: ubuntu-latest
22+
23+
steps:
24+
- name: Check out validated commit
25+
uses: actions/checkout@v4
26+
with:
27+
ref: ${{ github.event.workflow_run.head_sha }}
28+
fetch-depth: 2
29+
30+
- name: Check triggering commit
31+
id: trigger
32+
shell: bash
33+
run: |
34+
author_name="$(git show -s --format='%an' HEAD)"
35+
author_email="$(git show -s --format='%ae' HEAD)"
36+
committer_name="$(git show -s --format='%cn' HEAD)"
37+
committer_email="$(git show -s --format='%ce' HEAD)"
38+
changed_files="$(git diff-tree --no-commit-id --name-only -r -m HEAD)"
39+
40+
is_bot_commit=false
41+
case "${author_name}|${author_email}|${committer_name}|${committer_email}" in
42+
*"[bot]"*|*"github-actions[bot]"*|*"41898282+github-actions[bot]@users.noreply.github.com"*)
43+
is_bot_commit=true
44+
;;
45+
esac
46+
47+
has_config_change=false
48+
if echo "${changed_files}" | grep -q '^config/'; then
49+
has_config_change=true
50+
fi
51+
52+
echo "Commit author: ${author_name} <${author_email}>"
53+
echo "Commit committer: ${committer_name} <${committer_email}>"
54+
echo "Config files changed: ${has_config_change}"
55+
echo "Bot commit: ${is_bot_commit}"
56+
echo "has_config_change=${has_config_change}" >> "${GITHUB_OUTPUT}"
57+
echo "is_bot_commit=${is_bot_commit}" >> "${GITHUB_OUTPUT}"
58+
59+
- name: Set up Node.js
60+
if: ${{ steps.trigger.outputs.has_config_change == 'true' && steps.trigger.outputs.is_bot_commit != 'true' }}
61+
uses: actions/setup-node@v4
62+
with:
63+
node-version: "20"
64+
65+
- name: Load properties
66+
if: ${{ steps.trigger.outputs.has_config_change == 'true' && steps.trigger.outputs.is_bot_commit != 'true' }}
67+
id: properties
68+
env:
69+
GITHUB_REPOSITORY: ${{ github.repository }}
70+
run: node scripts/export-properties.mjs
71+
72+
- name: Validate config
73+
if: ${{ steps.trigger.outputs.has_config_change == 'true' && steps.trigger.outputs.is_bot_commit != 'true' }}
74+
run: node scripts/reverse-config-label-sync.mjs --validate-only
75+
76+
- name: Sync config into source repo labels
77+
if: ${{ steps.trigger.outputs.has_config_change == 'true' && steps.trigger.outputs.is_bot_commit != 'true' }}
78+
env:
79+
LABEL_SYNC_TOKEN: ${{ secrets[steps.properties.outputs.label_sync_token_secret_name] }}
80+
SOURCE_REPOSITORY: ${{ steps.properties.outputs.source_repository }}
81+
run: node scripts/reverse-config-label-sync.mjs

README.md

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,13 @@ Its job is to:
1212

1313
## How It Works
1414

15-
This repo uses four workflows:
15+
This repo uses five workflows:
1616

1717
1. `Config-Label_Sync`
1818
2. `Validate-Configs`
19-
3. `Org-Label-Sync`
20-
4. `Remove-Labels`
19+
3. `Reverse-Config-Label-Sync`
20+
4. `Org-Label-Sync`
21+
5. `Remove-Labels`
2122

2223
The normal flow is:
2324

@@ -28,6 +29,13 @@ The normal flow is:
2829
5. That config change triggers `Validate-Configs`.
2930
6. `Org-Label-Sync` then checks out the latest default branch, validates the config again, and syncs labels across the organization.
3031

32+
The reverse flow is:
33+
34+
1. You make a non-bot commit to `config/**` on the default branch.
35+
2. `Validate-Configs` runs for that commit.
36+
3. If validation passes, `Reverse-Config-Label-Sync` updates the configured source repository labels from `config/labels.jsonc`.
37+
4. Bot commits are ignored, so `Config-Label_Sync` can update `labels.jsonc` without triggering a reverse sync loop.
38+
3139
## Repository Layout
3240

3341
```text
@@ -37,6 +45,7 @@ The normal flow is:
3745
| |-- config-label-sync.yml
3846
| |-- org-label-sync.yml
3947
| |-- remove-labels.yml
48+
| |-- reverse-config-label-sync.yml
4049
| `-- validate-configs.yml
4150
|-- config/
4251
| |-- auto-pruned-labels.jsonc
@@ -49,6 +58,7 @@ The normal flow is:
4958
`-- scripts/
5059
|-- export-properties.mjs
5160
|-- remove-labels.mjs
61+
|-- reverse-config-label-sync.mjs
5262
|-- sync-config-labels.mjs
5363
|-- sync-labels.mjs
5464
`-- lib/
@@ -218,6 +228,26 @@ Validation includes:
218228
- exact auto-pruned label shape validation
219229
- validation for the shared config used by `Remove-Labels`
220230

231+
### `Reverse-Config-Label-Sync`
232+
233+
File: `.github/workflows/reverse-config-label-sync.yml`
234+
235+
Trigger:
236+
237+
- after `Validate-Configs` completes successfully for a push to the default branch
238+
239+
What it does:
240+
241+
1. Checks out the exact commit that passed validation
242+
2. Skips the run unless the triggering commit changed `config/**`
243+
3. Skips the run when the triggering commit author or committer is a bot
244+
4. Loads shared settings from `config/properties.jsonc`
245+
5. Validates the config again as a local guard
246+
6. Creates or updates source repository labels from `config/labels.jsonc`
247+
7. Deletes exact auto-pruned labels and any other source repository labels that are not in `config/labels.jsonc`
248+
249+
This workflow is the bridge from "the managed config was changed by a person" back to "the source repository label settings should now match that config."
250+
221251
### `Org-Label-Sync`
222252

223253
File: `.github/workflows/org-label-sync.yml`
@@ -304,6 +334,7 @@ That token needs enough access to:
304334
- read and update labels on the source repository
305335
- discover repositories in the organization
306336
- read and update labels on target repositories
337+
- read and update labels on the source repository when running `Reverse-Config-Label-Sync`
307338
- read and update issues and pull requests when running `Remove-Labels`
308339
- push config updates back to this repository when `Config-Label_Sync` changes `labels.jsonc`
309340
- push changelog commits back to this repository when an action changes another repository
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import path from "node:path";
2+
import {
3+
assert,
4+
labelsExactlyMatch,
5+
normalizeColor,
6+
normalizeDescription,
7+
normalizeName,
8+
readJsonc,
9+
} from "./lib/config-utils.mjs";
10+
import {
11+
validateDeleteLabels,
12+
validateLabels,
13+
validateProperties,
14+
} from "./lib/config-validation.mjs";
15+
16+
const workspaceRoot = process.cwd();
17+
const propertiesPath = path.join(workspaceRoot, "config", "properties.jsonc");
18+
const labelsPath = path.join(workspaceRoot, "config", "labels.jsonc");
19+
const autoPrunedLabelsPath = path.join(workspaceRoot, "config", "auto-pruned-labels.jsonc");
20+
21+
const validateOnly = process.argv.includes("--validate-only");
22+
const dryRun = validateOnly || process.env.DRY_RUN === "true";
23+
24+
async function githubRequest(token, method, apiPath, body) {
25+
const response = await fetch(`https://api.github.com${apiPath}`, {
26+
method,
27+
headers: {
28+
Accept: "application/vnd.github+json",
29+
Authorization: `Bearer ${token}`,
30+
"User-Agent": "reverse-config-label-sync",
31+
"X-GitHub-Api-Version": "2022-11-28",
32+
},
33+
body: body ? JSON.stringify(body) : undefined,
34+
});
35+
36+
if (!response.ok) {
37+
const message = await response.text();
38+
throw new Error(`${method} ${apiPath} failed with ${response.status}: ${message}`);
39+
}
40+
41+
if (response.status === 204) {
42+
return null;
43+
}
44+
45+
return response.json();
46+
}
47+
48+
async function getAllLabels(token, repo) {
49+
const labels = [];
50+
let page = 1;
51+
52+
while (true) {
53+
const batch = await githubRequest(token, "GET", `/repos/${repo}/labels?per_page=100&page=${page}`);
54+
labels.push(...batch);
55+
56+
if (batch.length < 100) {
57+
return labels;
58+
}
59+
60+
page += 1;
61+
}
62+
}
63+
64+
function summarizeLabelDiff(existing, desired) {
65+
if (!existing) {
66+
return "create";
67+
}
68+
69+
const sameColor = normalizeColor(existing.color) === desired.color;
70+
const sameDescription = normalizeDescription(existing.description) === desired.description;
71+
const sameName = existing.name === desired.name;
72+
73+
if (sameColor && sameDescription && sameName) {
74+
return "unchanged";
75+
}
76+
77+
return "update";
78+
}
79+
80+
function isExactAutoPrunedLabel(label, deleteLabels) {
81+
return deleteLabels.some((deleteLabel) => labelsExactlyMatch(label, deleteLabel));
82+
}
83+
84+
async function syncSourceRepository(token, repository, desiredLabels, deleteLabels) {
85+
console.log(`Syncing ${repository} labels from config`);
86+
const existingLabels = await getAllLabels(token, repository);
87+
const existingByName = new Map(existingLabels.map((label) => [normalizeName(label.name), label]));
88+
const desiredKeys = new Set(desiredLabels.map((label) => normalizeName(label.name)));
89+
90+
let created = 0;
91+
let updated = 0;
92+
let deletedConfigured = 0;
93+
let deletedMissing = 0;
94+
let unchanged = 0;
95+
96+
for (const desired of desiredLabels) {
97+
const existing = existingByName.get(normalizeName(desired.name));
98+
const action = summarizeLabelDiff(existing, desired);
99+
100+
if (action === "unchanged") {
101+
unchanged += 1;
102+
console.log(` = ${desired.name}`);
103+
continue;
104+
}
105+
106+
if (action === "create") {
107+
created += 1;
108+
console.log(` + ${desired.name}`);
109+
110+
if (!dryRun) {
111+
await githubRequest(token, "POST", `/repos/${repository}/labels`, desired);
112+
}
113+
114+
continue;
115+
}
116+
117+
updated += 1;
118+
console.log(` ~ ${desired.name}`);
119+
120+
if (!dryRun) {
121+
await githubRequest(
122+
token,
123+
"PATCH",
124+
`/repos/${repository}/labels/${encodeURIComponent(existing.name)}`,
125+
desired,
126+
);
127+
}
128+
}
129+
130+
for (const existing of existingLabels) {
131+
const existingKey = normalizeName(existing.name);
132+
133+
if (desiredKeys.has(existingKey) || !isExactAutoPrunedLabel(existing, deleteLabels)) {
134+
continue;
135+
}
136+
137+
deletedConfigured += 1;
138+
console.log(` - ${existing.name} (configured delete)`);
139+
140+
if (!dryRun) {
141+
await githubRequest(
142+
token,
143+
"DELETE",
144+
`/repos/${repository}/labels/${encodeURIComponent(existing.name)}`,
145+
);
146+
}
147+
}
148+
149+
for (const existing of existingLabels) {
150+
const existingKey = normalizeName(existing.name);
151+
152+
if (desiredKeys.has(existingKey) || isExactAutoPrunedLabel(existing, deleteLabels)) {
153+
continue;
154+
}
155+
156+
deletedMissing += 1;
157+
console.log(` - ${existing.name} (not in config)`);
158+
159+
if (!dryRun) {
160+
await githubRequest(
161+
token,
162+
"DELETE",
163+
`/repos/${repository}/labels/${encodeURIComponent(existing.name)}`,
164+
);
165+
}
166+
}
167+
168+
console.log(
169+
`Summary for ${repository}: created=${created}, updated=${updated}, deletedConfigured=${deletedConfigured}, deletedMissing=${deletedMissing}, unchanged=${unchanged}`,
170+
);
171+
}
172+
173+
async function main() {
174+
const properties = validateProperties(await readJsonc(propertiesPath), {
175+
includeSourceRepository: true,
176+
defaultSourceRepository: process.env.GITHUB_REPOSITORY ?? "",
177+
});
178+
const labels = validateLabels(await readJsonc(labelsPath));
179+
const deleteLabels = validateDeleteLabels(await readJsonc(autoPrunedLabelsPath));
180+
const repository = process.env.SOURCE_REPOSITORY ?? properties.sourceRepository;
181+
assert(repository, "SOURCE_REPOSITORY or properties.sourceRepository is required.");
182+
183+
console.log(
184+
`Loaded ${labels.length} managed labels and ${deleteLabels.length} exact auto-pruned label specs for ${repository}.`,
185+
);
186+
187+
if (validateOnly) {
188+
console.log("Configuration is valid.");
189+
return;
190+
}
191+
192+
const token = process.env.LABEL_SYNC_TOKEN;
193+
assert(token, "LABEL_SYNC_TOKEN is required unless --validate-only is used.");
194+
195+
await syncSourceRepository(token, repository, labels, deleteLabels);
196+
197+
if (dryRun) {
198+
console.log("Dry-run mode did not apply label changes.");
199+
}
200+
}
201+
202+
main().catch((error) => {
203+
console.error(error.message);
204+
process.exitCode = 1;
205+
});

scripts/validate-configs.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ function runValidation(scriptName) {
2525

2626
async function main() {
2727
await runValidation("sync-labels.mjs");
28+
await runValidation("reverse-config-label-sync.mjs");
2829
await runValidation("remove-labels.mjs");
2930
}
3031

0 commit comments

Comments
 (0)