@aspruyt/xfg 6.1.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.
Files changed (174) hide show
  1. package/dist/cli/lifecycle-report-builder.d.ts +2 -2
  2. package/dist/cli/lifecycle-report-builder.js +3 -11
  3. package/dist/cli/program.d.ts +2 -1
  4. package/dist/cli/program.js +43 -6
  5. package/dist/cli/repo-sync-runner.d.ts +24 -0
  6. package/dist/cli/repo-sync-runner.js +156 -0
  7. package/dist/cli/results-collector.d.ts +1 -1
  8. package/dist/cli/results-collector.js +2 -2
  9. package/dist/cli/secrets-command.d.ts +25 -0
  10. package/dist/cli/secrets-command.js +75 -0
  11. package/dist/cli/settings-factories.d.ts +8 -0
  12. package/dist/cli/settings-factories.js +32 -0
  13. package/dist/cli/settings-report-builder.d.ts +7 -2
  14. package/dist/cli/settings-report-builder.js +28 -20
  15. package/dist/cli/settings-runner.d.ts +2 -0
  16. package/dist/cli/settings-runner.js +94 -0
  17. package/dist/cli/sync-command.d.ts +1 -1
  18. package/dist/cli/sync-command.js +31 -372
  19. package/dist/cli/sync-report-builder.d.ts +1 -1
  20. package/dist/cli/sync-utils.d.ts +8 -0
  21. package/dist/cli/sync-utils.js +36 -0
  22. package/dist/cli/types.d.ts +8 -8
  23. package/dist/cli/unified-summary.d.ts +1 -3
  24. package/dist/cli/unified-summary.js +7 -5
  25. package/dist/cli.js +2 -1
  26. package/dist/{shared → config}/env.js +2 -2
  27. package/dist/config/extends-resolver.js +4 -3
  28. package/dist/config/file-reference-resolver.js +4 -2
  29. package/dist/config/formatter.js +1 -0
  30. package/dist/config/index.d.ts +2 -2
  31. package/dist/config/index.js +1 -1
  32. package/dist/config/loader.js +30 -6
  33. package/dist/config/merge.d.ts +11 -1
  34. package/dist/config/merge.js +78 -6
  35. package/dist/config/normalizer.js +129 -49
  36. package/dist/config/types.d.ts +20 -0
  37. package/dist/config/validator.d.ts +5 -4
  38. package/dist/config/validator.js +187 -614
  39. package/dist/config/validators/file-validator.d.ts +2 -1
  40. package/dist/config/validators/file-validator.js +9 -1
  41. package/dist/config/validators/group-validator.d.ts +3 -0
  42. package/dist/config/validators/group-validator.js +167 -0
  43. package/dist/config/validators/repo-entry-validator.d.ts +2 -0
  44. package/dist/config/validators/repo-entry-validator.js +165 -0
  45. package/dist/config/validators/repo-settings-validator.js +18 -7
  46. package/dist/config/validators/ruleset-validator.js +2 -5
  47. package/dist/config/validators/shared.d.ts +11 -0
  48. package/dist/config/validators/shared.js +242 -0
  49. package/dist/lifecycle/ado-migration-source.js +2 -4
  50. package/dist/lifecycle/github-lifecycle-provider.d.ts +7 -11
  51. package/dist/lifecycle/github-lifecycle-provider.js +125 -136
  52. package/dist/lifecycle/{lifecycle-helpers.d.ts → helpers.d.ts} +5 -1
  53. package/dist/lifecycle/{lifecycle-helpers.js → helpers.js} +9 -8
  54. package/dist/lifecycle/index.d.ts +2 -2
  55. package/dist/lifecycle/index.js +1 -1
  56. package/dist/lifecycle/repo-lifecycle-factory.d.ts +2 -2
  57. package/dist/output/github-summary.js +2 -3
  58. package/dist/output/index.d.ts +4 -0
  59. package/dist/output/index.js +4 -0
  60. package/dist/output/lifecycle-report.d.ts +1 -1
  61. package/dist/output/lifecycle-report.js +5 -0
  62. package/dist/output/settings-report.d.ts +11 -0
  63. package/dist/output/settings-report.js +24 -0
  64. package/dist/output/sync-report.d.ts +25 -3
  65. package/dist/output/sync-report.js +11 -11
  66. package/dist/secrets/encryption.d.ts +9 -0
  67. package/dist/secrets/encryption.js +29 -0
  68. package/dist/secrets/github-secrets-strategy.d.ts +17 -0
  69. package/dist/secrets/github-secrets-strategy.js +38 -0
  70. package/dist/secrets/index.d.ts +5 -0
  71. package/dist/secrets/index.js +3 -0
  72. package/dist/secrets/processor.d.ts +31 -0
  73. package/dist/secrets/processor.js +115 -0
  74. package/dist/secrets/types.d.ts +21 -0
  75. package/dist/settings/base-processor.d.ts +18 -7
  76. package/dist/settings/base-processor.js +26 -5
  77. package/dist/settings/code-scanning/diff.js +2 -2
  78. package/dist/settings/code-scanning/formatter.d.ts +2 -6
  79. package/dist/settings/code-scanning/formatter.js +2 -25
  80. package/dist/settings/code-scanning/github-code-scanning-strategy.d.ts +3 -7
  81. package/dist/settings/code-scanning/github-code-scanning-strategy.js +2 -2
  82. package/dist/settings/code-scanning/processor.js +6 -4
  83. package/dist/settings/code-scanning/types.d.ts +10 -8
  84. package/dist/settings/index.d.ts +1 -0
  85. package/dist/settings/index.js +2 -0
  86. package/dist/settings/labels/github-labels-strategy.d.ts +3 -11
  87. package/dist/settings/labels/types.d.ts +12 -10
  88. package/dist/settings/repo-settings/diff.d.ts +1 -1
  89. package/dist/settings/repo-settings/diff.js +1 -1
  90. package/dist/settings/repo-settings/formatter.d.ts +2 -6
  91. package/dist/settings/repo-settings/formatter.js +4 -23
  92. package/dist/settings/repo-settings/github-repo-settings-strategy.d.ts +2 -2
  93. package/dist/settings/repo-settings/github-repo-settings-strategy.js +8 -7
  94. package/dist/settings/repo-settings/processor.js +11 -11
  95. package/dist/settings/repo-settings/types.d.ts +2 -2
  96. package/dist/settings/rulesets/diff-algorithm.js +4 -2
  97. package/dist/settings/rulesets/diff.js +2 -51
  98. package/dist/settings/rulesets/formatter.js +4 -0
  99. package/dist/settings/rulesets/github-ruleset-strategy.d.ts +3 -3
  100. package/dist/settings/rulesets/github-ruleset-strategy.js +4 -6
  101. package/dist/settings/rulesets/index.d.ts +1 -1
  102. package/dist/settings/rulesets/index.js +0 -2
  103. package/dist/settings/rulesets/processor.js +1 -1
  104. package/dist/settings/rulesets/types.d.ts +6 -2
  105. package/dist/settings/variables/diff.d.ts +10 -0
  106. package/dist/settings/variables/diff.js +39 -0
  107. package/dist/settings/variables/formatter.d.ts +16 -0
  108. package/dist/settings/variables/formatter.js +70 -0
  109. package/dist/settings/variables/github-variables-strategy.d.ts +17 -0
  110. package/dist/settings/variables/github-variables-strategy.js +40 -0
  111. package/dist/settings/variables/index.d.ts +4 -0
  112. package/dist/settings/variables/index.js +2 -0
  113. package/dist/settings/variables/processor.d.ts +19 -0
  114. package/dist/settings/variables/processor.js +60 -0
  115. package/dist/settings/variables/types.d.ts +18 -0
  116. package/dist/settings/variables/types.js +1 -0
  117. package/dist/shared/command-executor.d.ts +4 -4
  118. package/dist/shared/command-executor.js +9 -7
  119. package/dist/shared/diff-format.d.ts +1 -0
  120. package/dist/shared/diff-format.js +10 -0
  121. package/dist/shared/env-resolver.d.ts +16 -0
  122. package/dist/shared/env-resolver.js +33 -0
  123. package/dist/shared/errors.d.ts +7 -4
  124. package/dist/shared/errors.js +8 -8
  125. package/dist/shared/gh-api-utils.d.ts +3 -34
  126. package/dist/shared/gh-api-utils.js +23 -53
  127. package/dist/shared/gh-token-utils.d.ts +26 -0
  128. package/dist/shared/gh-token-utils.js +32 -0
  129. package/dist/shared/json-utils.js +1 -1
  130. package/dist/shared/regex-utils.d.ts +1 -0
  131. package/dist/shared/regex-utils.js +3 -0
  132. package/dist/shared/retry-utils.d.ts +1 -0
  133. package/dist/shared/retry-utils.js +13 -7
  134. package/dist/sync/auth-options-builder.js +1 -1
  135. package/dist/sync/branch-manager.js +5 -3
  136. package/dist/sync/commit-push-manager.js +2 -3
  137. package/dist/sync/diff-utils.d.ts +0 -1
  138. package/dist/sync/diff-utils.js +5 -10
  139. package/dist/sync/file-sync-orchestrator.js +0 -2
  140. package/dist/sync/file-writer.d.ts +3 -0
  141. package/dist/sync/file-writer.js +84 -81
  142. package/dist/sync/index.d.ts +0 -1
  143. package/dist/sync/index.js +0 -1
  144. package/dist/sync/manifest.js +1 -1
  145. package/dist/sync/pr-merge-handler.js +6 -6
  146. package/dist/sync/sync-workflow.js +1 -1
  147. package/dist/sync/types.d.ts +2 -2
  148. package/dist/vcs/ado-pr-strategy.d.ts +3 -5
  149. package/dist/vcs/ado-pr-strategy.js +131 -33
  150. package/dist/vcs/authenticated-git-ops.js +45 -23
  151. package/dist/vcs/git-commit-strategy.js +10 -6
  152. package/dist/vcs/git-ops.js +30 -24
  153. package/dist/vcs/github-pr-strategy.d.ts +3 -2
  154. package/dist/vcs/github-pr-strategy.js +80 -30
  155. package/dist/vcs/gitlab-pr-strategy.d.ts +2 -5
  156. package/dist/vcs/gitlab-pr-strategy.js +88 -87
  157. package/dist/vcs/graphql-commit-strategy.d.ts +1 -5
  158. package/dist/vcs/graphql-commit-strategy.js +21 -37
  159. package/dist/vcs/pr-creator.js +9 -2
  160. package/dist/vcs/pr-strategy.d.ts +2 -3
  161. package/dist/vcs/pr-strategy.js +0 -1
  162. package/dist/vcs/types.d.ts +9 -5
  163. package/package.json +7 -5
  164. package/dist/config/validators/index.d.ts +0 -3
  165. package/dist/config/validators/index.js +0 -6
  166. package/dist/output/types.d.ts +0 -20
  167. package/dist/shared/shell-utils.d.ts +0 -6
  168. package/dist/shared/shell-utils.js +0 -17
  169. /package/dist/{shared → config}/env.d.ts +0 -0
  170. /package/dist/lifecycle/{lifecycle-formatter.d.ts → formatter.d.ts} +0 -0
  171. /package/dist/lifecycle/{lifecycle-formatter.js → formatter.js} +0 -0
  172. /package/dist/{output → secrets}/types.js +0 -0
  173. /package/dist/{vcs → shared}/sanitize-utils.d.ts +0 -0
  174. /package/dist/{vcs → shared}/sanitize-utils.js +0 -0
@@ -1,259 +1,13 @@
1
- import { resolveExtendsChain, expandRepoGroups } from "./extends-resolver.js";
2
- import { isTextContent, isObjectContent, isStructuredFileExtension, validateFileName, VALID_STRATEGIES, } from "./validators/file-validator.js";
3
- import { validateRepoSettings } from "./validators/repo-settings-validator.js";
4
- import { validateRuleset } from "./validators/ruleset-validator.js";
5
- import { escapeRegExp } from "../shared/shell-utils.js";
1
+ import { validateFileName } from "./validators/file-validator.js";
6
2
  import { isPlainObject } from "../shared/type-guards.js";
7
3
  import { ValidationError } from "../shared/errors.js";
8
- // Pattern for valid config ID: alphanumeric, hyphens, underscores
4
+ import { validateFileConfigFields, validateSettings, } from "./validators/shared.js";
5
+ import { validateGroups, validateConditionalGroups, } from "./validators/group-validator.js";
6
+ import { validateRepoEntry } from "./validators/repo-entry-validator.js";
9
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"]);
10
10
  const CONFIG_ID_MAX_LENGTH = 64;
11
- /**
12
- * Check if a string looks like a valid git URL.
13
- * Supports SSH (git@host:path) and HTTPS (https://host/path) formats.
14
- */
15
- function isValidGitUrl(url) {
16
- // SSH format: git@hostname:path OR HTTPS format: https://hostname/path
17
- return /^git@[^:]+:.+$/.test(url) || /^https?:\/\/[^/]+\/.+$/.test(url);
18
- }
19
- /**
20
- * Check if a git URL points to GitHub (github.com).
21
- * Used to reject GitHub URLs as migration sources (not supported).
22
- */
23
- function isGitHubUrl(url, githubHosts) {
24
- const hosts = ["github.com", ...(githubHosts ?? [])];
25
- for (const host of hosts) {
26
- if (url.startsWith(`git@${host}:`) ||
27
- url.match(new RegExp(`^https?://${escapeRegExp(host)}/`))) {
28
- return true;
29
- }
30
- }
31
- return false;
32
- }
33
- function getGitDisplayName(git) {
34
- if (Array.isArray(git)) {
35
- return git[0] || "unknown";
36
- }
37
- return git;
38
- }
39
- /**
40
- * Validate file config fields shared between root files and per-repo overrides.
41
- */
42
- function validateFileConfigFields(fileConfig, fileName, context) {
43
- if (fileConfig.content !== undefined) {
44
- const hasText = isTextContent(fileConfig.content);
45
- const hasObject = isObjectContent(fileConfig.content);
46
- if (!hasText && !hasObject) {
47
- throw new ValidationError(`${context} file '${fileName}' content must be an object, string, or array of strings`);
48
- }
49
- const isStructured = isStructuredFileExtension(fileName);
50
- if (isStructured && hasText) {
51
- throw new ValidationError(`${context} file '${fileName}' has JSON/YAML extension but string content. Use object content for structured files.`);
52
- }
53
- if (!isStructured && hasObject) {
54
- throw new ValidationError(`${context} file '${fileName}' has text extension but object content. Use string or string[] for text files, or use .json/.yaml/.yml extension.`);
55
- }
56
- }
57
- if (fileConfig.mergeStrategy !== undefined &&
58
- !VALID_STRATEGIES.includes(fileConfig.mergeStrategy)) {
59
- throw new ValidationError(`${context} file '${fileName}' has invalid mergeStrategy: ${fileConfig.mergeStrategy}. Must be one of: ${VALID_STRATEGIES.join(", ")}`);
60
- }
61
- const booleanFields = [
62
- "createOnly",
63
- "executable",
64
- "template",
65
- "deleteOrphaned",
66
- ];
67
- for (const field of booleanFields) {
68
- if (fileConfig[field] !== undefined &&
69
- typeof fileConfig[field] !== "boolean") {
70
- throw new ValidationError(`${context} file '${fileName}' ${field} must be a boolean`);
71
- }
72
- }
73
- const stringFields = ["schemaUrl"];
74
- for (const field of stringFields) {
75
- if (fileConfig[field] !== undefined &&
76
- typeof fileConfig[field] !== "string") {
77
- throw new ValidationError(`${context} file '${fileName}' ${field} must be a string`);
78
- }
79
- }
80
- if (fileConfig.header !== undefined) {
81
- if (typeof fileConfig.header !== "string" &&
82
- (!Array.isArray(fileConfig.header) ||
83
- !fileConfig.header.every((h) => typeof h === "string"))) {
84
- throw new ValidationError(`${context} file '${fileName}' header must be a string or array of strings`);
85
- }
86
- }
87
- if (fileConfig.vars !== undefined) {
88
- if (!isPlainObject(fileConfig.vars)) {
89
- throw new ValidationError(`${context} file '${fileName}' vars must be an object with string values`);
90
- }
91
- for (const [key, value] of Object.entries(fileConfig.vars)) {
92
- if (typeof value !== "string") {
93
- throw new ValidationError(`${context} file '${fileName}' vars.${key} must be a string`);
94
- }
95
- }
96
- }
97
- }
98
- /**
99
- * Validates a single label configuration.
100
- */
101
- function validateLabel(label, name, context) {
102
- if (!isPlainObject(label)) {
103
- throw new ValidationError(`${context}: label '${name}' must be an object`);
104
- }
105
- const l = label;
106
- if (typeof l.color !== "string" || !/^#?[0-9a-fA-F]{6}$/.test(l.color)) {
107
- throw new ValidationError(`${context}: label '${name}' color must be a 6-character hex code (with or without #)`);
108
- }
109
- if (l.description !== undefined) {
110
- if (typeof l.description !== "string") {
111
- throw new ValidationError(`${context}: label '${name}' description must be a string`);
112
- }
113
- if (l.description.length > 100) {
114
- throw new ValidationError(`${context}: label '${name}' description exceeds 100 characters (GitHub limit)`);
115
- }
116
- }
117
- if (l.new_name !== undefined && typeof l.new_name !== "string") {
118
- throw new ValidationError(`${context}: label '${name}' new_name must be a string`);
119
- }
120
- }
121
- function buildRootSettingsContext(config) {
122
- return {
123
- rulesetNames: config.settings?.rulesets
124
- ? Object.keys(config.settings.rulesets).filter((k) => k !== "inherit")
125
- : [],
126
- hasRepoSettings: config.settings?.repo !== undefined && config.settings.repo !== false,
127
- hasCodeScanningSettings: config.settings?.codeScanning !== undefined &&
128
- config.settings.codeScanning !== false,
129
- labelNames: config.settings?.labels
130
- ? Object.keys(config.settings.labels).filter((k) => k !== "inherit")
131
- : [],
132
- };
133
- }
134
- /**
135
- * Validates settings object containing rulesets, labels, and repo settings.
136
- */
137
- function validateSettingsRulesets(settings, context, rootCtx) {
138
- if (settings.rulesets === undefined)
139
- return;
140
- if (!isPlainObject(settings.rulesets)) {
141
- throw new ValidationError(`${context}: rulesets must be an object`);
142
- }
143
- const rulesets = settings.rulesets;
144
- for (const [name, ruleset] of Object.entries(rulesets)) {
145
- if (name === "inherit")
146
- continue;
147
- if (ruleset === false) {
148
- if (rootCtx && !rootCtx.rulesetNames.includes(name)) {
149
- throw new ValidationError(`${context}: Cannot opt out of '${name}' - not defined in root settings.rulesets`);
150
- }
151
- continue;
152
- }
153
- validateRuleset(ruleset, name, context);
154
- }
155
- }
156
- function validateSettingsLabels(settings, context, rootCtx) {
157
- if (settings.labels === undefined)
158
- return;
159
- if (!isPlainObject(settings.labels)) {
160
- throw new ValidationError(`${context}: labels must be an object`);
161
- }
162
- const labels = settings.labels;
163
- for (const [name, label] of Object.entries(labels)) {
164
- if (name === "inherit")
165
- continue;
166
- if (label === false) {
167
- if (rootCtx && !rootCtx.labelNames.includes(name)) {
168
- throw new ValidationError(`${context}: Cannot opt out of label '${name}' - not defined in root settings.labels`);
169
- }
170
- continue;
171
- }
172
- validateLabel(label, name, context);
173
- }
174
- }
175
- function validateSettingsDeleteOrphaned(settings, context) {
176
- if (settings.deleteOrphaned !== undefined &&
177
- typeof settings.deleteOrphaned !== "boolean") {
178
- throw new ValidationError(`${context}: settings.deleteOrphaned must be a boolean`);
179
- }
180
- }
181
- function validateSettingsRepo(settings, context, rootCtx) {
182
- if (settings.repo === undefined)
183
- return;
184
- if (settings.repo === false) {
185
- if (!rootCtx) {
186
- throw new ValidationError(`${context}: repo: false is not valid at root level. Define repo settings or remove the field.`);
187
- }
188
- if (!rootCtx.hasRepoSettings) {
189
- throw new ValidationError(`${context}: Cannot opt out of repo settings — not defined in root settings.repo`);
190
- }
191
- return;
192
- }
193
- validateRepoSettings(settings.repo, context);
194
- }
195
- function validateSettingsCodeScanning(settings, context, rootCtx) {
196
- if (settings.codeScanning === undefined)
197
- return;
198
- if (settings.codeScanning === false) {
199
- if (!rootCtx) {
200
- throw new ValidationError(`${context}: codeScanning: false is not valid at root level. Define codeScanning settings or remove the field.`);
201
- }
202
- if (!rootCtx.hasCodeScanningSettings) {
203
- throw new ValidationError(`${context}: Cannot opt out of code scanning settings — not defined in root settings.codeScanning`);
204
- }
205
- return;
206
- }
207
- validateCodeScanningSettings(settings.codeScanning, `${context} codeScanning`);
208
- }
209
- function validateSettings(settings, context, rootCtx) {
210
- if (!isPlainObject(settings)) {
211
- throw new ValidationError(`${context}: settings must be an object`);
212
- }
213
- validateSettingsRulesets(settings, context, rootCtx);
214
- validateSettingsLabels(settings, context, rootCtx);
215
- validateSettingsDeleteOrphaned(settings, context);
216
- validateSettingsRepo(settings, context, rootCtx);
217
- validateSettingsCodeScanning(settings, context, rootCtx);
218
- }
219
- const VALID_CODE_SCANNING_STATES = ["configured", "not-configured"];
220
- const VALID_CODE_SCANNING_QUERY_SUITES = ["default", "extended"];
221
- const VALID_CODE_SCANNING_LANGUAGES = [
222
- "actions",
223
- "c-cpp",
224
- "csharp",
225
- "go",
226
- "java-kotlin",
227
- "javascript-typescript",
228
- "python",
229
- "ruby",
230
- "swift",
231
- ];
232
- function validateCodeScanningSettings(settings, context) {
233
- if (!isPlainObject(settings)) {
234
- throw new ValidationError(`${context}: must be an object`);
235
- }
236
- if (settings.state === undefined) {
237
- throw new ValidationError(`${context}: state is required`);
238
- }
239
- if (!VALID_CODE_SCANNING_STATES.includes(settings.state)) {
240
- throw new ValidationError(`${context}: state must be one of: ${VALID_CODE_SCANNING_STATES.join(", ")}`);
241
- }
242
- if (settings.querySuite !== undefined &&
243
- !VALID_CODE_SCANNING_QUERY_SUITES.includes(settings.querySuite)) {
244
- throw new ValidationError(`${context}: querySuite must be one of: ${VALID_CODE_SCANNING_QUERY_SUITES.join(", ")}`);
245
- }
246
- if (settings.languages !== undefined) {
247
- if (!Array.isArray(settings.languages)) {
248
- throw new ValidationError(`${context}: languages must be an array`);
249
- }
250
- for (const lang of settings.languages) {
251
- if (!VALID_CODE_SCANNING_LANGUAGES.includes(lang)) {
252
- throw new ValidationError(`${context}: invalid language "${lang}". Valid languages: ${VALID_CODE_SCANNING_LANGUAGES.join(", ")}`);
253
- }
254
- }
255
- }
256
- }
257
11
  function validateConfigId(config) {
258
12
  if (!config.id || typeof config.id !== "string") {
259
13
  throw new ValidationError("Config requires an 'id' field. This unique identifier is used to namespace managed files in .xfg.json");
@@ -290,6 +44,9 @@ function validateRootSettings(config) {
290
44
  if (config.settings.labels && "inherit" in config.settings.labels) {
291
45
  throw new ValidationError("'inherit' is a reserved key and cannot be used as a label name");
292
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
+ }
293
50
  }
294
51
  function validateGithubHosts(config) {
295
52
  if (config.githubHosts === undefined)
@@ -322,352 +79,6 @@ function validatePrOptions(config) {
322
79
  }
323
80
  }
324
81
  }
325
- /**
326
- * Validates the extends field on a single group definition.
327
- * Checks type, self-reference, and that all referenced groups exist.
328
- */
329
- function validateGroupExtends(groupName, extends_, groupNames) {
330
- // Type check
331
- if (typeof extends_ === "string") {
332
- if (extends_.length === 0) {
333
- throw new ValidationError(`groups.${groupName}: 'extends' must be a non-empty string or array of strings`);
334
- }
335
- // Self-reference
336
- if (extends_ === groupName) {
337
- throw new ValidationError(`groups.${groupName}: extends cannot reference itself`);
338
- }
339
- // Existence
340
- if (!groupNames.has(extends_)) {
341
- throw new ValidationError(`groups.${groupName}: extends references undefined group '${extends_}'`);
342
- }
343
- }
344
- else if (Array.isArray(extends_)) {
345
- if (extends_.length === 0) {
346
- throw new ValidationError(`groups.${groupName}: 'extends' must be a non-empty string or array of strings`);
347
- }
348
- const seen = new Set();
349
- for (const entry of extends_) {
350
- if (typeof entry !== "string") {
351
- throw new ValidationError(`groups.${groupName}: 'extends' array entries must be strings`);
352
- }
353
- if (entry.length === 0) {
354
- throw new ValidationError(`groups.${groupName}: 'extends' array entries must be non-empty strings`);
355
- }
356
- if (entry === groupName) {
357
- throw new ValidationError(`groups.${groupName}: extends cannot reference itself`);
358
- }
359
- if (!groupNames.has(entry)) {
360
- throw new ValidationError(`groups.${groupName}: extends references undefined group '${entry}'`);
361
- }
362
- if (seen.has(entry)) {
363
- throw new ValidationError(`groups.${groupName}: duplicate '${entry}' in extends`);
364
- }
365
- seen.add(entry);
366
- }
367
- }
368
- else {
369
- throw new ValidationError(`groups.${groupName}: 'extends' must be a non-empty string or array of strings`);
370
- }
371
- }
372
- /**
373
- * Detects circular extends chains across all groups.
374
- * Reuses resolveExtendsChain from extends-resolver.ts to avoid
375
- * duplicating the chain-walking logic. Converts thrown errors
376
- * to ValidationError.
377
- */
378
- function validateNoCircularExtends(groups) {
379
- for (const name of Object.keys(groups)) {
380
- if (!groups[name].extends)
381
- continue;
382
- try {
383
- resolveExtendsChain(name, groups);
384
- }
385
- catch (error) {
386
- throw new ValidationError(error instanceof Error ? error.message : String(error));
387
- }
388
- }
389
- }
390
- function validateGroups(config) {
391
- if (config.groups === undefined)
392
- return;
393
- if (!isPlainObject(config.groups)) {
394
- throw new ValidationError("groups must be an object");
395
- }
396
- const rootCtx = buildRootSettingsContext(config);
397
- const groupNames = new Set(Object.keys(config.groups));
398
- for (const [groupName, group] of Object.entries(config.groups)) {
399
- if (groupName === "inherit") {
400
- throw new ValidationError("'inherit' is a reserved key and cannot be used as a group name");
401
- }
402
- if (groupName === "extends") {
403
- throw new ValidationError("'extends' is a reserved key and cannot be used as a group name");
404
- }
405
- // Validate extends field
406
- if (group.extends !== undefined) {
407
- validateGroupExtends(groupName, group.extends, groupNames);
408
- }
409
- if (group.files) {
410
- for (const [fileName, fileConfig] of Object.entries(group.files)) {
411
- if (fileName === "inherit")
412
- continue;
413
- if (fileConfig === false)
414
- continue;
415
- if (fileConfig === undefined)
416
- continue;
417
- validateFileConfigFields(fileConfig, fileName, `groups.${groupName}:`);
418
- }
419
- }
420
- if (group.settings !== undefined) {
421
- validateSettings(group.settings, `groups.${groupName}`, rootCtx);
422
- }
423
- }
424
- // Validate no circular extends after individual validation
425
- validateNoCircularExtends(config.groups);
426
- }
427
- function validateGroupRefArray(arr, fieldName, ctx, groupNames) {
428
- if (!Array.isArray(arr) || arr.length === 0) {
429
- throw new ValidationError(`${ctx}: '${fieldName}' must be a non-empty array of strings`);
430
- }
431
- const seen = new Set();
432
- for (const name of arr) {
433
- if (typeof name !== "string") {
434
- throw new ValidationError(`${ctx}: '${fieldName}' entries must be strings`);
435
- }
436
- if (!groupNames.includes(name)) {
437
- throw new ValidationError(`${ctx}: group '${name}' in ${fieldName} is not defined in root 'groups'`);
438
- }
439
- if (seen.has(name)) {
440
- throw new ValidationError(`${ctx}: duplicate group '${name}' in ${fieldName}`);
441
- }
442
- seen.add(name);
443
- }
444
- }
445
- function validateConditionalGroups(config) {
446
- if (config.conditionalGroups === undefined)
447
- return;
448
- if (!Array.isArray(config.conditionalGroups)) {
449
- throw new ValidationError("conditionalGroups must be an array");
450
- }
451
- const rootCtx = buildRootSettingsContext(config);
452
- const groupNames = config.groups ? Object.keys(config.groups) : [];
453
- for (let i = 0; i < config.conditionalGroups.length; i++) {
454
- const entry = config.conditionalGroups[i];
455
- const ctx = `conditionalGroups[${i}]`;
456
- // Validate 'when' clause
457
- if (!entry.when || !isPlainObject(entry.when)) {
458
- throw new ValidationError(`${ctx}: 'when' is required and must be an object`);
459
- }
460
- const { allOf, anyOf, noneOf } = entry.when;
461
- if (!allOf && !anyOf && !noneOf) {
462
- throw new ValidationError(`${ctx}: 'when' must have at least one of 'allOf', 'anyOf', or 'noneOf'`);
463
- }
464
- if (allOf !== undefined) {
465
- validateGroupRefArray(allOf, "allOf", ctx, groupNames);
466
- }
467
- if (anyOf !== undefined) {
468
- validateGroupRefArray(anyOf, "anyOf", ctx, groupNames);
469
- }
470
- if (noneOf !== undefined) {
471
- validateGroupRefArray(noneOf, "noneOf", ctx, groupNames);
472
- }
473
- // Cross-operator overlap: noneOf must not share groups with allOf or anyOf
474
- if (noneOf) {
475
- const noneOfSet = new Set(noneOf);
476
- if (allOf) {
477
- for (const g of allOf) {
478
- if (noneOfSet.has(g)) {
479
- throw new ValidationError(`${ctx}: noneOf group '${g}' overlaps with allOf (contradictory condition)`);
480
- }
481
- }
482
- }
483
- if (anyOf) {
484
- for (const g of anyOf) {
485
- if (noneOfSet.has(g)) {
486
- throw new ValidationError(`${ctx}: noneOf group '${g}' overlaps with anyOf (contradictory condition)`);
487
- }
488
- }
489
- }
490
- }
491
- // Validate files
492
- if (entry.files) {
493
- for (const [fileName, fileConfig] of Object.entries(entry.files)) {
494
- if (fileName === "inherit")
495
- continue;
496
- if (fileConfig === false)
497
- continue;
498
- if (fileConfig === undefined)
499
- continue;
500
- validateFileConfigFields(fileConfig, fileName, `${ctx}:`);
501
- }
502
- }
503
- // Validate settings
504
- if (entry.settings !== undefined) {
505
- validateSettings(entry.settings, ctx, rootCtx);
506
- }
507
- }
508
- }
509
- function validateRepoGitField(repo, index) {
510
- if (!repo.git) {
511
- throw new ValidationError(`Repo at index ${index} missing required field: git`);
512
- }
513
- if (Array.isArray(repo.git) && repo.git.length === 0) {
514
- throw new ValidationError(`Repo at index ${index} has empty git array`);
515
- }
516
- return getGitDisplayName(repo.git);
517
- }
518
- function validateRepoOrigins(config, repo, repoLabel) {
519
- if (repo.upstream !== undefined && repo.source !== undefined) {
520
- throw new ValidationError(`Repo ${repoLabel}: 'upstream' and 'source' are mutually exclusive. ` +
521
- `Use 'upstream' to fork, or 'source' to migrate, not both.`);
522
- }
523
- if (repo.upstream !== undefined) {
524
- if (typeof repo.upstream !== "string") {
525
- throw new ValidationError(`Repo ${repoLabel}: 'upstream' must be a string`);
526
- }
527
- if (!isValidGitUrl(repo.upstream)) {
528
- throw new ValidationError(`Repo ${repoLabel}: 'upstream' must be a valid git URL ` +
529
- `(SSH: git@host:path or HTTPS: https://host/path)`);
530
- }
531
- }
532
- if (repo.source !== undefined) {
533
- if (typeof repo.source !== "string") {
534
- throw new ValidationError(`Repo ${repoLabel}: 'source' must be a string`);
535
- }
536
- if (!isValidGitUrl(repo.source)) {
537
- throw new ValidationError(`Repo ${repoLabel}: 'source' must be a valid git URL ` +
538
- `(SSH: git@host:path or HTTPS: https://host/path)`);
539
- }
540
- if (isGitHubUrl(repo.source, config.githubHosts)) {
541
- throw new ValidationError(`Repo ${repoLabel}: 'source' cannot be a GitHub URL. ` +
542
- `Migration from GitHub is not supported. Currently supported sources: Azure DevOps`);
543
- }
544
- }
545
- }
546
- function validateRepoGroups(config, repo, index) {
547
- if (repo.groups === undefined)
548
- return;
549
- if (!Array.isArray(repo.groups) ||
550
- !repo.groups.every((g) => typeof g === "string")) {
551
- throw new ValidationError(`Repo at index ${index}: groups must be an array of strings`);
552
- }
553
- const seen = new Set();
554
- for (const groupName of repo.groups) {
555
- if (!config.groups || !config.groups[groupName]) {
556
- throw new ValidationError(`Repo at index ${index}: group '${groupName}' is not defined in root 'groups'`);
557
- }
558
- if (seen.has(groupName)) {
559
- throw new ValidationError(`Repo at index ${index}: duplicate group '${groupName}'`);
560
- }
561
- seen.add(groupName);
562
- }
563
- }
564
- function validateRepoFiles(config, repo, index, repoLabel) {
565
- if (!repo.files)
566
- return;
567
- if (!isPlainObject(repo.files)) {
568
- throw new ValidationError(`Repo at index ${index}: files must be an object`);
569
- }
570
- const knownFiles = new Set(config.files ? Object.keys(config.files) : []);
571
- if (repo.groups && config.groups) {
572
- const expandedGroups = expandRepoGroups(repo.groups, config.groups);
573
- for (const groupName of expandedGroups) {
574
- const group = config.groups[groupName];
575
- if (group?.files) {
576
- for (const fileName of Object.keys(group.files)) {
577
- if (fileName !== "inherit")
578
- knownFiles.add(fileName);
579
- }
580
- }
581
- }
582
- }
583
- if (config.conditionalGroups) {
584
- for (const cg of config.conditionalGroups) {
585
- if (cg.files) {
586
- for (const fileName of Object.keys(cg.files)) {
587
- if (fileName !== "inherit")
588
- knownFiles.add(fileName);
589
- }
590
- }
591
- }
592
- }
593
- for (const fileName of Object.keys(repo.files)) {
594
- if (fileName === "inherit") {
595
- const inheritValue = repo.files.inherit;
596
- if (typeof inheritValue !== "boolean") {
597
- throw new ValidationError(`Repo at index ${index}: files.inherit must be a boolean`);
598
- }
599
- continue;
600
- }
601
- if (!knownFiles.has(fileName)) {
602
- const fileEntry = repo.files[fileName];
603
- const isStandaloneDefinition = fileEntry != null &&
604
- fileEntry !== false &&
605
- typeof fileEntry === "object" &&
606
- "content" in fileEntry &&
607
- fileEntry.content !== undefined &&
608
- fileEntry.content !== null;
609
- if (!isStandaloneDefinition) {
610
- throw new ValidationError(`Repo at index ${index} references undefined file '${fileName}'. File must be defined in root 'files' object or in a referenced group, or provide content inline.`);
611
- }
612
- validateFileName(fileName);
613
- }
614
- const fileOverride = repo.files[fileName];
615
- if (fileOverride === false) {
616
- continue;
617
- }
618
- if (fileOverride.override && !fileOverride.content) {
619
- throw new ValidationError(`Repo ${repoLabel} has override: true for file '${fileName}' but no content defined. ` +
620
- `Use content: "" for an empty text file override, or content: {} for an empty JSON/YAML override.`);
621
- }
622
- validateFileConfigFields(fileOverride, fileName, `Repo ${repoLabel}:`);
623
- }
624
- }
625
- function enrichSettingsContext(rootCtx, settings) {
626
- if (!settings)
627
- return;
628
- if (settings.rulesets) {
629
- for (const name of Object.keys(settings.rulesets)) {
630
- if (name !== "inherit")
631
- rootCtx.rulesetNames.push(name);
632
- }
633
- }
634
- if (settings.labels) {
635
- for (const name of Object.keys(settings.labels)) {
636
- if (name !== "inherit")
637
- rootCtx.labelNames.push(name);
638
- }
639
- }
640
- if (settings.repo !== undefined && settings.repo !== false) {
641
- rootCtx.hasRepoSettings = true;
642
- }
643
- if (settings.codeScanning !== undefined && settings.codeScanning !== false) {
644
- rootCtx.hasCodeScanningSettings = true;
645
- }
646
- }
647
- function validateRepoSettingsEntry(config, repo, repoLabel) {
648
- if (repo.settings === undefined)
649
- return;
650
- const rootCtx = buildRootSettingsContext(config);
651
- if (repo.groups && config.groups) {
652
- const expandedGroups = expandRepoGroups(repo.groups, config.groups);
653
- for (const groupName of expandedGroups) {
654
- enrichSettingsContext(rootCtx, config.groups[groupName]?.settings);
655
- }
656
- }
657
- if (config.conditionalGroups) {
658
- for (const cg of config.conditionalGroups) {
659
- enrichSettingsContext(rootCtx, cg.settings);
660
- }
661
- }
662
- validateSettings(repo.settings, `Repo ${repoLabel}`, rootCtx);
663
- }
664
- function validateRepoEntry(config, repo, index) {
665
- const repoLabel = validateRepoGitField(repo, index);
666
- validateRepoOrigins(config, repo, repoLabel);
667
- validateRepoGroups(config, repo, index);
668
- validateRepoFiles(config, repo, index, repoLabel);
669
- validateRepoSettingsEntry(config, repo, repoLabel);
670
- }
671
82
  function hasGroupFiles(config) {
672
83
  return (isPlainObject(config.groups) &&
673
84
  Object.values(config.groups).some((g) => g.files &&
@@ -678,9 +89,13 @@ function hasConditionalGroupFiles(config) {
678
89
  config.conditionalGroups.some((cg) => cg.files &&
679
90
  Object.keys(cg.files).filter((k) => k !== "inherit" && cg.files[k] !== false).length > 0));
680
91
  }
681
- function hasConditionalGroupSettings(config, predicate) {
92
+ function hasConditionalGroupSettingsPresent(config) {
93
+ return (Array.isArray(config.conditionalGroups) &&
94
+ config.conditionalGroups.some((cg) => cg.settings && isPlainObject(cg.settings)));
95
+ }
96
+ function hasConditionalGroupSettingsActionable(config) {
682
97
  return (Array.isArray(config.conditionalGroups) &&
683
- config.conditionalGroups.some((cg) => cg.settings && predicate(cg.settings)));
98
+ config.conditionalGroups.some((cg) => cg.settings && hasActionableSettings(cg.settings)));
684
99
  }
685
100
  function hasConditionalGroupPR(config) {
686
101
  return (Array.isArray(config.conditionalGroups) &&
@@ -698,17 +113,20 @@ export function validateRawConfig(config) {
698
113
  const hasGrpSettings = isPlainObject(config.groups) &&
699
114
  Object.values(config.groups).some((g) => g.settings && isPlainObject(g.settings));
700
115
  const hasCondGrpFiles = hasConditionalGroupFiles(config);
701
- const hasCondGrpSettings = hasConditionalGroupSettings(config, isPlainObject);
116
+ const hasCondGrpSettings = hasConditionalGroupSettingsPresent(config);
702
117
  const hasCondGrpPR = hasConditionalGroupPR(config);
118
+ const hasSecrets = isPlainObject(config.secrets) && Object.keys(config.secrets).length > 0;
703
119
  if (!hasFiles &&
704
120
  !hasSettings &&
705
121
  !hasGrpFiles &&
706
122
  !hasGrpSettings &&
707
123
  !hasCondGrpFiles &&
708
124
  !hasCondGrpSettings &&
709
- !hasCondGrpPR) {
710
- throw new ValidationError("Config requires at least one of: 'files' or 'settings'. " +
711
- "Use 'files' to sync configuration files, or 'settings' to manage repository settings.");
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.");
712
130
  }
713
131
  validateRootFiles(config);
714
132
  if (config.deleteOrphaned !== undefined &&
@@ -727,9 +145,6 @@ export function validateRawConfig(config) {
727
145
  validateRepoEntry(config, config.repos[i], i);
728
146
  }
729
147
  }
730
- // =============================================================================
731
- // Command-Specific Validators
732
- // =============================================================================
733
148
  /**
734
149
  * Validates that config is suitable for the sync command.
735
150
  * @throws ValidationError if neither files nor settings are present
@@ -742,7 +157,7 @@ export function validateForSync(config) {
742
157
  const hasGroupSettings = isPlainObject(config.groups) &&
743
158
  Object.values(config.groups).some((g) => g.settings && hasActionableSettings(g.settings));
744
159
  const hasCondGrpFiles = hasConditionalGroupFiles(config);
745
- const hasCondGrpSettings = hasConditionalGroupSettings(config, hasActionableSettings);
160
+ const hasCondGrpSettings = hasConditionalGroupSettingsActionable(config);
746
161
  const hasCondGrpPR = hasConditionalGroupPR(config);
747
162
  if (!hasRootFiles &&
748
163
  !hasGrpFiles &&
@@ -752,13 +167,113 @@ export function validateForSync(config) {
752
167
  !hasCondGrpFiles &&
753
168
  !hasCondGrpSettings &&
754
169
  !hasCondGrpPR) {
755
- throw new ValidationError("Config requires at least one of: 'files' or 'settings'. " +
756
- "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
+ }
757
275
  }
758
276
  }
759
- /**
760
- * Checks if settings contain actionable configuration.
761
- */
762
277
  export function hasActionableSettings(settings) {
763
278
  if (!settings)
764
279
  return false;
@@ -773,8 +288,66 @@ export function hasActionableSettings(settings) {
773
288
  Object.keys(settings.labels).filter((k) => k !== "inherit").length > 0) {
774
289
  return true;
775
290
  }
776
- if (settings.codeScanning && typeof settings.codeScanning === "object") {
291
+ if (settings.codeScanning) {
777
292
  return true;
778
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
+ }
779
300
  return false;
780
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
+ }