@aspruyt/xfg 6.2.0 → 6.4.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/branch-utils.d.ts +1 -5
- package/dist/cli/branch-utils.js +1 -22
- package/dist/cli/program.js +41 -3
- package/dist/cli/repo-sync-runner.js +7 -2
- 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/loader.js +46 -17
- package/dist/config/normalizer.js +86 -1
- package/dist/config/types.d.ts +21 -0
- package/dist/config/validator.d.ts +4 -0
- package/dist/config/validator.js +178 -5
- package/dist/config/validators/group-validator.js +7 -0
- package/dist/config/validators/repo-entry-validator.js +7 -0
- 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/branch-validation.d.ts +2 -0
- package/dist/shared/branch-validation.js +19 -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/types.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export interface PRMergeOptions {
|
|
|
7
7
|
deleteBranch?: boolean;
|
|
8
8
|
bypassReason?: string;
|
|
9
9
|
labels?: string[];
|
|
10
|
+
branch?: string;
|
|
10
11
|
}
|
|
11
12
|
export type RulesetTarget = "branch" | "tag";
|
|
12
13
|
export type RulesetEnforcement = "active" | "disabled" | "evaluate";
|
|
@@ -267,6 +268,9 @@ export interface CodeScanningSettings {
|
|
|
267
268
|
querySuite?: CodeScanningQuerySuite;
|
|
268
269
|
languages?: CodeScanningLanguage[];
|
|
269
270
|
}
|
|
271
|
+
export interface SecretConfig {
|
|
272
|
+
env: string;
|
|
273
|
+
}
|
|
270
274
|
export interface RepoSettings {
|
|
271
275
|
/** GitHub rulesets keyed by name */
|
|
272
276
|
rulesets?: Record<string, Ruleset>;
|
|
@@ -276,6 +280,10 @@ export interface RepoSettings {
|
|
|
276
280
|
labels?: Record<string, Label>;
|
|
277
281
|
/** GitHub code scanning default setup */
|
|
278
282
|
codeScanning?: CodeScanningSettings;
|
|
283
|
+
/** GitHub Actions variables keyed by name */
|
|
284
|
+
variables?: Record<string, string> & {
|
|
285
|
+
deleteOrphaned?: boolean;
|
|
286
|
+
};
|
|
279
287
|
deleteOrphaned?: boolean;
|
|
280
288
|
}
|
|
281
289
|
export type ContentValue = Record<string, unknown> | string | string[];
|
|
@@ -336,6 +344,9 @@ export interface RawRootSettings {
|
|
|
336
344
|
repo?: GitHubRepoSettings | false;
|
|
337
345
|
labels?: Record<string, Label | false>;
|
|
338
346
|
codeScanning?: CodeScanningSettings | false;
|
|
347
|
+
variables?: Record<string, string | false> & {
|
|
348
|
+
deleteOrphaned?: boolean;
|
|
349
|
+
};
|
|
339
350
|
deleteOrphaned?: boolean;
|
|
340
351
|
}
|
|
341
352
|
export interface RawRepoSettings {
|
|
@@ -347,6 +358,10 @@ export interface RawRepoSettings {
|
|
|
347
358
|
inherit?: boolean;
|
|
348
359
|
};
|
|
349
360
|
codeScanning?: CodeScanningSettings | false;
|
|
361
|
+
variables?: Record<string, string | false> & {
|
|
362
|
+
inherit?: boolean;
|
|
363
|
+
deleteOrphaned?: boolean;
|
|
364
|
+
};
|
|
350
365
|
deleteOrphaned?: boolean;
|
|
351
366
|
}
|
|
352
367
|
export interface RawRepoConfig {
|
|
@@ -373,6 +388,9 @@ export interface RawConfig {
|
|
|
373
388
|
githubHosts?: string[];
|
|
374
389
|
deleteOrphaned?: boolean;
|
|
375
390
|
settings?: RawRootSettings;
|
|
391
|
+
secrets?: Record<string, SecretConfig | boolean> & {
|
|
392
|
+
deleteOrphaned?: boolean;
|
|
393
|
+
};
|
|
376
394
|
}
|
|
377
395
|
export interface FileContent {
|
|
378
396
|
fileName: string;
|
|
@@ -402,5 +420,8 @@ export interface Config {
|
|
|
402
420
|
githubHosts?: string[];
|
|
403
421
|
deleteOrphaned?: boolean;
|
|
404
422
|
settings?: RepoSettings;
|
|
423
|
+
secrets?: Record<string, SecretConfig | boolean> & {
|
|
424
|
+
deleteOrphaned?: boolean;
|
|
425
|
+
};
|
|
405
426
|
}
|
|
406
427
|
export {};
|
|
@@ -9,4 +9,8 @@ export declare function validateRawConfig(config: RawConfig): void;
|
|
|
9
9
|
* @throws ValidationError if neither files nor settings are present
|
|
10
10
|
*/
|
|
11
11
|
export declare function validateForSync(config: RawConfig): void;
|
|
12
|
+
export declare function validateVariableSecretOverlaps(config: RawConfig): void;
|
|
12
13
|
export declare function hasActionableSettings(settings: RawRootSettings | RawRepoSettings | undefined): boolean;
|
|
14
|
+
export declare function validateVariableName(name: string): void;
|
|
15
|
+
export declare function validateSecretName(name: string): void;
|
|
16
|
+
export declare function validateSecretsConfig(config: RawConfig): void;
|
package/dist/config/validator.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { validateFileName } from "./validators/file-validator.js";
|
|
2
2
|
import { isPlainObject } from "../shared/type-guards.js";
|
|
3
3
|
import { ValidationError } from "../shared/errors.js";
|
|
4
|
+
import { validateBranchName } from "../shared/branch-validation.js";
|
|
4
5
|
import { validateFileConfigFields, validateSettings, } from "./validators/shared.js";
|
|
5
6
|
import { validateGroups, validateConditionalGroups, } from "./validators/group-validator.js";
|
|
6
7
|
import { validateRepoEntry } from "./validators/repo-entry-validator.js";
|
|
7
8
|
const CONFIG_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
|
9
|
+
const VARIABLE_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
10
|
+
const VARIABLE_RESERVED_KEYS = new Set(["deleteOrphaned", "inherit"]);
|
|
8
11
|
const CONFIG_ID_MAX_LENGTH = 64;
|
|
9
12
|
function validateConfigId(config) {
|
|
10
13
|
if (!config.id || typeof config.id !== "string") {
|
|
@@ -42,6 +45,9 @@ function validateRootSettings(config) {
|
|
|
42
45
|
if (config.settings.labels && "inherit" in config.settings.labels) {
|
|
43
46
|
throw new ValidationError("'inherit' is a reserved key and cannot be used as a label name");
|
|
44
47
|
}
|
|
48
|
+
if (config.settings.variables && "inherit" in config.settings.variables) {
|
|
49
|
+
throw new ValidationError("'inherit' is not allowed in root-level variables (nothing to inherit from)");
|
|
50
|
+
}
|
|
45
51
|
}
|
|
46
52
|
function validateGithubHosts(config) {
|
|
47
53
|
if (config.githubHosts === undefined)
|
|
@@ -63,6 +69,9 @@ function validateGithubHosts(config) {
|
|
|
63
69
|
}
|
|
64
70
|
}
|
|
65
71
|
function validatePrOptions(config) {
|
|
72
|
+
if (config.prOptions?.branch !== undefined) {
|
|
73
|
+
validateBranchName(config.prOptions.branch);
|
|
74
|
+
}
|
|
66
75
|
if (config.prOptions?.labels === undefined)
|
|
67
76
|
return;
|
|
68
77
|
if (!Array.isArray(config.prOptions.labels)) {
|
|
@@ -110,15 +119,18 @@ export function validateRawConfig(config) {
|
|
|
110
119
|
const hasCondGrpFiles = hasConditionalGroupFiles(config);
|
|
111
120
|
const hasCondGrpSettings = hasConditionalGroupSettingsPresent(config);
|
|
112
121
|
const hasCondGrpPR = hasConditionalGroupPR(config);
|
|
122
|
+
const hasSecrets = isPlainObject(config.secrets) && Object.keys(config.secrets).length > 0;
|
|
113
123
|
if (!hasFiles &&
|
|
114
124
|
!hasSettings &&
|
|
115
125
|
!hasGrpFiles &&
|
|
116
126
|
!hasGrpSettings &&
|
|
117
127
|
!hasCondGrpFiles &&
|
|
118
128
|
!hasCondGrpSettings &&
|
|
119
|
-
!hasCondGrpPR
|
|
120
|
-
|
|
121
|
-
|
|
129
|
+
!hasCondGrpPR &&
|
|
130
|
+
!hasSecrets) {
|
|
131
|
+
throw new ValidationError("Config requires at least one of: 'files', 'settings', or 'secrets'. " +
|
|
132
|
+
"Use 'files' to sync configuration files, 'settings' to manage repository settings, " +
|
|
133
|
+
"or 'secrets' to manage GitHub Actions secrets.");
|
|
122
134
|
}
|
|
123
135
|
validateRootFiles(config);
|
|
124
136
|
if (config.deleteOrphaned !== undefined &&
|
|
@@ -159,8 +171,111 @@ export function validateForSync(config) {
|
|
|
159
171
|
!hasCondGrpFiles &&
|
|
160
172
|
!hasCondGrpSettings &&
|
|
161
173
|
!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."
|
|
174
|
+
throw new ValidationError("Config requires at least one of: 'files' or 'settings' (rulesets, labels, variables, repo config). " +
|
|
175
|
+
"Use 'files' to sync configuration files, or 'settings' to manage repository settings. " +
|
|
176
|
+
"For secrets, use 'xfg secrets sync'.");
|
|
177
|
+
}
|
|
178
|
+
// Validate variable names across all settings
|
|
179
|
+
const allSettings = [
|
|
180
|
+
config.settings,
|
|
181
|
+
...config.repos.map((r) => r.settings),
|
|
182
|
+
...Object.values(config.groups ?? {}).map((g) => g.settings),
|
|
183
|
+
...(config.conditionalGroups ?? []).map((cg) => cg.settings),
|
|
184
|
+
];
|
|
185
|
+
for (const settings of allSettings) {
|
|
186
|
+
if (!settings?.variables)
|
|
187
|
+
continue;
|
|
188
|
+
const vars = settings.variables;
|
|
189
|
+
if (vars.deleteOrphaned !== undefined &&
|
|
190
|
+
typeof vars.deleteOrphaned !== "boolean") {
|
|
191
|
+
throw new ValidationError("variables.deleteOrphaned must be a boolean");
|
|
192
|
+
}
|
|
193
|
+
if (vars.inherit !== undefined && typeof vars.inherit !== "boolean") {
|
|
194
|
+
throw new ValidationError("variables.inherit must be a boolean");
|
|
195
|
+
}
|
|
196
|
+
for (const [name, value] of Object.entries(vars)) {
|
|
197
|
+
if (VARIABLE_RESERVED_KEYS.has(name))
|
|
198
|
+
continue;
|
|
199
|
+
validateVariableName(name);
|
|
200
|
+
if (value !== false && typeof value !== "string") {
|
|
201
|
+
throw new ValidationError(`Variable '${name}' must have a string value (got ${typeof value}). Quote numeric values in YAML: "${String(value)}".`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// Reject duplicate case-insensitive variable names
|
|
205
|
+
const seenVarNames = new Map();
|
|
206
|
+
for (const name of Object.keys(settings.variables)) {
|
|
207
|
+
if (VARIABLE_RESERVED_KEYS.has(name))
|
|
208
|
+
continue;
|
|
209
|
+
const upper = name.toUpperCase();
|
|
210
|
+
const existing = seenVarNames.get(upper);
|
|
211
|
+
if (existing) {
|
|
212
|
+
throw new ValidationError(`Duplicate variable name: '${name}' and '${existing}' collide (GitHub treats variable names case-insensitively).`);
|
|
213
|
+
}
|
|
214
|
+
seenVarNames.set(upper, name);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// Validate secret names and configs
|
|
218
|
+
validateSecretsConfig(config);
|
|
219
|
+
// Cross-validate: no overlap between global secret names and variable names
|
|
220
|
+
validateVariableSecretOverlaps(config);
|
|
221
|
+
}
|
|
222
|
+
export function validateVariableSecretOverlaps(config) {
|
|
223
|
+
if (!config.secrets)
|
|
224
|
+
return;
|
|
225
|
+
const { deleteOrphaned: _, ...secretEntries } = config.secrets;
|
|
226
|
+
// GitHub treats secret/variable names case-insensitively for collision purposes
|
|
227
|
+
const secretNames = new Set(Object.keys(secretEntries)
|
|
228
|
+
.filter((k) => typeof secretEntries[k] !== "boolean")
|
|
229
|
+
.map((n) => n.toUpperCase()));
|
|
230
|
+
if (secretNames.size === 0)
|
|
231
|
+
return;
|
|
232
|
+
// Check root-level variables
|
|
233
|
+
if (config.settings?.variables) {
|
|
234
|
+
const { deleteOrphaned: _rd, inherit: _ri, ...rootVarEntries } = config.settings.variables;
|
|
235
|
+
const rootVariableNames = Object.keys(rootVarEntries).filter((k) => typeof rootVarEntries[k] !== "boolean");
|
|
236
|
+
const overlapping = rootVariableNames.filter((n) => secretNames.has(n.toUpperCase()));
|
|
237
|
+
if (overlapping.length > 0) {
|
|
238
|
+
throw new ValidationError(`${overlapping.join(", ")} overlap between root variables and secrets. ` +
|
|
239
|
+
"GitHub does not allow variables and secrets with the same name.");
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
for (const repo of config.repos) {
|
|
243
|
+
const { deleteOrphaned: _d, inherit: _i, ...varEntries } = (repo.settings?.variables ?? {});
|
|
244
|
+
const variableNames = Object.keys(varEntries).filter((k) => typeof varEntries[k] !== "boolean");
|
|
245
|
+
const overlapping = variableNames.filter((n) => secretNames.has(n.toUpperCase()));
|
|
246
|
+
if (overlapping.length > 0) {
|
|
247
|
+
throw new ValidationError(`Repo '${repo.git}': ${overlapping.join(", ")} overlap between variables and secrets. ` +
|
|
248
|
+
"GitHub does not allow variables and secrets with the same name.");
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
// Check group-level variables
|
|
252
|
+
if (isPlainObject(config.groups)) {
|
|
253
|
+
for (const [groupName, group] of Object.entries(config.groups)) {
|
|
254
|
+
if (!group.settings?.variables)
|
|
255
|
+
continue;
|
|
256
|
+
const { deleteOrphaned: _gd, inherit: _gi, ...groupVarEntries } = group.settings.variables;
|
|
257
|
+
const groupVariableNames = Object.keys(groupVarEntries).filter((k) => typeof groupVarEntries[k] !== "boolean");
|
|
258
|
+
const overlapping = groupVariableNames.filter((n) => secretNames.has(n.toUpperCase()));
|
|
259
|
+
if (overlapping.length > 0) {
|
|
260
|
+
throw new ValidationError(`Group '${groupName}': ${overlapping.join(", ")} overlap between variables and secrets. ` +
|
|
261
|
+
"GitHub does not allow variables and secrets with the same name.");
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// Check conditional group-level variables
|
|
266
|
+
if (Array.isArray(config.conditionalGroups)) {
|
|
267
|
+
for (let i = 0; i < config.conditionalGroups.length; i++) {
|
|
268
|
+
const cg = config.conditionalGroups[i];
|
|
269
|
+
if (!cg.settings?.variables)
|
|
270
|
+
continue;
|
|
271
|
+
const { deleteOrphaned: _cd, inherit: _ci, ...cgVarEntries } = cg.settings.variables;
|
|
272
|
+
const cgVariableNames = Object.keys(cgVarEntries).filter((k) => typeof cgVarEntries[k] !== "boolean");
|
|
273
|
+
const overlapping = cgVariableNames.filter((n) => secretNames.has(n.toUpperCase()));
|
|
274
|
+
if (overlapping.length > 0) {
|
|
275
|
+
throw new ValidationError(`Conditional group ${i}: ${overlapping.join(", ")} overlap between variables and secrets. ` +
|
|
276
|
+
"GitHub does not allow variables and secrets with the same name.");
|
|
277
|
+
}
|
|
278
|
+
}
|
|
164
279
|
}
|
|
165
280
|
}
|
|
166
281
|
export function hasActionableSettings(settings) {
|
|
@@ -180,5 +295,63 @@ export function hasActionableSettings(settings) {
|
|
|
180
295
|
if (settings.codeScanning) {
|
|
181
296
|
return true;
|
|
182
297
|
}
|
|
298
|
+
if (settings.variables) {
|
|
299
|
+
const { deleteOrphaned, inherit: _i, ...entries } = settings.variables;
|
|
300
|
+
if (Object.keys(entries).length > 0 || deleteOrphaned === true) {
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
183
304
|
return false;
|
|
184
305
|
}
|
|
306
|
+
export function validateVariableName(name) {
|
|
307
|
+
if (!VARIABLE_NAME_PATTERN.test(name)) {
|
|
308
|
+
throw new ValidationError(`Variable name '${name}' contains invalid characters. Only alphanumeric and underscore allowed.`);
|
|
309
|
+
}
|
|
310
|
+
if (name.startsWith("GITHUB_")) {
|
|
311
|
+
throw new ValidationError(`Variable name '${name}' cannot start with 'GITHUB_' (reserved prefix).`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
export function validateSecretName(name) {
|
|
315
|
+
if (!VARIABLE_NAME_PATTERN.test(name)) {
|
|
316
|
+
throw new ValidationError(`Secret name '${name}' contains invalid characters. Only alphanumeric and underscore allowed.`);
|
|
317
|
+
}
|
|
318
|
+
if (name.startsWith("GITHUB_")) {
|
|
319
|
+
throw new ValidationError(`Secret name '${name}' cannot start with 'GITHUB_' (reserved prefix).`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
function validateSecretEntry(name, config) {
|
|
323
|
+
validateSecretName(name);
|
|
324
|
+
if (!config.env || typeof config.env !== "string") {
|
|
325
|
+
throw new ValidationError(`Secret '${name}' requires an 'env' field (string) specifying the environment variable source.`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
export function validateSecretsConfig(config) {
|
|
329
|
+
if (!config.secrets)
|
|
330
|
+
return;
|
|
331
|
+
const { deleteOrphaned, ...entries } = config.secrets;
|
|
332
|
+
// Reject 'deleteOrphaned' used as a secret name (it's a reserved peer key)
|
|
333
|
+
if (deleteOrphaned !== undefined && typeof deleteOrphaned !== "boolean") {
|
|
334
|
+
throw new ValidationError("'deleteOrphaned' is a reserved key in secrets config and cannot be used as a secret name.");
|
|
335
|
+
}
|
|
336
|
+
// Reject boolean true — only false (opt-out) is valid
|
|
337
|
+
for (const [name, value] of Object.entries(entries)) {
|
|
338
|
+
if (value === true) {
|
|
339
|
+
throw new ValidationError(`Secret '${name}' is set to true, which is not valid. Use false to opt out, or provide a SecretConfig object.`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// Reject duplicate case-insensitive secret names
|
|
343
|
+
const seen = new Map();
|
|
344
|
+
for (const name of Object.keys(entries)) {
|
|
345
|
+
const upper = name.toUpperCase();
|
|
346
|
+
const existing = seen.get(upper);
|
|
347
|
+
if (existing) {
|
|
348
|
+
throw new ValidationError(`Duplicate secret name: '${name}' and '${existing}' collide (GitHub treats secret names case-insensitively).`);
|
|
349
|
+
}
|
|
350
|
+
seen.set(upper, name);
|
|
351
|
+
}
|
|
352
|
+
for (const [name, value] of Object.entries(entries)) {
|
|
353
|
+
if (typeof value === "boolean")
|
|
354
|
+
continue;
|
|
355
|
+
validateSecretEntry(name, value);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { resolveExtendsChain } from "../extends-resolver.js";
|
|
2
2
|
import { isPlainObject } from "../../shared/type-guards.js";
|
|
3
3
|
import { ValidationError } from "../../shared/errors.js";
|
|
4
|
+
import { validateBranchName } from "../../shared/branch-validation.js";
|
|
4
5
|
import { validateFileConfigFields, validateSettings, buildRootSettingsContext, } from "./shared.js";
|
|
5
6
|
function validateGroupExtends(groupName, extends_, groupNames) {
|
|
6
7
|
if (typeof extends_ === "string") {
|
|
@@ -102,6 +103,9 @@ export function validateGroups(config) {
|
|
|
102
103
|
if (group.settings !== undefined) {
|
|
103
104
|
validateSettings(group.settings, `groups.${groupName}`, rootCtx);
|
|
104
105
|
}
|
|
106
|
+
if (group.prOptions?.branch !== undefined) {
|
|
107
|
+
validateBranchName(group.prOptions.branch);
|
|
108
|
+
}
|
|
105
109
|
}
|
|
106
110
|
validateNoCircularExtends(config.groups);
|
|
107
111
|
}
|
|
@@ -163,5 +167,8 @@ export function validateConditionalGroups(config) {
|
|
|
163
167
|
if (entry.settings !== undefined) {
|
|
164
168
|
validateSettings(entry.settings, ctx, rootCtx);
|
|
165
169
|
}
|
|
170
|
+
if (entry.prOptions?.branch !== undefined) {
|
|
171
|
+
validateBranchName(entry.prOptions.branch);
|
|
172
|
+
}
|
|
166
173
|
}
|
|
167
174
|
}
|
|
@@ -3,6 +3,7 @@ import { validateFileName } from "./file-validator.js";
|
|
|
3
3
|
import { isPlainObject } from "../../shared/type-guards.js";
|
|
4
4
|
import { escapeRegExp } from "../../shared/regex-utils.js";
|
|
5
5
|
import { ValidationError } from "../../shared/errors.js";
|
|
6
|
+
import { validateBranchName } from "../../shared/branch-validation.js";
|
|
6
7
|
import { validateFileConfigFields, validateSettings, buildRootSettingsContext, enrichSettingsContext, } from "./shared.js";
|
|
7
8
|
function isValidGitUrl(url) {
|
|
8
9
|
return /^git@[^:]+:.+$/.test(url) || /^https?:\/\/[^/]+\/.+$/.test(url);
|
|
@@ -156,10 +157,16 @@ function validateRepoSettingsEntry(config, repo, repoLabel) {
|
|
|
156
157
|
}
|
|
157
158
|
validateSettings(repo.settings, `Repo ${repoLabel}`, rootCtx);
|
|
158
159
|
}
|
|
160
|
+
function validateRepoPrOptions(repo) {
|
|
161
|
+
if (repo.prOptions?.branch !== undefined) {
|
|
162
|
+
validateBranchName(repo.prOptions.branch);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
159
165
|
export function validateRepoEntry(config, repo, index) {
|
|
160
166
|
const repoLabel = validateRepoGitField(repo, index);
|
|
161
167
|
validateRepoOrigins(config, repo, repoLabel);
|
|
162
168
|
validateRepoGroups(config, repo, index);
|
|
163
169
|
validateRepoFiles(config, repo, index, repoLabel);
|
|
164
170
|
validateRepoSettingsEntry(config, repo, repoLabel);
|
|
171
|
+
validateRepoPrOptions(repo);
|
|
165
172
|
}
|
|
@@ -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 {};
|