Skip to content

Commit 76d5921

Browse files
committed
Label transfer workflow
1 parent e1f10f3 commit 76d5921

4 files changed

Lines changed: 524 additions & 1 deletion

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
name: 06 - Transfer-Labels
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
dry_run:
7+
description: "[TEST MODE] Preview label transfers without applying them. Writes a preview changelog to the workflow summary."
8+
required: false
9+
type: boolean
10+
default: false
11+
override_existing:
12+
description: "Override receiving labels to match the source exactly, deleting labels absent from the source. Unchecked: only add missing labels."
13+
required: false
14+
type: boolean
15+
default: false
16+
source_repository:
17+
description: "Starting repository to copy labels from. Format: repo-name or owner/repo-name"
18+
required: true
19+
type: string
20+
target_repository:
21+
description: "Receiving repository to copy labels into. Format: repo-name or owner/repo-name"
22+
required: true
23+
type: string
24+
25+
permissions:
26+
contents: read
27+
28+
jobs:
29+
transfer-labels:
30+
runs-on: ubuntu-latest
31+
32+
steps:
33+
- name: Check out latest default branch
34+
uses: actions/checkout@v7
35+
with:
36+
ref: ${{ github.event.repository.default_branch }}
37+
38+
- name: Set up Node.js
39+
uses: actions/setup-node@v6
40+
with:
41+
node-version: "24"
42+
43+
- name: Load properties
44+
id: properties
45+
env:
46+
GITHUB_REPOSITORY: ${{ github.repository }}
47+
run: node scripts/export-properties.mjs
48+
49+
- name: Resolve PAT auth token
50+
id: pat_auth
51+
if: ${{ steps.properties.outputs.auth_mode == 'pat' }}
52+
env:
53+
AUTH_MODE: pat
54+
PAT_TOKEN: ${{ secrets[steps.properties.outputs.pat_token_secret_name] }}
55+
run: node scripts/create-github-auth-token.mjs
56+
57+
- name: Resolve GitHub App auth token
58+
id: app_auth
59+
if: ${{ steps.properties.outputs.auth_mode == 'githubApp' }}
60+
env:
61+
AUTH_MODE: githubApp
62+
GITHUB_APP_ID: ${{ secrets[steps.properties.outputs.github_app_id_secret_name] }}
63+
GITHUB_APP_PRIVATE_KEY: ${{ secrets[steps.properties.outputs.github_app_private_key_secret_name] }}
64+
GITHUB_APP_INSTALLATION_ID: ${{ secrets[steps.properties.outputs.github_app_installation_id_secret_name] }}
65+
run: node scripts/create-github-auth-token.mjs
66+
67+
- name: Transfer labels between repositories
68+
env:
69+
ORG_NAME: ${{ steps.properties.outputs.organization }}
70+
DRY_RUN: ${{ inputs.dry_run }}
71+
OVERRIDE_EXISTING: ${{ inputs.override_existing }}
72+
SOURCE_REPOSITORY: ${{ inputs.source_repository }}
73+
TARGET_REPOSITORY: ${{ inputs.target_repository }}
74+
run: node scripts/transfer-labels.mjs

README.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Features:
2222
- Write changelogs to GitHub Actions workflow summaries for real workflow changes and dry-run previews
2323
- Enforce centrally configured PR label requirements through a reusable required-check workflow
2424
- Distribute PR label-test caller workflows to selected organization repositories
25+
- Copy all labels directly between two repositories, optionally making the receiving label set match exactly
2526

2627
## How to setup
2728

@@ -59,7 +60,7 @@ The configured source repository is always skipped by repository filtering. You
5960

6061
## How to use the workflows
6162

62-
This repository includes ten operational GitHub Actions workflows:
63+
This repository includes eleven operational GitHub Actions workflows:
6364

6465
- `02 - Config-Label-Sync`
6566
- `Config-Reset`
@@ -71,6 +72,7 @@ This repository includes ten operational GitHub Actions workflows:
7172
- `Reverse-Config-Label-Sync`
7273
- `01 - Org-Label-Sync`
7374
- `04 - Remove-Labels`
75+
- `06 - Transfer-Labels`
7476

7577
### Recommended sync flow
7678

@@ -238,6 +240,27 @@ In `Direct Commit` mode, a repository whose default branch is protected is recor
238240

239241
After the workflows are merged into a target repository, make only `Label Test / label-test / label-test` required in that repository's branch protection rules. Do not require `Refresh Label Test`; it is an operational helper. The target repository's Actions policy must allow the refresher's requested `actions: write` permission so it can rerun the policy workflow.
240242

243+
### 06 - Transfer-Labels
244+
245+
Run `06 - Transfer-Labels` manually to copy all label names, colors, and descriptions directly from one repository to another.
246+
247+
Inputs, in workflow form order:
248+
249+
- `dry_run`: the first checkbox, off by default; previews the transfer in the workflow summary without modifying either repository
250+
- `override_existing`: off by default; makes the receiving repository's labels match the source exactly, including updating matching labels and deleting any labels absent from the source
251+
- `source_repository`: starting repository, as `repo-name` or `owner/repo-name`
252+
- `target_repository`: receiving repository, as `repo-name` or `owner/repo-name`
253+
254+
Short names use the organization in `config/properties.jsonc`. Full names may reference other owners when the configured token can access both repositories. The workflow uses the existing PAT or GitHub App authentication settings and needs label write access to the receiving repository.
255+
256+
With override unchecked, the workflow only creates labels whose names are missing from the receiving repository. Existing labels keep their current names, colors, and descriptions, including when the source has a matching name with different capitalization. With override checked, matching labels are updated in place to preserve their issue and pull request assignments. Labels absent from the source are deleted after all additions and updates succeed; deleting those labels also removes their existing assignments. An empty source deletes all receiving labels only when override is checked.
257+
258+
Both inputs select repositories directly, independently of the configured sync source and repository filters. The source is only read, and selecting the same repository for both inputs is rejected. Archived receiving repositories are skipped; read-only receiving repositories are skipped for live transfers but can be previewed in test mode.
259+
260+
Override mode stops before any changes if the receiving repository has a label named `.` or `..`, because those names cannot be safely addressed through the label API's URL path.
261+
262+
The workflow uses the Org-Label-Sync changelog layout in the GitHub Actions run summary, showing the source and receiving repositories, test and override settings, starting label counts, and created, updated, deleted, and retained counts. Retained labels are existing receiving labels left unchanged. Preview changelogs are marked as test-mode output. If the transfer fails partway through, the summary records completed changes and the failure; rerunning continues from the current label state.
263+
241264
### Config-Reset
242265

243266
Run `Config-Reset` manually when you want to restore selected config files to their default unconfigured versions.

scripts/transfer-labels.mjs

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import { realpathSync } from "node:fs";
2+
import { pathToFileURL } from "node:url";
3+
import { assert, labelsExactlyMatch, normalizeDescription, normalizeName } from "./lib/config-utils.mjs";
4+
import { validateLabels } from "./lib/config-validation.mjs";
5+
import { renderLabelSyncSection, writeChangelog } from "./lib/changelog-utils.mjs";
6+
import { formatRepositoryLink, getRepositorySkipReason, parseTokenPermissions } from "./lib/repository-selection.mjs";
7+
8+
function resolveRepository(value, organization, inputName) {
9+
const name = typeof value === "string" ? value.trim() : "";
10+
assert(name, `${inputName} is required.`);
11+
const fullName = name.includes("/") ? name : `${organization ?? ""}/${name}`;
12+
const parts = fullName.split("/");
13+
assert(
14+
parts.length === 2
15+
&& /^[a-zA-Z0-9][a-zA-Z0-9-]*$/.test(parts[0])
16+
&& /^[a-zA-Z0-9_.-]+$/.test(parts[1])
17+
&& parts[1] !== "." && parts[1] !== "..",
18+
`${inputName} must be a repository name in the configured organization or owner/repo.`,
19+
);
20+
return fullName;
21+
}
22+
23+
async function githubRequest(token, method, apiPath, body) {
24+
const response = await fetch(`https://api.github.com${apiPath}`, {
25+
method,
26+
headers: {
27+
Accept: "application/vnd.github+json",
28+
Authorization: `Bearer ${token}`,
29+
"User-Agent": "label-sync",
30+
"X-GitHub-Api-Version": "2022-11-28",
31+
},
32+
body: body ? JSON.stringify(body) : undefined,
33+
});
34+
if (!response.ok) {
35+
throw new Error(`${method} ${apiPath} failed with ${response.status}: ${await response.text()}`);
36+
}
37+
return response.status === 204 ? null : response.json();
38+
}
39+
40+
async function getAllLabels(token, repository) {
41+
const labels = [];
42+
for (let page = 1; ; page += 1) {
43+
const batch = await githubRequest(token, "GET", `/repos/${repository}/labels?per_page=100&page=${page}`);
44+
assert(Array.isArray(batch), `Invalid label response for ${repository}.`);
45+
labels.push(...batch);
46+
if (batch.length < 100) {
47+
return validateLabels(labels.map((label) => ({
48+
...label,
49+
description: normalizeDescription(label.description),
50+
})));
51+
}
52+
}
53+
}
54+
55+
export async function transferLabels({
56+
token,
57+
organization,
58+
sourceRepository,
59+
targetRepository,
60+
dryRun = false,
61+
overrideExisting = false,
62+
tokenPermissions = null,
63+
}) {
64+
const result = {
65+
repository: "",
66+
hasChanges: false,
67+
createdLabels: [],
68+
updatedLabels: [],
69+
deletedConfiguredLabels: [],
70+
deletedGithubDefaultLabels: [],
71+
deletedMissingLabels: [],
72+
labelReplacements: [],
73+
};
74+
const skippedRepositories = [];
75+
let sourceName;
76+
let targetName;
77+
let sourceCount = null;
78+
let initialTargetCount = null;
79+
let retainedCount = 0;
80+
let failure = null;
81+
82+
try {
83+
sourceName = resolveRepository(sourceRepository, organization, "Source repository");
84+
targetName = resolveRepository(targetRepository, organization, "Receiving repository");
85+
assert(sourceName.toLowerCase() !== targetName.toLowerCase(), "Source and receiving repositories must be different repositories.");
86+
assert(token, "LABEL_SYNC_TOKEN is required.");
87+
const source = await githubRequest(token, "GET", `/repos/${sourceName}`);
88+
const target = await githubRequest(token, "GET", `/repos/${targetName}`);
89+
assert(source.id && target.id, "GitHub did not return valid repository IDs.");
90+
assert(source.id !== target.id, "Source and receiving repositories must be different repositories.");
91+
// Use canonical names after resolving renamed/transferred repository aliases.
92+
sourceName = resolveRepository(source.full_name, organization, "Source repository");
93+
targetName = resolveRepository(target.full_name, organization, "Receiving repository");
94+
result.repository = targetName;
95+
96+
const skipReason = getRepositorySkipReason(target, { requireWriteAccess: !dryRun, tokenPermissions });
97+
if (skipReason) {
98+
skippedRepositories.push({ repository: targetName, reason: skipReason });
99+
return result;
100+
}
101+
102+
// Read and validate both complete label sets before making any changes.
103+
const sourceLabels = await getAllLabels(token, sourceName);
104+
sourceCount = sourceLabels.length;
105+
const targetLabels = await getAllLabels(token, targetName);
106+
initialTargetCount = targetLabels.length;
107+
if (overrideExisting) {
108+
for (const label of targetLabels) {
109+
assert(
110+
label.name !== "." && label.name !== "..",
111+
`Receiving repository label "${label.name}" cannot be safely addressed by the GitHub API.`,
112+
);
113+
}
114+
}
115+
const targetByName = new Map(targetLabels.map((label) => [normalizeName(label.name), label]));
116+
const sourceNames = new Set(sourceLabels.map((label) => normalizeName(label.name)));
117+
retainedCount = overrideExisting
118+
? sourceLabels.filter((label) => {
119+
const existing = targetByName.get(normalizeName(label.name));
120+
return existing && labelsExactlyMatch(existing, label);
121+
}).length
122+
: targetLabels.length;
123+
124+
console.log(`${dryRun ? "Previewing" : "Applying"} ${overrideExisting ? "override" : "additive"} label transfer: ${sourceName} -> ${targetName}.`);
125+
for (const desired of sourceLabels) {
126+
const existing = targetByName.get(normalizeName(desired.name));
127+
if (!existing) {
128+
if (!dryRun) {
129+
await githubRequest(token, "POST", `/repos/${targetName}/labels`, desired);
130+
}
131+
result.createdLabels.push(desired);
132+
result.hasChanges = true;
133+
console.log(` + ${desired.name}`);
134+
} else if (overrideExisting && !labelsExactlyMatch(existing, desired)) {
135+
if (!dryRun) {
136+
await githubRequest(token, "PATCH", `/repos/${targetName}/labels/${encodeURIComponent(existing.name)}`, {
137+
new_name: desired.name,
138+
color: desired.color,
139+
description: desired.description,
140+
});
141+
}
142+
result.updatedLabels.push({ before: existing, after: desired });
143+
result.hasChanges = true;
144+
console.log(` ~ ${desired.name}`);
145+
}
146+
}
147+
148+
// Delete extras only after every create/update has succeeded. Updating shared
149+
// labels in place preserves their existing issue and pull request assignments.
150+
if (overrideExisting) {
151+
for (const existing of targetLabels) {
152+
if (sourceNames.has(normalizeName(existing.name))) continue;
153+
if (!dryRun) {
154+
await githubRequest(token, "DELETE", `/repos/${targetName}/labels/${encodeURIComponent(existing.name)}`);
155+
}
156+
result.deletedConfiguredLabels.push(existing);
157+
result.hasChanges = true;
158+
console.log(` - ${existing.name}`);
159+
}
160+
}
161+
return result;
162+
} catch (error) {
163+
failure = error;
164+
throw error;
165+
} finally {
166+
await writeChangelog({
167+
workflowName: dryRun ? "Transfer-Labels Fake" : "Transfer-Labels",
168+
summaryLines: ({ generatedDate, metadata }) => [
169+
`Generated On: ${generatedDate}`,
170+
`Actor: ${metadata.actor || "Unavailable"}`,
171+
`Test Mode: ${dryRun ? "True" : "False"}`,
172+
`Source Repository: ${sourceName ? formatRepositoryLink(sourceName) : "Unavailable"}`,
173+
`Receiving Repository: ${targetName ? formatRepositoryLink(targetName) : "Unavailable"}`,
174+
`Override Existing Labels: ${overrideExisting ? "True" : "False"}`,
175+
`Source Labels: ${sourceCount ?? "Unavailable"}`,
176+
`Receiving Labels Before Transfer: ${initialTargetCount ?? "Unavailable"}`,
177+
`Repositories Affected: ${result.hasChanges ? 1 : 0}`,
178+
`Repositories Skipped: ${skippedRepositories.length}`,
179+
`Created Labels: ${result.createdLabels.length}`,
180+
`Updated Labels: ${result.updatedLabels.length}`,
181+
`Deleted Labels: ${result.deletedConfiguredLabels.length}`,
182+
`Retained Labels: ${failure || initialTargetCount === null ? "Unavailable" : retainedCount}`,
183+
],
184+
sections: [renderLabelSyncSection(result)],
185+
skippedRepositories,
186+
failure,
187+
});
188+
}
189+
}
190+
191+
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
192+
transferLabels({
193+
token: process.env.LABEL_SYNC_TOKEN,
194+
organization: process.env.ORG_NAME ?? process.env.GITHUB_REPOSITORY_OWNER,
195+
sourceRepository: process.env.SOURCE_REPOSITORY,
196+
targetRepository: process.env.TARGET_REPOSITORY,
197+
dryRun: process.env.DRY_RUN === "true",
198+
overrideExisting: process.env.OVERRIDE_EXISTING === "true",
199+
tokenPermissions: parseTokenPermissions(process.env.LABEL_SYNC_TOKEN_PERMISSIONS),
200+
}).catch((error) => {
201+
console.error(error.message);
202+
process.exitCode = 1;
203+
});
204+
}

0 commit comments

Comments
 (0)