@aspruyt/xfg 6.2.0 → 6.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/program.js +41 -3
- package/dist/cli/secrets-command.d.ts +25 -0
- package/dist/cli/secrets-command.js +75 -0
- package/dist/cli/settings-factories.d.ts +2 -1
- package/dist/cli/settings-factories.js +6 -1
- package/dist/cli/settings-report-builder.d.ts +6 -1
- package/dist/cli/settings-report-builder.js +21 -2
- package/dist/cli/settings-runner.js +7 -0
- package/dist/cli/types.d.ts +4 -2
- package/dist/config/index.d.ts +2 -2
- package/dist/config/index.js +1 -1
- package/dist/config/normalizer.js +86 -1
- package/dist/config/types.d.ts +20 -0
- package/dist/config/validator.d.ts +4 -0
- package/dist/config/validator.js +174 -5
- package/dist/output/settings-report.d.ts +11 -0
- package/dist/output/settings-report.js +24 -0
- package/dist/secrets/encryption.d.ts +9 -0
- package/dist/secrets/encryption.js +29 -0
- package/dist/secrets/github-secrets-strategy.d.ts +17 -0
- package/dist/secrets/github-secrets-strategy.js +38 -0
- package/dist/secrets/index.d.ts +5 -0
- package/dist/secrets/index.js +3 -0
- package/dist/secrets/processor.d.ts +31 -0
- package/dist/secrets/processor.js +115 -0
- package/dist/secrets/types.d.ts +21 -0
- package/dist/secrets/types.js +1 -0
- package/dist/settings/index.d.ts +1 -0
- package/dist/settings/index.js +2 -0
- package/dist/settings/variables/diff.d.ts +10 -0
- package/dist/settings/variables/diff.js +39 -0
- package/dist/settings/variables/formatter.d.ts +16 -0
- package/dist/settings/variables/formatter.js +70 -0
- package/dist/settings/variables/github-variables-strategy.d.ts +17 -0
- package/dist/settings/variables/github-variables-strategy.js +40 -0
- package/dist/settings/variables/index.d.ts +4 -0
- package/dist/settings/variables/index.js +2 -0
- package/dist/settings/variables/processor.d.ts +19 -0
- package/dist/settings/variables/processor.js +60 -0
- package/dist/settings/variables/types.d.ts +18 -0
- package/dist/settings/variables/types.js +1 -0
- package/dist/shared/env-resolver.d.ts +16 -0
- package/dist/shared/env-resolver.js +33 -0
- package/package.json +3 -1
package/dist/config/validator.js
CHANGED
|
@@ -5,6 +5,8 @@ import { validateFileConfigFields, validateSettings, } from "./validators/shared
|
|
|
5
5
|
import { validateGroups, validateConditionalGroups, } from "./validators/group-validator.js";
|
|
6
6
|
import { validateRepoEntry } from "./validators/repo-entry-validator.js";
|
|
7
7
|
const CONFIG_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
|
8
|
+
const VARIABLE_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
9
|
+
const VARIABLE_RESERVED_KEYS = new Set(["deleteOrphaned", "inherit"]);
|
|
8
10
|
const CONFIG_ID_MAX_LENGTH = 64;
|
|
9
11
|
function validateConfigId(config) {
|
|
10
12
|
if (!config.id || typeof config.id !== "string") {
|
|
@@ -42,6 +44,9 @@ function validateRootSettings(config) {
|
|
|
42
44
|
if (config.settings.labels && "inherit" in config.settings.labels) {
|
|
43
45
|
throw new ValidationError("'inherit' is a reserved key and cannot be used as a label name");
|
|
44
46
|
}
|
|
47
|
+
if (config.settings.variables && "inherit" in config.settings.variables) {
|
|
48
|
+
throw new ValidationError("'inherit' is not allowed in root-level variables (nothing to inherit from)");
|
|
49
|
+
}
|
|
45
50
|
}
|
|
46
51
|
function validateGithubHosts(config) {
|
|
47
52
|
if (config.githubHosts === undefined)
|
|
@@ -110,15 +115,18 @@ export function validateRawConfig(config) {
|
|
|
110
115
|
const hasCondGrpFiles = hasConditionalGroupFiles(config);
|
|
111
116
|
const hasCondGrpSettings = hasConditionalGroupSettingsPresent(config);
|
|
112
117
|
const hasCondGrpPR = hasConditionalGroupPR(config);
|
|
118
|
+
const hasSecrets = isPlainObject(config.secrets) && Object.keys(config.secrets).length > 0;
|
|
113
119
|
if (!hasFiles &&
|
|
114
120
|
!hasSettings &&
|
|
115
121
|
!hasGrpFiles &&
|
|
116
122
|
!hasGrpSettings &&
|
|
117
123
|
!hasCondGrpFiles &&
|
|
118
124
|
!hasCondGrpSettings &&
|
|
119
|
-
!hasCondGrpPR
|
|
120
|
-
|
|
121
|
-
|
|
125
|
+
!hasCondGrpPR &&
|
|
126
|
+
!hasSecrets) {
|
|
127
|
+
throw new ValidationError("Config requires at least one of: 'files', 'settings', or 'secrets'. " +
|
|
128
|
+
"Use 'files' to sync configuration files, 'settings' to manage repository settings, " +
|
|
129
|
+
"or 'secrets' to manage GitHub Actions secrets.");
|
|
122
130
|
}
|
|
123
131
|
validateRootFiles(config);
|
|
124
132
|
if (config.deleteOrphaned !== undefined &&
|
|
@@ -159,8 +167,111 @@ export function validateForSync(config) {
|
|
|
159
167
|
!hasCondGrpFiles &&
|
|
160
168
|
!hasCondGrpSettings &&
|
|
161
169
|
!hasCondGrpPR) {
|
|
162
|
-
throw new ValidationError("Config requires at least one of: 'files' or 'settings'. " +
|
|
163
|
-
"Use 'files' to sync configuration files, or 'settings' to manage repository settings."
|
|
170
|
+
throw new ValidationError("Config requires at least one of: 'files' or 'settings' (rulesets, labels, variables, repo config). " +
|
|
171
|
+
"Use 'files' to sync configuration files, or 'settings' to manage repository settings. " +
|
|
172
|
+
"For secrets, use 'xfg secrets sync'.");
|
|
173
|
+
}
|
|
174
|
+
// Validate variable names across all settings
|
|
175
|
+
const allSettings = [
|
|
176
|
+
config.settings,
|
|
177
|
+
...config.repos.map((r) => r.settings),
|
|
178
|
+
...Object.values(config.groups ?? {}).map((g) => g.settings),
|
|
179
|
+
...(config.conditionalGroups ?? []).map((cg) => cg.settings),
|
|
180
|
+
];
|
|
181
|
+
for (const settings of allSettings) {
|
|
182
|
+
if (!settings?.variables)
|
|
183
|
+
continue;
|
|
184
|
+
const vars = settings.variables;
|
|
185
|
+
if (vars.deleteOrphaned !== undefined &&
|
|
186
|
+
typeof vars.deleteOrphaned !== "boolean") {
|
|
187
|
+
throw new ValidationError("variables.deleteOrphaned must be a boolean");
|
|
188
|
+
}
|
|
189
|
+
if (vars.inherit !== undefined && typeof vars.inherit !== "boolean") {
|
|
190
|
+
throw new ValidationError("variables.inherit must be a boolean");
|
|
191
|
+
}
|
|
192
|
+
for (const [name, value] of Object.entries(vars)) {
|
|
193
|
+
if (VARIABLE_RESERVED_KEYS.has(name))
|
|
194
|
+
continue;
|
|
195
|
+
validateVariableName(name);
|
|
196
|
+
if (value !== false && typeof value !== "string") {
|
|
197
|
+
throw new ValidationError(`Variable '${name}' must have a string value (got ${typeof value}). Quote numeric values in YAML: "${String(value)}".`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// Reject duplicate case-insensitive variable names
|
|
201
|
+
const seenVarNames = new Map();
|
|
202
|
+
for (const name of Object.keys(settings.variables)) {
|
|
203
|
+
if (VARIABLE_RESERVED_KEYS.has(name))
|
|
204
|
+
continue;
|
|
205
|
+
const upper = name.toUpperCase();
|
|
206
|
+
const existing = seenVarNames.get(upper);
|
|
207
|
+
if (existing) {
|
|
208
|
+
throw new ValidationError(`Duplicate variable name: '${name}' and '${existing}' collide (GitHub treats variable names case-insensitively).`);
|
|
209
|
+
}
|
|
210
|
+
seenVarNames.set(upper, name);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// Validate secret names and configs
|
|
214
|
+
validateSecretsConfig(config);
|
|
215
|
+
// Cross-validate: no overlap between global secret names and variable names
|
|
216
|
+
validateVariableSecretOverlaps(config);
|
|
217
|
+
}
|
|
218
|
+
export function validateVariableSecretOverlaps(config) {
|
|
219
|
+
if (!config.secrets)
|
|
220
|
+
return;
|
|
221
|
+
const { deleteOrphaned: _, ...secretEntries } = config.secrets;
|
|
222
|
+
// GitHub treats secret/variable names case-insensitively for collision purposes
|
|
223
|
+
const secretNames = new Set(Object.keys(secretEntries)
|
|
224
|
+
.filter((k) => typeof secretEntries[k] !== "boolean")
|
|
225
|
+
.map((n) => n.toUpperCase()));
|
|
226
|
+
if (secretNames.size === 0)
|
|
227
|
+
return;
|
|
228
|
+
// Check root-level variables
|
|
229
|
+
if (config.settings?.variables) {
|
|
230
|
+
const { deleteOrphaned: _rd, inherit: _ri, ...rootVarEntries } = config.settings.variables;
|
|
231
|
+
const rootVariableNames = Object.keys(rootVarEntries).filter((k) => typeof rootVarEntries[k] !== "boolean");
|
|
232
|
+
const overlapping = rootVariableNames.filter((n) => secretNames.has(n.toUpperCase()));
|
|
233
|
+
if (overlapping.length > 0) {
|
|
234
|
+
throw new ValidationError(`${overlapping.join(", ")} overlap between root variables and secrets. ` +
|
|
235
|
+
"GitHub does not allow variables and secrets with the same name.");
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
for (const repo of config.repos) {
|
|
239
|
+
const { deleteOrphaned: _d, inherit: _i, ...varEntries } = (repo.settings?.variables ?? {});
|
|
240
|
+
const variableNames = Object.keys(varEntries).filter((k) => typeof varEntries[k] !== "boolean");
|
|
241
|
+
const overlapping = variableNames.filter((n) => secretNames.has(n.toUpperCase()));
|
|
242
|
+
if (overlapping.length > 0) {
|
|
243
|
+
throw new ValidationError(`Repo '${repo.git}': ${overlapping.join(", ")} overlap between variables and secrets. ` +
|
|
244
|
+
"GitHub does not allow variables and secrets with the same name.");
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// Check group-level variables
|
|
248
|
+
if (isPlainObject(config.groups)) {
|
|
249
|
+
for (const [groupName, group] of Object.entries(config.groups)) {
|
|
250
|
+
if (!group.settings?.variables)
|
|
251
|
+
continue;
|
|
252
|
+
const { deleteOrphaned: _gd, inherit: _gi, ...groupVarEntries } = group.settings.variables;
|
|
253
|
+
const groupVariableNames = Object.keys(groupVarEntries).filter((k) => typeof groupVarEntries[k] !== "boolean");
|
|
254
|
+
const overlapping = groupVariableNames.filter((n) => secretNames.has(n.toUpperCase()));
|
|
255
|
+
if (overlapping.length > 0) {
|
|
256
|
+
throw new ValidationError(`Group '${groupName}': ${overlapping.join(", ")} overlap between variables and secrets. ` +
|
|
257
|
+
"GitHub does not allow variables and secrets with the same name.");
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// Check conditional group-level variables
|
|
262
|
+
if (Array.isArray(config.conditionalGroups)) {
|
|
263
|
+
for (let i = 0; i < config.conditionalGroups.length; i++) {
|
|
264
|
+
const cg = config.conditionalGroups[i];
|
|
265
|
+
if (!cg.settings?.variables)
|
|
266
|
+
continue;
|
|
267
|
+
const { deleteOrphaned: _cd, inherit: _ci, ...cgVarEntries } = cg.settings.variables;
|
|
268
|
+
const cgVariableNames = Object.keys(cgVarEntries).filter((k) => typeof cgVarEntries[k] !== "boolean");
|
|
269
|
+
const overlapping = cgVariableNames.filter((n) => secretNames.has(n.toUpperCase()));
|
|
270
|
+
if (overlapping.length > 0) {
|
|
271
|
+
throw new ValidationError(`Conditional group ${i}: ${overlapping.join(", ")} overlap between variables and secrets. ` +
|
|
272
|
+
"GitHub does not allow variables and secrets with the same name.");
|
|
273
|
+
}
|
|
274
|
+
}
|
|
164
275
|
}
|
|
165
276
|
}
|
|
166
277
|
export function hasActionableSettings(settings) {
|
|
@@ -180,5 +291,63 @@ export function hasActionableSettings(settings) {
|
|
|
180
291
|
if (settings.codeScanning) {
|
|
181
292
|
return true;
|
|
182
293
|
}
|
|
294
|
+
if (settings.variables) {
|
|
295
|
+
const { deleteOrphaned, inherit: _i, ...entries } = settings.variables;
|
|
296
|
+
if (Object.keys(entries).length > 0 || deleteOrphaned === true) {
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
183
300
|
return false;
|
|
184
301
|
}
|
|
302
|
+
export function validateVariableName(name) {
|
|
303
|
+
if (!VARIABLE_NAME_PATTERN.test(name)) {
|
|
304
|
+
throw new ValidationError(`Variable name '${name}' contains invalid characters. Only alphanumeric and underscore allowed.`);
|
|
305
|
+
}
|
|
306
|
+
if (name.startsWith("GITHUB_")) {
|
|
307
|
+
throw new ValidationError(`Variable name '${name}' cannot start with 'GITHUB_' (reserved prefix).`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
export function validateSecretName(name) {
|
|
311
|
+
if (!VARIABLE_NAME_PATTERN.test(name)) {
|
|
312
|
+
throw new ValidationError(`Secret name '${name}' contains invalid characters. Only alphanumeric and underscore allowed.`);
|
|
313
|
+
}
|
|
314
|
+
if (name.startsWith("GITHUB_")) {
|
|
315
|
+
throw new ValidationError(`Secret name '${name}' cannot start with 'GITHUB_' (reserved prefix).`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
function validateSecretEntry(name, config) {
|
|
319
|
+
validateSecretName(name);
|
|
320
|
+
if (!config.env || typeof config.env !== "string") {
|
|
321
|
+
throw new ValidationError(`Secret '${name}' requires an 'env' field (string) specifying the environment variable source.`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
export function validateSecretsConfig(config) {
|
|
325
|
+
if (!config.secrets)
|
|
326
|
+
return;
|
|
327
|
+
const { deleteOrphaned, ...entries } = config.secrets;
|
|
328
|
+
// Reject 'deleteOrphaned' used as a secret name (it's a reserved peer key)
|
|
329
|
+
if (deleteOrphaned !== undefined && typeof deleteOrphaned !== "boolean") {
|
|
330
|
+
throw new ValidationError("'deleteOrphaned' is a reserved key in secrets config and cannot be used as a secret name.");
|
|
331
|
+
}
|
|
332
|
+
// Reject boolean true — only false (opt-out) is valid
|
|
333
|
+
for (const [name, value] of Object.entries(entries)) {
|
|
334
|
+
if (value === true) {
|
|
335
|
+
throw new ValidationError(`Secret '${name}' is set to true, which is not valid. Use false to opt out, or provide a SecretConfig object.`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
// Reject duplicate case-insensitive secret names
|
|
339
|
+
const seen = new Map();
|
|
340
|
+
for (const name of Object.keys(entries)) {
|
|
341
|
+
const upper = name.toUpperCase();
|
|
342
|
+
const existing = seen.get(upper);
|
|
343
|
+
if (existing) {
|
|
344
|
+
throw new ValidationError(`Duplicate secret name: '${name}' and '${existing}' collide (GitHub treats secret names case-insensitively).`);
|
|
345
|
+
}
|
|
346
|
+
seen.set(upper, name);
|
|
347
|
+
}
|
|
348
|
+
for (const [name, value] of Object.entries(entries)) {
|
|
349
|
+
if (typeof value === "boolean")
|
|
350
|
+
continue;
|
|
351
|
+
validateSecretEntry(name, value);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
@@ -17,6 +17,11 @@ export interface SettingsReport {
|
|
|
17
17
|
update: number;
|
|
18
18
|
delete: number;
|
|
19
19
|
};
|
|
20
|
+
variables?: {
|
|
21
|
+
create: number;
|
|
22
|
+
update: number;
|
|
23
|
+
delete: number;
|
|
24
|
+
};
|
|
20
25
|
};
|
|
21
26
|
}
|
|
22
27
|
export interface RepoChanges {
|
|
@@ -24,6 +29,12 @@ export interface RepoChanges {
|
|
|
24
29
|
settings: SettingChange[];
|
|
25
30
|
rulesets: RulesetChange[];
|
|
26
31
|
labels: LabelChange[];
|
|
32
|
+
variables?: {
|
|
33
|
+
name: string;
|
|
34
|
+
action: ActiveAction;
|
|
35
|
+
oldValue?: string;
|
|
36
|
+
newValue?: string;
|
|
37
|
+
}[];
|
|
27
38
|
error?: string;
|
|
28
39
|
}
|
|
29
40
|
export interface SettingChange {
|
|
@@ -90,6 +90,13 @@ function formatSettingsSummary(totals) {
|
|
|
90
90
|
]);
|
|
91
91
|
if (labelsEntry)
|
|
92
92
|
parts.push(labelsEntry);
|
|
93
|
+
const variablesEntry = formatCountEntry("variable", "variables", [
|
|
94
|
+
{ label: "to create", value: totals.variables?.create ?? 0 },
|
|
95
|
+
{ label: "to update", value: totals.variables?.update ?? 0 },
|
|
96
|
+
{ label: "to delete", value: totals.variables?.delete ?? 0 },
|
|
97
|
+
]);
|
|
98
|
+
if (variablesEntry)
|
|
99
|
+
parts.push(variablesEntry);
|
|
93
100
|
if (parts.length === 0) {
|
|
94
101
|
return "No changes";
|
|
95
102
|
}
|
|
@@ -112,6 +119,7 @@ export function formatSettingsReportCLI(report) {
|
|
|
112
119
|
if (repo.settings.length === 0 &&
|
|
113
120
|
repo.rulesets.length === 0 &&
|
|
114
121
|
repo.labels.length === 0 &&
|
|
122
|
+
(repo.variables ?? []).length === 0 &&
|
|
115
123
|
!repo.error) {
|
|
116
124
|
continue;
|
|
117
125
|
}
|
|
@@ -231,6 +239,21 @@ export function renderRepoSettingsDiffLines(repo, diffLines) {
|
|
|
231
239
|
diffLines.push(`- label "${label.name}"`);
|
|
232
240
|
}
|
|
233
241
|
}
|
|
242
|
+
// Blank line before variables if there was content above
|
|
243
|
+
if ((repo.variables ?? []).length > 0 && diffLines.length > startLength) {
|
|
244
|
+
diffLines.push("");
|
|
245
|
+
}
|
|
246
|
+
for (const variable of repo.variables ?? []) {
|
|
247
|
+
if (variable.action === "create") {
|
|
248
|
+
diffLines.push(`+ variable "${variable.name}": ${formatValuePlain(variable.newValue)}`);
|
|
249
|
+
}
|
|
250
|
+
else if (variable.action === "update") {
|
|
251
|
+
diffLines.push(`! variable "${variable.name}": ${formatValuePlain(variable.oldValue)} → ${formatValuePlain(variable.newValue)}`);
|
|
252
|
+
}
|
|
253
|
+
else if (variable.action === "delete") {
|
|
254
|
+
diffLines.push(`- variable "${variable.name}"`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
234
257
|
if (repo.error) {
|
|
235
258
|
diffLines.push(`- Error: ${repo.error}`);
|
|
236
259
|
}
|
|
@@ -252,6 +275,7 @@ export function formatSettingsReportMarkdown(report, dryRun) {
|
|
|
252
275
|
if (repo.settings.length === 0 &&
|
|
253
276
|
repo.rulesets.length === 0 &&
|
|
254
277
|
repo.labels.length === 0 &&
|
|
278
|
+
(repo.variables ?? []).length === 0 &&
|
|
255
279
|
!repo.error) {
|
|
256
280
|
continue;
|
|
257
281
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface ISecretEncryptor {
|
|
2
|
+
encrypt(value: string, publicKeyBase64: string): Promise<string>;
|
|
3
|
+
}
|
|
4
|
+
export declare class SodiumEncryptor implements ISecretEncryptor {
|
|
5
|
+
private sodium;
|
|
6
|
+
private initPromise;
|
|
7
|
+
private ensureInitialized;
|
|
8
|
+
encrypt(value: string, publicKeyBase64: string): Promise<string>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export class SodiumEncryptor {
|
|
2
|
+
sodium;
|
|
3
|
+
initPromise = null;
|
|
4
|
+
ensureInitialized() {
|
|
5
|
+
if (!this.initPromise) {
|
|
6
|
+
this.initPromise = (async () => {
|
|
7
|
+
try {
|
|
8
|
+
const sodium = await import("libsodium-wrappers");
|
|
9
|
+
await sodium.default.ready;
|
|
10
|
+
this.sodium = sodium.default;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
throw new Error("Failed to load libsodium-wrappers. Install it: npm install libsodium-wrappers");
|
|
14
|
+
}
|
|
15
|
+
})().catch((err) => {
|
|
16
|
+
this.initPromise = null;
|
|
17
|
+
throw err;
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
return this.initPromise.then(() => this.sodium);
|
|
21
|
+
}
|
|
22
|
+
async encrypt(value, publicKeyBase64) {
|
|
23
|
+
const sodium = await this.ensureInitialized();
|
|
24
|
+
const messageBytes = sodium.from_string(value);
|
|
25
|
+
const publicKey = sodium.from_base64(publicKeyBase64, sodium.base64_variants.ORIGINAL);
|
|
26
|
+
const encrypted = sodium.crypto_box_seal(messageBytes, publicKey);
|
|
27
|
+
return sodium.to_base64(encrypted, sodium.base64_variants.ORIGINAL);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ICommandExecutor } from "../shared/command-executor.js";
|
|
2
|
+
import { type RepoInfo } from "../repo/index.js";
|
|
3
|
+
import { type GhApiOptions } from "../shared/gh-api-utils.js";
|
|
4
|
+
import type { ISecretsStrategy, GitHubSecret, GitHubPublicKey } from "./types.js";
|
|
5
|
+
interface GitHubSecretsStrategyOptions {
|
|
6
|
+
retries?: number;
|
|
7
|
+
cwd: string;
|
|
8
|
+
}
|
|
9
|
+
export declare class GitHubSecretsStrategy implements ISecretsStrategy {
|
|
10
|
+
private api;
|
|
11
|
+
constructor(executor: ICommandExecutor, options: GitHubSecretsStrategyOptions);
|
|
12
|
+
list(repoInfo: RepoInfo, options?: GhApiOptions): Promise<GitHubSecret[]>;
|
|
13
|
+
getPublicKey(repoInfo: RepoInfo, options?: GhApiOptions): Promise<GitHubPublicKey>;
|
|
14
|
+
upsert(repoInfo: RepoInfo, name: string, encryptedValue: string, keyId: string, options?: GhApiOptions): Promise<void>;
|
|
15
|
+
delete(repoInfo: RepoInfo, name: string, options?: GhApiOptions): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { assertGitHubRepo } from "../repo/index.js";
|
|
2
|
+
import { GhApiClient } from "../shared/gh-api-utils.js";
|
|
3
|
+
import { parseApiJson } from "../shared/json-utils.js";
|
|
4
|
+
export class GitHubSecretsStrategy {
|
|
5
|
+
api;
|
|
6
|
+
constructor(executor, options) {
|
|
7
|
+
this.api = new GhApiClient(executor, options.retries ?? 3, options.cwd);
|
|
8
|
+
}
|
|
9
|
+
async list(repoInfo, options) {
|
|
10
|
+
assertGitHubRepo(repoInfo, "GitHub Secrets strategy");
|
|
11
|
+
const endpoint = `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/secrets`;
|
|
12
|
+
const result = await this.api.call("GET", endpoint, {
|
|
13
|
+
options,
|
|
14
|
+
paginate: true,
|
|
15
|
+
});
|
|
16
|
+
const response = parseApiJson(result, "secrets response");
|
|
17
|
+
return response.secrets ?? [];
|
|
18
|
+
}
|
|
19
|
+
async getPublicKey(repoInfo, options) {
|
|
20
|
+
assertGitHubRepo(repoInfo, "GitHub Secrets strategy");
|
|
21
|
+
const endpoint = `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/secrets/public-key`;
|
|
22
|
+
const result = await this.api.call("GET", endpoint, { options });
|
|
23
|
+
return parseApiJson(result, "public key response");
|
|
24
|
+
}
|
|
25
|
+
async upsert(repoInfo, name, encryptedValue, keyId, options) {
|
|
26
|
+
assertGitHubRepo(repoInfo, "GitHub Secrets strategy");
|
|
27
|
+
const endpoint = `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/secrets/${encodeURIComponent(name)}`;
|
|
28
|
+
await this.api.call("PUT", endpoint, {
|
|
29
|
+
payload: { encrypted_value: encryptedValue, key_id: keyId },
|
|
30
|
+
options,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
async delete(repoInfo, name, options) {
|
|
34
|
+
assertGitHubRepo(repoInfo, "GitHub Secrets strategy");
|
|
35
|
+
const endpoint = `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/secrets/${encodeURIComponent(name)}`;
|
|
36
|
+
await this.api.call("DELETE", endpoint, { options });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { SecretsProcessor } from "./processor.js";
|
|
2
|
+
export type { SecretsProcessorOptions, SecretsProcessorResult, } from "./processor.js";
|
|
3
|
+
export { GitHubSecretsStrategy } from "./github-secrets-strategy.js";
|
|
4
|
+
export { SodiumEncryptor, type ISecretEncryptor } from "./encryption.js";
|
|
5
|
+
export type { ISecretsStrategy, GitHubSecret, GitHubPublicKey, } from "./types.js";
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type RepoInfo } from "../repo/index.js";
|
|
2
|
+
import type { ISecretsStrategy } from "./types.js";
|
|
3
|
+
import type { ISecretEncryptor } from "./encryption.js";
|
|
4
|
+
import type { IEnvResolver } from "../shared/env-resolver.js";
|
|
5
|
+
import type { SecretConfig } from "../config/index.js";
|
|
6
|
+
type SecretsConfig = Record<string, SecretConfig | boolean> & {
|
|
7
|
+
deleteOrphaned?: boolean;
|
|
8
|
+
};
|
|
9
|
+
export interface SecretsProcessorOptions {
|
|
10
|
+
dryRun?: boolean;
|
|
11
|
+
token?: string;
|
|
12
|
+
noDelete?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export interface SecretsProcessorResult {
|
|
15
|
+
success: boolean;
|
|
16
|
+
repoName: string;
|
|
17
|
+
message: string;
|
|
18
|
+
skipped?: boolean;
|
|
19
|
+
dryRun?: boolean;
|
|
20
|
+
created: number;
|
|
21
|
+
updated: number;
|
|
22
|
+
deleted: number;
|
|
23
|
+
}
|
|
24
|
+
export declare class SecretsProcessor {
|
|
25
|
+
private readonly strategy;
|
|
26
|
+
private readonly encryptor;
|
|
27
|
+
private readonly envResolver;
|
|
28
|
+
constructor(strategy: ISecretsStrategy, encryptor: ISecretEncryptor, envResolver: IEnvResolver);
|
|
29
|
+
process(secretsConfig: SecretsConfig, repoInfo: RepoInfo, options: SecretsProcessorOptions): Promise<SecretsProcessorResult>;
|
|
30
|
+
}
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { isGitHubRepo, getRepoDisplayName, } from "../repo/index.js";
|
|
2
|
+
export class SecretsProcessor {
|
|
3
|
+
strategy;
|
|
4
|
+
encryptor;
|
|
5
|
+
envResolver;
|
|
6
|
+
constructor(strategy, encryptor, envResolver) {
|
|
7
|
+
this.strategy = strategy;
|
|
8
|
+
this.encryptor = encryptor;
|
|
9
|
+
this.envResolver = envResolver;
|
|
10
|
+
}
|
|
11
|
+
async process(secretsConfig, repoInfo, options) {
|
|
12
|
+
const repoName = getRepoDisplayName(repoInfo);
|
|
13
|
+
if (!isGitHubRepo(repoInfo)) {
|
|
14
|
+
return {
|
|
15
|
+
success: true,
|
|
16
|
+
repoName,
|
|
17
|
+
message: "Skipped: not a GitHub repository",
|
|
18
|
+
skipped: true,
|
|
19
|
+
created: 0,
|
|
20
|
+
updated: 0,
|
|
21
|
+
deleted: 0,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const githubRepo = repoInfo;
|
|
25
|
+
const { deleteOrphaned: configDeleteOrphaned = false, ...rawEntries } = secretsConfig;
|
|
26
|
+
const { dryRun, token, noDelete } = options;
|
|
27
|
+
const deleteOrphaned = configDeleteOrphaned && !(noDelete ?? false);
|
|
28
|
+
const strategyOptions = { token, host: githubRepo.host };
|
|
29
|
+
const secretEntries = Object.entries(rawEntries).filter((entry) => typeof entry[1] !== "boolean");
|
|
30
|
+
let resolvedValues;
|
|
31
|
+
if (!dryRun && secretEntries.length > 0) {
|
|
32
|
+
resolvedValues = this.envResolver.resolveAll(secretEntries.map(([name, config]) => ({
|
|
33
|
+
name,
|
|
34
|
+
envVar: config.env,
|
|
35
|
+
})));
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
resolvedValues = new Map();
|
|
39
|
+
}
|
|
40
|
+
const currentSecrets = await this.strategy.list(githubRepo, strategyOptions);
|
|
41
|
+
const currentByName = new Set(currentSecrets.map((s) => s.name.toUpperCase()));
|
|
42
|
+
const desiredNames = new Set(secretEntries.map(([name]) => name.toUpperCase()));
|
|
43
|
+
let created = 0;
|
|
44
|
+
let updated = 0;
|
|
45
|
+
let deleted = 0;
|
|
46
|
+
if (!dryRun) {
|
|
47
|
+
if (secretEntries.length > 0) {
|
|
48
|
+
const publicKey = await this.strategy.getPublicKey(githubRepo, strategyOptions);
|
|
49
|
+
for (const [name] of secretEntries) {
|
|
50
|
+
const value = resolvedValues.get(name);
|
|
51
|
+
const encrypted = await this.encryptor.encrypt(value, publicKey.key);
|
|
52
|
+
await this.strategy.upsert(githubRepo, name, encrypted, publicKey.key_id, strategyOptions);
|
|
53
|
+
if (currentByName.has(name.toUpperCase())) {
|
|
54
|
+
updated++;
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
created++;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (deleteOrphaned) {
|
|
62
|
+
for (const current of currentSecrets) {
|
|
63
|
+
if (!desiredNames.has(current.name.toUpperCase())) {
|
|
64
|
+
await this.strategy.delete(githubRepo, current.name, strategyOptions);
|
|
65
|
+
deleted++;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
for (const [name] of secretEntries) {
|
|
72
|
+
if (currentByName.has(name.toUpperCase())) {
|
|
73
|
+
updated++;
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
created++;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (deleteOrphaned) {
|
|
80
|
+
for (const current of currentSecrets) {
|
|
81
|
+
if (!desiredNames.has(current.name.toUpperCase())) {
|
|
82
|
+
deleted++;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const parts = [];
|
|
88
|
+
if (created > 0)
|
|
89
|
+
parts.push(`${created} created`);
|
|
90
|
+
if (updated > 0)
|
|
91
|
+
parts.push(`${updated} updated`);
|
|
92
|
+
if (deleted > 0)
|
|
93
|
+
parts.push(`${deleted} deleted`);
|
|
94
|
+
const summary = parts.length > 0 ? parts.join(", ") : "no changes";
|
|
95
|
+
if (dryRun) {
|
|
96
|
+
return {
|
|
97
|
+
success: true,
|
|
98
|
+
repoName,
|
|
99
|
+
message: `[DRY RUN] ${summary}`,
|
|
100
|
+
dryRun: true,
|
|
101
|
+
created,
|
|
102
|
+
updated,
|
|
103
|
+
deleted,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
success: true,
|
|
108
|
+
repoName,
|
|
109
|
+
message: summary,
|
|
110
|
+
created,
|
|
111
|
+
updated,
|
|
112
|
+
deleted,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { RepoInfo } from "../repo/index.js";
|
|
2
|
+
import type { GhApiOptions } from "../shared/gh-api-utils.js";
|
|
3
|
+
export interface GitHubSecret {
|
|
4
|
+
name: string;
|
|
5
|
+
created_at: string;
|
|
6
|
+
updated_at: string;
|
|
7
|
+
}
|
|
8
|
+
export interface GitHubSecretsListResponse {
|
|
9
|
+
total_count: number;
|
|
10
|
+
secrets: GitHubSecret[];
|
|
11
|
+
}
|
|
12
|
+
export interface GitHubPublicKey {
|
|
13
|
+
key_id: string;
|
|
14
|
+
key: string;
|
|
15
|
+
}
|
|
16
|
+
export interface ISecretsStrategy {
|
|
17
|
+
list(repoInfo: RepoInfo, options?: GhApiOptions): Promise<GitHubSecret[]>;
|
|
18
|
+
getPublicKey(repoInfo: RepoInfo, options?: GhApiOptions): Promise<GitHubPublicKey>;
|
|
19
|
+
upsert(repoInfo: RepoInfo, name: string, encryptedValue: string, keyId: string, options?: GhApiOptions): Promise<void>;
|
|
20
|
+
delete(repoInfo: RepoInfo, name: string, options?: GhApiOptions): Promise<void>;
|
|
21
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/settings/index.d.ts
CHANGED
|
@@ -3,3 +3,4 @@ export { type PropertyDiff, type RulesetPlanEntry, RulesetProcessor, type IRules
|
|
|
3
3
|
export { RepoSettingsProcessor, type IRepoSettingsProcessor, type RepoSettingsPlanEntry, GitHubRepoSettingsStrategy, } from "./repo-settings/index.js";
|
|
4
4
|
export { type LabelsPlanEntry, LabelsProcessor, type ILabelsProcessor, GitHubLabelsStrategy, } from "./labels/index.js";
|
|
5
5
|
export { type CodeScanningPlanEntry, CodeScanningProcessor, type ICodeScanningProcessor, GitHubCodeScanningStrategy, } from "./code-scanning/index.js";
|
|
6
|
+
export { type VariablesPlanEntry, VariablesProcessor, type IVariablesProcessor, GitHubVariablesStrategy, } from "./variables/index.js";
|
package/dist/settings/index.js
CHANGED
|
@@ -8,3 +8,5 @@ export { RepoSettingsProcessor, GitHubRepoSettingsStrategy, } from "./repo-setti
|
|
|
8
8
|
export { LabelsProcessor, GitHubLabelsStrategy, } from "./labels/index.js";
|
|
9
9
|
// Code scanning
|
|
10
10
|
export { CodeScanningProcessor, GitHubCodeScanningStrategy, } from "./code-scanning/index.js";
|
|
11
|
+
// Variables
|
|
12
|
+
export { VariablesProcessor, GitHubVariablesStrategy, } from "./variables/index.js";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { GitHubVariable } from "./types.js";
|
|
2
|
+
import type { SettingsAction } from "../base-processor.js";
|
|
3
|
+
export type VariableAction = SettingsAction;
|
|
4
|
+
export interface VariableChange {
|
|
5
|
+
action: VariableAction;
|
|
6
|
+
name: string;
|
|
7
|
+
oldValue?: string;
|
|
8
|
+
newValue?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function diffVariables(current: GitHubVariable[], desired: Record<string, string>, deleteOrphaned: boolean): VariableChange[];
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export function diffVariables(current, desired, deleteOrphaned) {
|
|
2
|
+
const changes = [];
|
|
3
|
+
const currentByName = new Map();
|
|
4
|
+
for (const v of current) {
|
|
5
|
+
currentByName.set(v.name.toUpperCase(), v);
|
|
6
|
+
}
|
|
7
|
+
const desiredUpper = new Set(Object.keys(desired).map((n) => n.toUpperCase()));
|
|
8
|
+
for (const [name, desiredValue] of Object.entries(desired)) {
|
|
9
|
+
const currentVar = currentByName.get(name.toUpperCase());
|
|
10
|
+
if (!currentVar) {
|
|
11
|
+
changes.push({ action: "create", name, newValue: desiredValue });
|
|
12
|
+
}
|
|
13
|
+
else if (currentVar.value !== desiredValue) {
|
|
14
|
+
changes.push({
|
|
15
|
+
action: "update",
|
|
16
|
+
name: currentVar.name,
|
|
17
|
+
oldValue: currentVar.value,
|
|
18
|
+
newValue: desiredValue,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
changes.push({ action: "unchanged", name: currentVar.name });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (deleteOrphaned) {
|
|
26
|
+
for (const [nameUpper, currentVar] of currentByName) {
|
|
27
|
+
if (!desiredUpper.has(nameUpper)) {
|
|
28
|
+
changes.push({ action: "delete", name: currentVar.name });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const actionOrder = {
|
|
33
|
+
delete: 0,
|
|
34
|
+
update: 1,
|
|
35
|
+
create: 2,
|
|
36
|
+
unchanged: 3,
|
|
37
|
+
};
|
|
38
|
+
return changes.sort((a, b) => actionOrder[a.action] - actionOrder[b.action]);
|
|
39
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { VariableChange, VariableAction } from "./diff.js";
|
|
2
|
+
export interface VariablesPlanEntry {
|
|
3
|
+
name: string;
|
|
4
|
+
action: VariableAction;
|
|
5
|
+
oldValue?: string;
|
|
6
|
+
newValue?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface VariablesPlanResult {
|
|
9
|
+
lines: string[];
|
|
10
|
+
creates: number;
|
|
11
|
+
updates: number;
|
|
12
|
+
deletes: number;
|
|
13
|
+
unchanged: number;
|
|
14
|
+
entries: VariablesPlanEntry[];
|
|
15
|
+
}
|
|
16
|
+
export declare function formatVariablesPlan(changes: VariableChange[]): VariablesPlanResult;
|