workspace-conformance 2.0.2 → 4.0.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/index.js CHANGED
@@ -946,6 +946,41 @@ async function readWorkspacePackages(cwd, layout) {
946
946
  if (discovered.length === 0) throw new ConformanceError(`no workspace package found: ${patterns.length === 0 ? "the package patterns are empty, so no directory is selected" : `no directory the patterns ${patterns.join(", ")} select holds a package.json`}; for a single-package repository, set the layout's packages to ['.']`);
947
947
  return classifyPackages(discovered, layout);
948
948
  }
949
+ /**
950
+ * The `package.json` fields a package's declared dependencies are read from when the layout's `dependencyFields` is omitted, the default of the ESLint workspace rules that read the same layout.
951
+ */
952
+ const DEFAULT_DEPENDENCY_FIELDS = ["dependencies"];
953
+ /**
954
+ * The fields npm and pnpm install dependencies from, read alongside the layout's own so that a dependency declared outside them can be told apart from one not declared at all.
955
+ */
956
+ const MANIFEST_DEPENDENCY_FIELDS = [
957
+ "dependencies",
958
+ "devDependencies",
959
+ "peerDependencies",
960
+ "optionalDependencies"
961
+ ];
962
+ /**
963
+ * For each package directory, the fields its `package.json` declares each dependency in: the manifest dependency fields and any other the layout's `dependencyFields` names.
964
+ */
965
+ async function readDeclaredDependencies(root, packages, layout) {
966
+ const fields = [.../* @__PURE__ */ new Set([...MANIFEST_DEPENDENCY_FIELDS, ...layout.dependencyFields ?? DEFAULT_DEPENDENCY_FIELDS])];
967
+ return new Map(await Promise.all(packages.map(async (member) => {
968
+ const manifest = join(root, member.dir, "package.json");
969
+ let content;
970
+ try {
971
+ content = JSON.parse(await readFile(manifest, "utf8"));
972
+ } catch (error) {
973
+ throw new ConformanceError(`${manifest} cannot be read as JSON`, { cause: error });
974
+ }
975
+ if (!isRecord(content)) throw new ConformanceError(`${manifest} is not a JSON object`);
976
+ const declared = /* @__PURE__ */ new Map();
977
+ for (const field of fields) {
978
+ const entries = content[field];
979
+ for (const name of isRecord(entries) ? Object.keys(entries) : []) declared.set(name, [...declared.get(name) ?? [], field]);
980
+ }
981
+ return [member.dir, declared];
982
+ })));
983
+ }
949
984
  //#endregion
950
985
  //#region src/checks/engines-floor.ts
951
986
  /**
@@ -1529,10 +1564,17 @@ async function runImportChecks(input) {
1529
1564
  const { cwd, layout, entries } = input;
1530
1565
  const root = workspaceRoot(cwd, layout);
1531
1566
  const packages = await readWorkspacePackages(cwd, layout);
1567
+ const declared = await readDeclaredDependencies(root, packages, layout);
1568
+ const read = new Set(layout.dependencyFields ?? DEFAULT_DEPENDENCY_FIELDS);
1569
+ const judged = (from, to) => {
1570
+ const fields = to.name === void 0 ? void 0 : declared.get(from.dir)?.get(to.name);
1571
+ return fields === void 0 || fields.some((field) => read.has(field));
1572
+ };
1532
1573
  const scope = {
1533
1574
  cwd,
1534
1575
  root,
1535
- packages
1576
+ packages,
1577
+ judged
1536
1578
  };
1537
1579
  const contributions = entries.map((entry) => ({
1538
1580
  ...entry,
@@ -1588,13 +1630,13 @@ async function runImportCheck(context, spec) {
1588
1630
  })).get(spec.name) ?? [];
1589
1631
  }
1590
1632
  /**
1591
- * The report of a check whose every rule forbids one package importing another: one violation per finding, at the importing file, ordered by file and message.
1633
+ * The report of a check whose every rule forbids one package importing another: one violation per finding the scope judges, at the importing file, ordered by file and message.
1592
1634
  */
1593
1635
  function edgeReport(spec) {
1594
- return (findings, { cwd, root, packages }) => findings.map((finding) => {
1636
+ return (findings, { cwd, root, packages, judged }) => findings.flatMap((finding) => {
1595
1637
  const from = packageOfPath(packages, finding.from);
1596
1638
  const to = packageOfPath(packages, finding.to);
1597
- return {
1639
+ return judged(from, to) ? [{
1598
1640
  code: `${spec.name}/${spec.reason}`,
1599
1641
  message: spec.message({
1600
1642
  from,
@@ -1603,7 +1645,7 @@ function edgeReport(spec) {
1603
1645
  toFile: finding.to
1604
1646
  }),
1605
1647
  file: relativePosix(cwd, join(root, finding.from))
1606
- };
1648
+ }] : [];
1607
1649
  }).sort((a, b) => a.file.localeCompare(b.file) || a.message.localeCompare(b.message));
1608
1650
  }
1609
1651
  /**
@@ -2238,7 +2280,9 @@ function createGitHubClient(options) {
2238
2280
  pullRequests: rules.filter((rule) => rule.type === "pull_request").map((rule) => ({
2239
2281
  requiredReviewThreadResolution: rule.parameters?.required_review_thread_resolution === true,
2240
2282
  allowedMergeMethods: rule.parameters?.allowed_merge_methods
2241
- }))
2283
+ })),
2284
+ requiredLinearHistory: rules.some((rule) => rule.type === "required_linear_history"),
2285
+ mergeQueue: rules.some((rule) => rule.type === "merge_queue")
2242
2286
  };
2243
2287
  }
2244
2288
  };
@@ -2275,9 +2319,9 @@ async function branchOf(github, slug, options) {
2275
2319
  return options.branch ?? (await github.repository(slug)).defaultBranch;
2276
2320
  }
2277
2321
  /**
2278
- * What a violation of a settings check names as its file.
2322
+ * The repository a violation of a settings check names, `owner/name`.
2279
2323
  */
2280
- function repositoryFile(slug) {
2324
+ function repositoryName(slug) {
2281
2325
  return `${slug.owner}/${slug.name}`;
2282
2326
  }
2283
2327
  /**
@@ -2295,8 +2339,8 @@ async function branchRulesOf(github, slug, options) {
2295
2339
  /**
2296
2340
  * The message of a `rules-unavailable` violation: the branch rules of the repository cannot be read or enforced on its plan, so `what` cannot be required.
2297
2341
  */
2298
- function rulesUnavailableMessage(file, what, githubMessage) {
2299
- return `the branch rules of ${file} cannot be read or enforced on its plan, so ${what} cannot be required (GitHub: ${githubMessage})`;
2342
+ function rulesUnavailableMessage(repository, what, githubMessage) {
2343
+ return `the branch rules of ${repository} cannot be read or enforced on its plan, so ${what} cannot be required (GitHub: ${githubMessage})`;
2300
2344
  }
2301
2345
  //#endregion
2302
2346
  //#region src/checks/settings-merge-methods.ts
@@ -2306,7 +2350,15 @@ const METHODS = [
2306
2350
  "merge"
2307
2351
  ];
2308
2352
  /**
2309
- * Only one merge method is available for a pull request into the branch, `rebase` unless the options say otherwise. A method is available when the repository allows it and no `pull_request` rule that applies to the branch leaves it out of its allowed methods.
2353
+ * How a message names each method, in the words GitHub's settings page uses.
2354
+ */
2355
+ const METHOD_NAMES = {
2356
+ rebase: "rebase merging",
2357
+ squash: "squash merging",
2358
+ merge: "merge commits"
2359
+ };
2360
+ /**
2361
+ * Only one merge method is available for a pull request into the branch, `rebase` unless the options say otherwise. A method is available when the repository allows it, no `pull_request` rule that applies to the branch leaves it out of its allowed methods, and, for a merge commit, no `required_linear_history` rule applies, since that rule keeps merge commits off the branch.
2310
2362
  *
2311
2363
  * When the repository's plan has no rulesets, no rule applies to the branch and the repository settings alone decide.
2312
2364
  *
@@ -2314,7 +2366,7 @@ const METHODS = [
2314
2366
  */
2315
2367
  const settingsMergeMethods = async ({ cwd, options, github }) => {
2316
2368
  const slug = await repositoryOf(cwd, options);
2317
- const file = repositoryFile(slug);
2369
+ const repository = repositoryName(slug);
2318
2370
  const settings = await github.repository(slug);
2319
2371
  const read = await branchRulesOf(github, slug, options);
2320
2372
  const pullRequests = "unavailable" in read ? [] : read.rules.pullRequests;
@@ -2324,16 +2376,18 @@ const settingsMergeMethods = async ({ cwd, options, github }) => {
2324
2376
  squash: settings.allowSquashMerge,
2325
2377
  merge: settings.allowMergeCommit
2326
2378
  };
2327
- const available = METHODS.filter((method) => repositoryAllows[method] && pullRequests.every((rule) => rule.allowedMergeMethods === void 0 || rule.allowedMergeMethods.includes(method)));
2379
+ const requiredLinearHistory = "unavailable" in read ? false : read.rules.requiredLinearHistory;
2380
+ const rulesAllow = (method) => !(method === "merge" && requiredLinearHistory) && pullRequests.every((rule) => rule.allowedMergeMethods === void 0 || rule.allowedMergeMethods.includes(method));
2381
+ const available = METHODS.filter((method) => repositoryAllows[method] && rulesAllow(method));
2328
2382
  const violations = available.filter((method) => method !== wanted).map((method) => ({
2329
2383
  code: "settings-merge-methods/method-enabled",
2330
- message: `${file} allows ${method} merges; only ${wanted} should be allowed`,
2331
- file
2384
+ message: `${repository} allows ${METHOD_NAMES[method]}; only ${METHOD_NAMES[wanted]} should be allowed`,
2385
+ repository
2332
2386
  }));
2333
2387
  if (!available.includes(wanted)) violations.push({
2334
2388
  code: "settings-merge-methods/method-unavailable",
2335
- message: `${file} does not allow ${wanted} merges`,
2336
- file
2389
+ message: `${repository} does not allow ${METHOD_NAMES[wanted]}`,
2390
+ repository
2337
2391
  });
2338
2392
  return violations;
2339
2393
  };
@@ -2525,6 +2579,7 @@ function parseWorkflow(file, source) {
2525
2579
  const triggers = triggersOf(reader, root["on"]);
2526
2580
  const call = triggers.get("workflow_call");
2527
2581
  const outputs = reader.record(reader.record(call, "on.workflow_call")["outputs"], "on.workflow_call.outputs");
2582
+ const inputs = reader.record(reader.record(call, "on.workflow_call")["inputs"], "on.workflow_call.inputs");
2528
2583
  return {
2529
2584
  file,
2530
2585
  name: reader.text(root["name"], "name"),
@@ -2534,7 +2589,8 @@ function parseWorkflow(file, source) {
2534
2589
  permissionsLocation: root["permissions"] === void 0 ? void 0 : reader.location(["permissions"]),
2535
2590
  env: reader.texts(root["env"], "env"),
2536
2591
  jobs: Object.entries(reader.record(root["jobs"], "jobs")).map(([id, job]) => jobOf(reader, id, job)),
2537
- callOutputs: Object.fromEntries(Object.entries(outputs).map(([name, output]) => [name, reader.text(reader.record(output, `on.workflow_call.outputs.${name}`)["value"], `on.workflow_call.outputs.${name}.value`) ?? ""]))
2592
+ callOutputs: Object.fromEntries(Object.entries(outputs).map(([name, output]) => [name, reader.text(reader.record(output, `on.workflow_call.outputs.${name}`)["value"], `on.workflow_call.outputs.${name}.value`) ?? ""])),
2593
+ callInputs: Object.fromEntries(Object.entries(inputs).map(([name, input]) => [name, reader.text(reader.record(input, `on.workflow_call.inputs.${name}`)["default"], `on.workflow_call.inputs.${name}.default`)]))
2538
2594
  };
2539
2595
  }
2540
2596
  /**
@@ -2729,12 +2785,12 @@ const REGISTRY_TOKEN_VARIABLES = ["NODE_AUTH_TOKEN", "NPM_TOKEN"];
2729
2785
  */
2730
2786
  const settingsRequiredChecks = async ({ cwd, options, github }) => {
2731
2787
  const slug = await repositoryOf(cwd, options);
2732
- const file = repositoryFile(slug);
2788
+ const repository = repositoryName(slug);
2733
2789
  const read = await branchRulesOf(github, slug, options);
2734
2790
  if ("unavailable" in read) return [{
2735
2791
  code: "settings-required-checks/rules-unavailable",
2736
- message: rulesUnavailableMessage(file, "a status check", read.unavailable),
2737
- file
2792
+ message: rulesUnavailableMessage(repository, "a status check", read.unavailable),
2793
+ repository
2738
2794
  }];
2739
2795
  const { rules } = read;
2740
2796
  const junctions = (await loadWorkflows(cwd, options)).flatMap((workflow) => jobsNamed(workflow, options.junctionJobs ?? DEFAULT_JUNCTION_JOBS).map((job) => ({
@@ -2745,26 +2801,26 @@ const settingsRequiredChecks = async ({ cwd, options, github }) => {
2745
2801
  if (junctions.length === 0) violations.push({
2746
2802
  code: "settings-required-checks/no-junction-job",
2747
2803
  message: "no workflow has a junction job, so there is no check that reports for the whole workflow to require",
2748
- file
2804
+ repository
2749
2805
  });
2750
2806
  if (rules.requiredStatusChecks.length === 0) {
2751
2807
  violations.push({
2752
2808
  code: "settings-required-checks/no-required-checks",
2753
- message: `no rule requires status checks on the branch of ${file}`,
2754
- file
2809
+ message: `no rule requires status checks on the branch of ${repository}`,
2810
+ repository
2755
2811
  });
2756
2812
  return violations;
2757
2813
  }
2758
2814
  const required = new Set(rules.requiredStatusChecks.flatMap((rule) => rule.contexts));
2759
2815
  for (const { workflow, name } of junctions.filter((junction) => !required.has(junction.name))) violations.push({
2760
2816
  code: "settings-required-checks/required-check-missing",
2761
- message: `${file} does not require the check '${name}' of ${workflow.file}`,
2762
- file
2817
+ message: `${repository} does not require the check '${name}' of ${workflow.file}`,
2818
+ repository
2763
2819
  });
2764
2820
  if (!rules.requiredStatusChecks.some((rule) => rule.strict)) violations.push({
2765
2821
  code: "settings-required-checks/not-strict",
2766
- message: `${file} does not require branches to be up to date before merging`,
2767
- file
2822
+ message: `${repository} does not require branches to be up to date before merging`,
2823
+ repository
2768
2824
  });
2769
2825
  return violations;
2770
2826
  };
@@ -2780,16 +2836,16 @@ const settingsRequiredChecks = async ({ cwd, options, github }) => {
2780
2836
  const settingsReviewThreadResolution = async ({ cwd, options, github }) => {
2781
2837
  const slug = await repositoryOf(cwd, options);
2782
2838
  const read = await branchRulesOf(github, slug, options);
2783
- const file = repositoryFile(slug);
2839
+ const repository = repositoryName(slug);
2784
2840
  if ("unavailable" in read) return [{
2785
2841
  code: "settings-review-thread-resolution/rules-unavailable",
2786
- message: rulesUnavailableMessage(file, "resolving review conversations", read.unavailable),
2787
- file
2842
+ message: rulesUnavailableMessage(repository, "resolving review conversations", read.unavailable),
2843
+ repository
2788
2844
  }];
2789
2845
  return read.rules.pullRequests.some((rule) => rule.requiredReviewThreadResolution) ? [] : [{
2790
2846
  code: "settings-review-thread-resolution/not-required",
2791
- message: `no rule requires review conversations to be resolved before merging on the branch of ${file}`,
2792
- file
2847
+ message: `no rule requires review conversations to be resolved before merging on the branch of ${repository}`,
2848
+ repository
2793
2849
  }];
2794
2850
  };
2795
2851
  //#endregion
@@ -2881,6 +2937,15 @@ const PACKAGE_MANAGER_OPTIONS = String.raw`(?:\s+(?:(?:--filter|-F|--workspace|-
2881
2937
  * Commands that publish a package to a registry.
2882
2938
  */
2883
2939
  const PUBLISH_COMMAND = new RegExp(String.raw`^(?:(?:npm|pnpm|yarn)${PACKAGE_MANAGER_OPTIONS}(?:\s+npm)?\s+publish|(?:(?:pnpm|yarn)(?:\s+exec)?\s+|npx\s+)?(?:semantic-release|changeset\s+publish|lerna\s+publish))(?:\s|$)`, "u");
2940
+ /**
2941
+ * npm reads no token variable by name: a variable reaches it only through `${NAME}` (or `${NAME?}`) in the value of an `.npmrc` setting. These are the settings that carry credentials.
2942
+ */
2943
+ const NPMRC_AUTH_SETTING = /^\s*[^#;=\s]*(?:_authToken|_auth|_password)\s*=(.*)$/u;
2944
+ const NPMRC_VARIABLE = /\$\{([^${}?]+)\??\}/gu;
2945
+ /**
2946
+ * `semantic-release` as the command a publish line runs, whose npm plugin reads `NPM_TOKEN` itself.
2947
+ */
2948
+ const SEMANTIC_RELEASE = /(?:^|\s)semantic-release(?:\s|$)/u;
2884
2949
  const PROVENANCE_FLAG = /--provenance(?:=true)?(?:\s|$)/u;
2885
2950
  /**
2886
2951
  * Whether the `package.json` in the working directory asks for provenance with `publishConfig.provenance: true`, which applies to every publish of that package.
@@ -2892,6 +2957,34 @@ async function requestsProvenance(cwd) {
2892
2957
  const config = isRecord(manifest) ? manifest["publishConfig"] : void 0;
2893
2958
  return isRecord(config) && config["provenance"] === true;
2894
2959
  }
2960
+ /**
2961
+ * The variables the auth settings of the `.npmrc` in the working directory read, which npm and pnpm load for a publish run there.
2962
+ */
2963
+ async function npmrcTokenVariables(cwd) {
2964
+ const file = resolve(cwd, ".npmrc");
2965
+ if (!existsSync(file)) return [];
2966
+ const values = (await readFile(file, "utf8")).split("\n").flatMap((line) => NPMRC_AUTH_SETTING.exec(line)?.[1] ?? []);
2967
+ return [...new Set(values.flatMap((value) => [...value.matchAll(NPMRC_VARIABLE)].flatMap((match) => match[1] ?? [])))];
2968
+ }
2969
+ /**
2970
+ * The token variables that what a publish step runs reads, with what reads each: the `.npmrc` that an earlier `actions/setup-node` step with `registry-url` writes reads `NODE_AUTH_TOKEN`, semantic-release's npm plugin reads `NPM_TOKEN`, and the `.npmrc` of the working directory reads the variables of its auth settings.
2971
+ */
2972
+ function tokenReads(job, step, publishes, npmrcVariables) {
2973
+ const reads = [];
2974
+ if (job.steps.some((earlier) => earlier.index < step.index && stepUses(earlier, "actions/setup-node") && (earlier.with["registry-url"] ?? "") !== "")) reads.push({
2975
+ variable: "NODE_AUTH_TOKEN",
2976
+ reader: "the .npmrc actions/setup-node writes for registry-url"
2977
+ });
2978
+ if (publishes.some((line) => SEMANTIC_RELEASE.test(line))) reads.push({
2979
+ variable: "NPM_TOKEN",
2980
+ reader: "semantic-release's npm plugin"
2981
+ });
2982
+ reads.push(...npmrcVariables.map((variable) => ({
2983
+ variable,
2984
+ reader: "the .npmrc of the working directory"
2985
+ })));
2986
+ return reads.filter((read, index) => reads.findIndex((other) => other.variable === read.variable) === index);
2987
+ }
2895
2988
  function attestationViolations(workflow, job) {
2896
2989
  const permissions = effectivePermissions(workflow, job);
2897
2990
  return job.steps.filter((step) => ATTESTATION_ACTIONS.some((action) => stepUses(step, action))).flatMap((step) => {
@@ -2903,18 +2996,18 @@ function attestationViolations(workflow, job) {
2903
2996
  /**
2904
2997
  * Whether the job passes a registry token to a step, which makes its publish not tokenless: a token variable with a value other than the empty string, anywhere in the environment.
2905
2998
  */
2906
- function passesToken(workflow, job) {
2907
- return [void 0, ...job.steps].some((step) => REGISTRY_TOKEN_VARIABLES.some((variable) => (effectiveEnv(workflow, job, step)[variable] ?? "") !== ""));
2999
+ function passesToken(workflow, job, variables) {
3000
+ return [void 0, ...job.steps].some((step) => variables.some((variable) => (effectiveEnv(workflow, job, step)[variable] ?? "") !== ""));
2908
3001
  }
2909
- function tokenlessPublishViolations(workflow, job, provenanceInManifest) {
2910
- if (accessOf(effectivePermissions(workflow, job), "id-token") !== "write" || passesToken(workflow, job)) return [];
3002
+ function tokenlessPublishViolations(workflow, job, provenanceInManifest, npmrcVariables) {
3003
+ if (accessOf(effectivePermissions(workflow, job), "id-token") !== "write" || passesToken(workflow, job, [...REGISTRY_TOKEN_VARIABLES, ...npmrcVariables])) return [];
2911
3004
  return job.steps.flatMap((step) => {
2912
3005
  const publishes = scriptCommands(step.run).filter((line) => PUBLISH_COMMAND.test(line));
2913
3006
  if (publishes.length === 0) return [];
2914
3007
  const env = effectiveEnv(workflow, job, step);
2915
- const blanked = REGISTRY_TOKEN_VARIABLES.every((variable) => env[variable] === "");
3008
+ const unprotected = tokenReads(job, step, publishes, npmrcVariables).filter((read) => env[read.variable] !== "");
2916
3009
  const provenance = provenanceInManifest || publishes.some((line) => PROVENANCE_FLAG.test(line)) || env["NPM_CONFIG_PROVENANCE"] === "true";
2917
- return blanked || provenance ? [] : [workflowViolation(workflow, "workflow-credentials/tokenless-publish-unprotected", `job '${job.id}' publishes with an OIDC identity and no token, but does not set ${REGISTRY_TOKEN_VARIABLES.join(" and ")} to the empty string, so a token inherited from the environment is used when the OIDC exchange fails; set them to the empty string to prevent that, or request provenance so that such a publish at least carries a traceable attestation`, step.location)];
3010
+ return unprotected.length === 0 || provenance ? [] : [workflowViolation(workflow, "workflow-credentials/tokenless-publish-unprotected", `job '${job.id}' publishes with an OIDC identity and no token, but does not set ${unprotected.map((read) => `${read.variable} (read by ${read.reader})`).join(" and ")} to the empty string, so a token inherited from the environment is used when the OIDC exchange fails; set ${unprotected.length === 1 ? "it" : "them"} to the empty string to prevent that, or request provenance so that such a publish at least carries a traceable attestation`, step.location)];
2918
3011
  });
2919
3012
  }
2920
3013
  /**
@@ -2922,45 +3015,19 @@ function tokenlessPublishViolations(workflow, job, provenanceInManifest) {
2922
3015
  *
2923
3016
  * `workflow-credentials/attestation-permissions`: a step of `actions/attest`, `actions/attest-build-provenance` or `actions/attest-sbom` needs `id-token: write` and `attestations: write` in the job's effective permissions. Permissions a job declares replace the workflow's; permissions declared nowhere are the repository's default, which the file cannot show and which never includes `id-token`, so they are reported.
2924
3017
  *
2925
- * `workflow-credentials/tokenless-publish-unprotected`: a job with `id-token: write` that publishes (`npm`, `pnpm` or `yarn npm publish`, with options such as `-r` or `--filter <package>` before `publish`, `semantic-release`, `changeset publish`, `lerna publish`, bare or run through `pnpm`, `pnpm exec`, `yarn` or `npx`) and passes no token must blank `NODE_AUTH_TOKEN` and `NPM_TOKEN` (set them to `''` at step, job or workflow level) or request provenance (`--provenance`, `NPM_CONFIG_PROVENANCE: true`, or `publishConfig.provenance: true` in the `package.json` of the working directory). The npm CLI replaces any configured token with the one from the OIDC exchange, but when the exchange fails it falls back to whatever token the environment holds, silently. Only blanking the variables prevents that fallback. Provenance does not: npm signs it with the CI's own OIDC identity (through Sigstore), separately from the registry token, so a fallback publish still goes ahead with the inherited token and carries an attestation of where it was built. Requesting provenance is accepted because it makes such a publish traceable, not because it makes it safe.
3018
+ * `workflow-credentials/tokenless-publish-unprotected`: a job with `id-token: write` that publishes (`npm`, `pnpm` or `yarn npm publish`, with options such as `-r` or `--filter <package>` before `publish`, `semantic-release`, `changeset publish`, `lerna publish`, bare or run through `pnpm`, `pnpm exec`, `yarn` or `npx`) and passes no token must blank (set to `''` at step, job or workflow level) every token variable the publish reads, or request provenance (`--provenance`, `NPM_CONFIG_PROVENANCE: true`, or `publishConfig.provenance: true` in the `package.json` of the working directory). The npm CLI replaces any configured token with the one from the OIDC exchange, but when the exchange fails it falls back to whatever token the environment holds, silently. Only blanking the variables prevents that fallback. Provenance does not: npm signs it with the CI's own OIDC identity (through Sigstore), separately from the registry token, so a fallback publish still goes ahead with the inherited token and carries an attestation of where it was built. Requesting provenance is accepted because it makes such a publish traceable, not because it makes it safe.
3019
+ *
3020
+ * A variable is judged only where something reads it, since npm reads none by name, only through `${NAME}` in an `.npmrc`: `NODE_AUTH_TOKEN` after an `actions/setup-node` step with `registry-url`, whose `.npmrc` refers to it; `NPM_TOKEN` for `semantic-release`, whose npm plugin reads it; and the variables the auth settings (`_authToken`, `_auth`, `_password`) of the `.npmrc` in the working directory refer to.
2926
3021
  *
2927
- * Limits: `registry-url` is not reported, because it does not stop the exchange (see the README); a job that calls a reusable workflow is not read; a publish inside a script file or composite action is not seen; provenance set in a `package.json` other than the one in the working directory is not read; a job is tokenless only when no token variable is non-empty anywhere in the job, so a publish that uses a secret is left alone.
3022
+ * Limits: `registry-url` is not reported, because it does not stop the exchange (see the README); an `.npmrc` the file does not show, such as one in the runner's home directory, a Yarn `.yarnrc.yml` and a token set through an `npm_config_` variable are not read; a job that calls a reusable workflow is not read; a publish inside a script file or composite action is not seen; provenance set in a `package.json` other than the one in the working directory is not read; a job is tokenless only when no token variable is non-empty anywhere in the job, so a publish that uses a secret is left alone.
2928
3023
  */
2929
3024
  const workflowCredentials = async ({ cwd, options }) => {
2930
3025
  const provenanceInManifest = await requestsProvenance(cwd);
2931
- return (await loadWorkflows(cwd, options)).flatMap((workflow) => workflow.jobs.flatMap((job) => [...attestationViolations(workflow, job), ...tokenlessPublishViolations(workflow, job, provenanceInManifest)]));
3026
+ const npmrcVariables = await npmrcTokenVariables(cwd);
3027
+ return (await loadWorkflows(cwd, options)).flatMap((workflow) => workflow.jobs.flatMap((job) => [...attestationViolations(workflow, job), ...tokenlessPublishViolations(workflow, job, provenanceInManifest, npmrcVariables)]));
2932
3028
  };
2933
3029
  //#endregion
2934
- //#region src/checks/workflow-job-ordering.ts
2935
- const DEFAULT_DEPLOY_JOBS = ["deploy"];
2936
- const DEFAULT_DOCS_DEPLOY_JOBS = ["docs-deploy"];
2937
- const DEFAULT_BRANCH = "main";
2938
- /**
2939
- * The expression that always names the repository's default branch.
2940
- */
2941
- const DEFAULT_BRANCH_EXPRESSION$1 = /^\$\{\{\s*github\.event\.repository\.default_branch\s*\}\}$/u;
2942
- /**
2943
- * The text a junction job decides on: its `if`, and the conditions and scripts of its steps.
2944
- */
2945
- function decisionText(job) {
2946
- return [job.condition, ...job.steps.flatMap((step) => [
2947
- step.condition,
2948
- step.run,
2949
- ...Object.values(step.env),
2950
- ...Object.values(step.with)
2951
- ])].filter((text) => text !== void 0).join("\n");
2952
- }
2953
- /**
2954
- * Where a script gets the results of the jobs it needs from: `needs.*.result`, one job's `needs.<job>.result` or the whole of `needs`.
2955
- */
2956
- const RESULT_SOURCE = /needs\.(?:\*|[\w-]+)\.result|toJSON\(\s*needs\s*\)/u;
2957
- const FAILURE_WORD = /['"]failure['"]/u;
2958
- const CANCELLED_WORD = /['"]cancelled['"]/u;
2959
- const SKIPPED_OR_NOT_SUCCESS = /needs\.(?:\*|[\w-]+)\.result[^\n]*(?:['"]skipped['"]|!==?\s*['"]success['"])|!\s*contains\(\s*needs\.\*\.result\s*,\s*['"]success['"]/u;
2960
- /**
2961
- * An `if` that compares the ref or the event name.
2962
- */
2963
- const COMPARES_REF_OR_EVENT = /github\.(?:ref|ref_name|event_name)\s*==/u;
3030
+ //#region src/workflows/expressions.ts
2964
3031
  /**
2965
3032
  * An expression written inside the `${{ }}` that marks it as one.
2966
3033
  */
@@ -3008,6 +3075,153 @@ function unwrapped(expression) {
3008
3075
  return enclosedByOneGroup(inner) ? unwrapped(inner.slice(1, -1)) : inner;
3009
3076
  }
3010
3077
  /**
3078
+ * A string literal of an expression, whose body writes a quote as `''`.
3079
+ */
3080
+ const STRING_LITERAL = /^'((?:[^']|'')*)'$/u;
3081
+ /**
3082
+ * A call of `format`, whose name, like every function name in an expression, is case-insensitive.
3083
+ */
3084
+ const FORMAT_CALL = /^format\s*\(([\s\S]*)\)$/iu;
3085
+ /**
3086
+ * A reference to an input of the workflow.
3087
+ */
3088
+ const INPUT_REFERENCE = /^inputs\.([\w-]+)$/u;
3089
+ /**
3090
+ * A placeholder of a `format` pattern, or the doubled brace that writes a literal one.
3091
+ */
3092
+ const FORMAT_PLACEHOLDER = /\{\{|\}\}|\{(\d+)\}/gu;
3093
+ /**
3094
+ * The arguments of a call, split at the commas outside quotes and parentheses.
3095
+ */
3096
+ function callArguments(text) {
3097
+ const parts = [];
3098
+ let depth = 0;
3099
+ let quoted = false;
3100
+ let start = 0;
3101
+ for (let index = 0; index < text.length; index += 1) {
3102
+ const character = text.charAt(index);
3103
+ if (character === "'") quoted = !quoted;
3104
+ else if (!quoted && character === "(") depth += 1;
3105
+ else if (!quoted && character === ")") depth -= 1;
3106
+ else if (!quoted && depth === 0 && character === ",") {
3107
+ parts.push(text.slice(start, index));
3108
+ start = index + 1;
3109
+ }
3110
+ }
3111
+ parts.push(text.slice(start));
3112
+ return parts.map((part) => part.trim());
3113
+ }
3114
+ /**
3115
+ * `format(pattern, ...values)` as GitHub evaluates it: `{N}` is the value at index N and `{{` and `}}` are literal braces. `undefined` when a placeholder names a value that is not given, which GitHub rejects.
3116
+ */
3117
+ function formatted(pattern, values) {
3118
+ let text = "";
3119
+ let end = 0;
3120
+ for (const match of pattern.matchAll(FORMAT_PLACEHOLDER)) {
3121
+ const index = match[1];
3122
+ const value = index === void 0 ? match[0].charAt(0) : values[Number(index)];
3123
+ if (value === void 0) return;
3124
+ text += `${pattern.slice(end, match.index)}${value}`;
3125
+ end = match.index + match[0].length;
3126
+ }
3127
+ return `${text}${pattern.slice(end)}`;
3128
+ }
3129
+ /**
3130
+ * The value an operand of an expression has on every run, or `undefined` when that depends on the run: a string literal, an input whose value `inputs` gives, or `format()` of such values. An input `inputs` holds as `undefined`, or does not hold, is not known.
3131
+ */
3132
+ function constantString(operand, inputs) {
3133
+ const text = unwrapped(operand);
3134
+ const literal = STRING_LITERAL.exec(text)?.[1];
3135
+ if (literal !== void 0) return literal.replaceAll("''", "'");
3136
+ const input = INPUT_REFERENCE.exec(text)?.[1];
3137
+ if (input !== void 0) return Object.hasOwn(inputs, input) ? inputs[input] : void 0;
3138
+ const call = FORMAT_CALL.exec(text)?.[1];
3139
+ if (call === void 0) return;
3140
+ const [pattern, ...rest] = callArguments(call).map((argument) => constantString(argument, inputs));
3141
+ const values = rest.filter((value) => value !== void 0);
3142
+ return pattern === void 0 || values.length !== rest.length ? void 0 : formatted(pattern, values);
3143
+ }
3144
+ //#endregion
3145
+ //#region src/checks/workflow-job-ordering.ts
3146
+ const DEFAULT_DEPLOY_JOBS = ["deploy"];
3147
+ const DEFAULT_DOCS_DEPLOY_JOBS = ["docs-deploy"];
3148
+ const DEFAULT_BRANCH = "main";
3149
+ /**
3150
+ * The expression that always names the repository's default branch.
3151
+ */
3152
+ const DEFAULT_BRANCH_EXPRESSION$1 = /^\$\{\{\s*github\.event\.repository\.default_branch\s*\}\}$/u;
3153
+ /**
3154
+ * The whole of an environment value that is one expression, `${{ <expression> }}`.
3155
+ */
3156
+ const WHOLE_EXPRESSION = /^\$\{\{\s*([\s\S]*?)\s*\}\}$/u;
3157
+ /**
3158
+ * A reference to an environment variable in a script, `$NAME` or `${NAME}`, and in an expression, `env.NAME`.
3159
+ */
3160
+ const SCRIPT_VARIABLE = /\$(?:\{([A-Za-z_]\w*)\}|([A-Za-z_]\w*))/gu;
3161
+ const EXPRESSION_VARIABLE = /\benv\.([A-Za-z_]\w*)/gu;
3162
+ /**
3163
+ * A script or condition with each environment variable whose value is a whole expression replaced by that expression, so `"$RESULT" != "success"` with `RESULT: ${{ needs.test.result }}` reads as the test of `needs.test.result` it is.
3164
+ */
3165
+ function withVariablesResolved(text, env, reference) {
3166
+ return text.replaceAll(reference, (match, ...names) => {
3167
+ const name = names.find((candidate) => typeof candidate === "string");
3168
+ const value = name === void 0 ? void 0 : env[name];
3169
+ return (value === void 0 ? void 0 : WHOLE_EXPRESSION.exec(value.trim())?.[1]) ?? match;
3170
+ });
3171
+ }
3172
+ /**
3173
+ * The text a junction job decides on: its `if`, and the conditions, scripts and inputs of its steps, with the environment variables they read replaced by the expressions that set them, and the values of their environments.
3174
+ */
3175
+ function decisionText(workflow, job) {
3176
+ return [job.condition === void 0 ? void 0 : withVariablesResolved(job.condition, effectiveEnv(workflow, job, void 0), EXPRESSION_VARIABLE), ...job.steps.flatMap((step) => {
3177
+ const env = effectiveEnv(workflow, job, step);
3178
+ return [
3179
+ step.condition === void 0 ? void 0 : withVariablesResolved(step.condition, env, EXPRESSION_VARIABLE),
3180
+ step.run === void 0 ? void 0 : withVariablesResolved(step.run, env, SCRIPT_VARIABLE),
3181
+ ...Object.values(env),
3182
+ ...Object.values(step.with)
3183
+ ];
3184
+ })].filter((text) => text !== void 0).join("\n");
3185
+ }
3186
+ /**
3187
+ * Where a script gets the results of the jobs it needs from: `needs.*.result`, one job's `needs.<job>.result` or the whole of `needs`.
3188
+ */
3189
+ const RESULT_SOURCE = /needs\.(?:\*|[\w-]+)\.result|toJSON\(\s*needs\s*\)/u;
3190
+ const FAILURE_WORD = /['"]failure['"]/u;
3191
+ const CANCELLED_WORD = /['"]cancelled['"]/u;
3192
+ /**
3193
+ * A test that a result is not `success`, which fails on `failure` and `cancelled`, and on `skipped` too. The group is the job, or `*`.
3194
+ */
3195
+ const NOT_SUCCESS = /needs\.(\*|[\w-]+)\.result[^\n]*?!==?\s*['"]success['"]/gu;
3196
+ /**
3197
+ * A test that names `skipped`, or that fails unless some result is `success`.
3198
+ */
3199
+ const SKIPPED_TEST = /needs\.(?:\*|[\w-]+)\.result[^\n]*['"]skipped['"]|!\s*contains\(\s*needs\.\*\.result\s*,\s*['"]success['"]/u;
3200
+ /**
3201
+ * A read of a job's output, which is what an `if` that skips a job on the result of an earlier one reads.
3202
+ */
3203
+ const OUTPUT_READ = /needs\.[\w-]+\.outputs\.[\w-]+/gu;
3204
+ /**
3205
+ * Whether an `if` can skip the job, its own or that of a job it needs, without the junction job seeing why: a job runs unless an `if` other than `always()` stops it, and a skip that comes only from a need that failed is a failure the junction rightly fails on. A skip is seen when the junction reads every job output those conditions read, as a junction that tests `has-packages` before the result of a job skipped on it does.
3206
+ */
3207
+ function skippableUnseen(workflow, jobId, text) {
3208
+ return workflow.jobs.filter((job) => job.id === jobId || transitiveNeeds(workflow, jobId).has(job.id)).flatMap((job) => job.condition === void 0 || callsAlways(job.condition) ? [] : [job.condition]).some((condition) => {
3209
+ const reads = [...condition.matchAll(OUTPUT_READ)].map((read) => read[0]);
3210
+ return reads.length === 0 || !reads.every((read) => text.includes(read));
3211
+ });
3212
+ }
3213
+ /**
3214
+ * Whether the junction fails when a job it needs is skipped: it names `skipped`, or tests a result for not being `success` where an `if` can skip that job (any of its needs, for `needs.*.result`) unseen.
3215
+ */
3216
+ function failsOnSkipped(workflow, junction, text) {
3217
+ const tested = [...text.matchAll(NOT_SUCCESS)].flatMap((match) => match[1] === "*" ? junction.needs : match[1] ?? []);
3218
+ return SKIPPED_TEST.test(text) || tested.some((id) => skippableUnseen(workflow, id, text));
3219
+ }
3220
+ /**
3221
+ * An `if` that compares the ref or the event name.
3222
+ */
3223
+ const COMPARES_REF_OR_EVENT = /github\.(?:ref|ref_name|event_name)\s*==/u;
3224
+ /**
3011
3225
  * Whether an expression can only hold in runs other than those of a pull request and of a merge group, which are the runs the required check decides on: an operand of an `&&` that does so is enough, every operand of an `||` must, and a comparison of the ref or the event name does so when it names neither `pull_request` nor `merge_group`.
3012
3226
  */
3013
3227
  function keepsOutOfPullRequests(expression) {
@@ -3054,17 +3268,17 @@ function junctionOrdering(workflow, options) {
3054
3268
  if (!callsAlways(junction.condition)) violations.push(workflowViolation(workflow, "workflow-job-ordering/junction-not-always", `junction job '${junction.id}' has no if: always(), so it is skipped, and its required check never reports, when a job it needs fails`, junction.location));
3055
3269
  const missing = workflow.jobs.filter((job) => job.id !== junction.id && !exempt.has(job.id) && !junction.needs.includes(job.id) && !transitiveNeeds(workflow, job.id).has(junction.id));
3056
3270
  for (const job of missing) violations.push(workflowViolation(workflow, "workflow-job-ordering/junction-missing-need", `junction job '${junction.id}' does not list job '${job.id}' in needs, so its result is not part of the required check`, junction.location));
3057
- const text = decisionText(junction);
3271
+ const text = decisionText(workflow, junction);
3058
3272
  const reportsOutcome = junction.steps.some((step) => stepUses(step, OUTCOME_ACTION));
3059
- if (SKIPPED_OR_NOT_SUCCESS.test(text)) violations.push(workflowViolation(workflow, "workflow-job-ordering/junction-fails-on-skipped", `junction job '${junction.id}' fails when a needed job is skipped, but a skipped job is how a path filter or an if keeps a job out; fail only on 'failure' and 'cancelled'`, junction.location));
3060
- if (!reportsOutcome && !(RESULT_SOURCE.test(text) && FAILURE_WORD.test(text) && CANCELLED_WORD.test(text))) violations.push(workflowViolation(workflow, "workflow-job-ordering/junction-no-failure-condition", `junction job '${junction.id}' does not test the results of its needs for both 'failure' and 'cancelled', so it can pass when a job it needs did not succeed`, junction.location));
3273
+ if (failsOnSkipped(workflow, junction, text)) violations.push(workflowViolation(workflow, "workflow-job-ordering/junction-fails-on-skipped", `junction job '${junction.id}' fails when a needed job is skipped, but a skipped job is how a path filter or an if keeps a job out; fail only on 'failure' and 'cancelled'`, junction.location));
3274
+ if (!reportsOutcome && !(RESULT_SOURCE.test(text) && (FAILURE_WORD.test(text) && CANCELLED_WORD.test(text) || [...text.matchAll(NOT_SUCCESS)].length > 0))) violations.push(workflowViolation(workflow, "workflow-job-ordering/junction-no-failure-condition", `junction job '${junction.id}' does not test the results of its needs for both 'failure' and 'cancelled', so it can pass when a job it needs did not succeed`, junction.location));
3061
3275
  }
3062
3276
  return violations;
3063
3277
  }
3064
3278
  /**
3065
3279
  * The jobs that release, deploy and report a required check are ordered so that a failure stops what follows: a release job needs the deploy job, a documentation deploy job needs the release job and checks out the default branch afresh, and one junction job with `if: always()` needs every other job and fails only on `failure` or `cancelled`.
3066
3280
  *
3067
- * Jobs are recognised by id (see the options), and a role with no job in a workflow is not judged there. Ordering is judged through the transitive `needs` graph; the junction job must list every job directly, because `needs.*.result` only covers direct needs, except jobs that never run for a pull request or a merge group (an `if` on `github.ref` or `github.event_name` that mentions neither `pull_request` nor `merge_group`, where an `&&` needs one such operand and an `||` needs every operand to be), jobs that need such a job or the junction job itself, and the ids in `junctionExempt`. What the junction job decides on is read as text: it must read the results of its needs (`needs.*.result`, `needs.<job>.result` or `toJSON(needs)`) and name both `'failure'` and `'cancelled'`, or use the `re-actors/alls-green` action; a result read in a way the text does not show is reported as incomplete.
3281
+ * Jobs are recognised by id (see the options), and a role with no job in a workflow is not judged there. Ordering is judged through the transitive `needs` graph; the junction job must list every job directly, because `needs.*.result` only covers direct needs, except jobs that never run for a pull request or a merge group (an `if` on `github.ref` or `github.event_name` that mentions neither `pull_request` nor `merge_group`, where an `&&` needs one such operand and an `||` needs every operand to be), jobs that need such a job or the junction job itself, and the ids in `junctionExempt`. What the junction job decides on is read as text, with the environment variables its scripts and conditions read replaced by the expressions that set them: it must read the results of its needs (`needs.*.result`, `needs.<job>.result` or `toJSON(needs)`) and name both `'failure'` and `'cancelled'` or test a result for `!= 'success'`, or use the `re-actors/alls-green` action; a result read in a way the text does not show is reported as incomplete. A `!= 'success'` test fails on a skip too, which is reported only for a job an `if` can skip without the junction reading what that `if` reads.
3068
3282
  */
3069
3283
  const workflowJobOrdering = async ({ cwd, options }) => (await loadWorkflows(cwd, options)).flatMap((workflow) => [...releaseAndDocsOrdering(workflow, options), ...junctionOrdering(workflow, options)]);
3070
3284
  //#endregion
@@ -3072,12 +3286,21 @@ const workflowJobOrdering = async ({ cwd, options }) => (await loadWorkflows(cwd
3072
3286
  /**
3073
3287
  * A workflow that checks pull requests also runs for the merge queue: GitHub starts no run of it for a queued pull request unless the workflow has a `merge_group` trigger, and a required check that never reports blocks the queue.
3074
3288
  *
3075
- * A workflow is taken to produce required checks when it is triggered by `pull_request`; `pull_request_target` is left out, since it runs trusted code against untrusted input and is not a place for required checks. The file cannot show which checks are required, so a `pull_request` workflow whose checks are not required is reported too: narrow the check with `workflows` and `exclude`. A `merge_group` trigger on a workflow with no `pull_request` trigger is not judged. Limits: a `merge_group` trigger takes only `branches` filters (`paths` is a filter of `push`, `pull_request` and `pull_request_target`), so a workflow path-filtered for pull requests still runs in the queue once it has the trigger; the `branches` filter of a `merge_group` trigger is not read.
3289
+ * A workflow is taken to produce required checks when it is triggered by `pull_request`; `pull_request_target` is left out, since it runs trusted code against untrusted input and is not a place for required checks. The file cannot show which checks are required, so a `pull_request` workflow whose checks are not required is reported too: narrow the check with `workflows` and `exclude`. A `merge_group` trigger on a workflow with no `pull_request` trigger is not judged.
3290
+ *
3291
+ * Whether the branch has a merge queue is a setting, not something a file shows. Given a GitHub client, the check reads the rules of the branch (`branch`, else the default branch, of `repository`, else the `origin` remote) once it has found a workflow without the trigger, and reports only when a `merge_queue` rule applies, since without a queue no `merge_group` event is ever sent. A repository whose plan has no rulesets (the client rejects with `RulesetsUnavailableError`) has no merge queue either, since GitHub offers merge queues only in public repositories of an organisation and in private ones on GitHub Enterprise Cloud, both of which have rulesets, so nothing is reported for it; any other refusal fails the run. Offline it cannot tell, so it reports every such workflow.
3292
+ *
3293
+ * Limits: a `merge_group` trigger takes only `branches` filters (`paths` is a filter of `push`, `pull_request` and `pull_request_target`), so a workflow path-filtered for pull requests still runs in the queue once it has the trigger; the `branches` filter of a `merge_group` trigger is not read; only rulesets are read, so a queue configured through classic branch protection is not seen and, with a client, nothing is reported for it.
3076
3294
  */
3077
- const workflowMergeGroup = async ({ cwd, options }) => (await loadWorkflows(cwd, options)).flatMap((workflow) => {
3078
- const location = workflow.triggerLocations.get("pull_request");
3079
- return location === void 0 || workflow.triggers.has("merge_group") ? [] : [workflowViolation(workflow, "workflow-merge-group/missing-trigger", "the workflow runs for pull requests but has no merge_group trigger, so its checks do not report for a pull request in the merge queue", location)];
3080
- });
3295
+ const workflowMergeGroup = async ({ cwd, options, github }) => {
3296
+ const violations = (await loadWorkflows(cwd, options)).flatMap((workflow) => {
3297
+ const location = workflow.triggerLocations.get("pull_request");
3298
+ return location === void 0 || workflow.triggers.has("merge_group") ? [] : [workflowViolation(workflow, "workflow-merge-group/missing-trigger", "the workflow runs for pull requests but has no merge_group trigger, so its checks do not report for a pull request in the merge queue", location)];
3299
+ });
3300
+ if (violations.length === 0 || github === void 0) return violations;
3301
+ const branch = await branchRulesOf(github, await repositoryOf(cwd, options), options);
3302
+ return "rules" in branch && branch.rules.mergeQueue ? violations : [];
3303
+ };
3081
3304
  //#endregion
3082
3305
  //#region src/checks/workflow-release-job.ts
3083
3306
  const CHECKOUT_ACTION$1 = "actions/checkout";
@@ -3272,7 +3495,7 @@ const workflowRepositoryDispatch = async ({ cwd, options }) => (await loadWorkfl
3272
3495
  //#endregion
3273
3496
  //#region src/checks/workflow-runner-resolution.ts
3274
3497
  /**
3275
- * GitHub's own images: `ubuntu`, `windows` and `macos`, as `-latest`, a version, or a version with an architecture or size suffix, and `ubuntu-slim`.
3498
+ * The `hostedLabels` used when the options set none: GitHub's own images, `ubuntu`, `windows` and `macos`, as `-latest`, a version, or a version with an architecture or size suffix, and `ubuntu-slim`. Setting `hostedLabels` replaces it, so a workflow that also falls back to a third-party hosted runner extends it: `[...DEFAULT_HOSTED_LABELS, '^blacksmith-']`.
3276
3499
  */
3277
3500
  const DEFAULT_HOSTED_LABELS = ["^(?:ubuntu|windows|macos)-(?:latest|slim|[0-9][0-9.]*)(?:-(?:arm|intel|xlarge|large))*$"];
3278
3501
  /**
@@ -3284,10 +3507,6 @@ const RESOLVED_RUNNER = /fromJSON\(\s*needs\.([\w-]+)\.outputs\.([\w-]+)\s*\)/gi
3284
3507
  */
3285
3508
  const JOB_OUTPUT_REFERENCE = /^\$\{\{\s*jobs\.([\w-]+)\.outputs\.([\w-]+)\s*\}\}$/u;
3286
3509
  /**
3287
- * A fallback in an output expression: `||` followed by a string literal, whose body may hold `''` for a quote.
3288
- */
3289
- const LITERAL_FALLBACK = /\|\|\s*'((?:[^']|'')*)'/gu;
3290
- /**
3291
3510
  * The resolver behind each `fromJson(needs.<job>.outputs.<name>)` in a `runs-on` of the workflow; none for one this check cannot read.
3292
3511
  */
3293
3512
  function resolversOf(workflow, workflows) {
@@ -3305,7 +3524,8 @@ function implementation(workflow, workflows, caller, output) {
3305
3524
  if (caller.uses === void 0) return {
3306
3525
  workflow,
3307
3526
  job: caller,
3308
- output
3527
+ output,
3528
+ inputs: {}
3309
3529
  };
3310
3530
  const reference = parseUses(caller.uses);
3311
3531
  if (reference.kind !== "local") return;
@@ -3314,17 +3534,22 @@ function implementation(workflow, workflows, caller, output) {
3314
3534
  const forwarded = JOB_OUTPUT_REFERENCE.exec(called.callOutputs[output] ?? "");
3315
3535
  const job = called.jobs.find((candidate) => candidate.id === forwarded?.[1]);
3316
3536
  const jobOutput = forwarded?.[2];
3537
+ const inputs = Object.fromEntries(Object.entries(called.callInputs).map(([name, fallback]) => {
3538
+ const passed = caller.with[name];
3539
+ return [name, passed === void 0 ? fallback : hasExpression(passed) ? void 0 : passed];
3540
+ }));
3317
3541
  return job === void 0 || jobOutput === void 0 ? void 0 : {
3318
3542
  workflow: called,
3319
3543
  job,
3320
- output: jobOutput
3544
+ output: jobOutput,
3545
+ inputs
3321
3546
  };
3322
3547
  }
3323
3548
  /**
3324
- * The runner labels the last literal fallback of an output expression names, read as the JSON the `fromJson` that consumes it expects: a label or a list of labels. `undefined` when there is no literal fallback, and an empty list when the literal is not labels.
3549
+ * The runner labels the fallback of an output expression names, read as the JSON the `fromJson` that consumes it expects: a label or a list of labels. The fallback is the first operand after the first of the expression's top-level `||` chain whose value is the same on every run and not empty (see `constantString`), which is what the chain yields when the operands before it are empty. `undefined` when there is no such operand, and an empty list when its value is not labels.
3325
3550
  */
3326
- function fallbackLabels(expression) {
3327
- const literal = [...expression.matchAll(LITERAL_FALLBACK)].at(-1)?.[1]?.replaceAll("''", "'");
3551
+ function fallbackLabels(expression, inputs) {
3552
+ const literal = operands(unwrapped(expression), "||").slice(1).map((operand) => constantString(operand, inputs)).find((value) => value !== void 0 && value !== "");
3328
3553
  if (literal === void 0) return;
3329
3554
  let parsed;
3330
3555
  try {
@@ -3339,9 +3564,13 @@ function fallbackLabels(expression) {
3339
3564
  function resolverViolations(resolver, hosted) {
3340
3565
  const violations = [];
3341
3566
  if (resolver.job.timeoutMinutes === void 0) violations.push(workflowViolation(resolver.workflow, "workflow-runner-resolution/resolver-no-timeout", `resolver job '${resolver.job.id}' has no timeout-minutes, so a runner that never starts holds every job waiting for it until the default six hours`, resolver.job.location));
3342
- const fallback = fallbackLabels(resolver.job.outputs[resolver.output] ?? "");
3343
- if (fallback === void 0) violations.push(workflowViolation(resolver.workflow, "workflow-runner-resolution/resolver-no-fallback", `output '${resolver.output}' of resolver job '${resolver.job.id}' has no literal fallback (\`|| '["<hosted label>"]'\`), so the jobs that read it have no runner when resolution produces nothing`, resolver.job.location));
3344
- else if (fallback.length === 0 || !fallback.every((label) => hosted.some((pattern) => pattern.test(label)))) violations.push(workflowViolation(resolver.workflow, "workflow-runner-resolution/resolver-fallback-not-hosted", `the literal fallback of output '${resolver.output}' of resolver job '${resolver.job.id}' does not name only hosted runner labels, so it offers no runner when the self-hosted fleet is down`, resolver.job.location));
3567
+ const fallback = fallbackLabels(resolver.job.outputs[resolver.output] ?? "", resolver.inputs);
3568
+ if (fallback === void 0) violations.push(workflowViolation(resolver.workflow, "workflow-runner-resolution/resolver-no-fallback", `output '${resolver.output}' of resolver job '${resolver.job.id}' has no fallback whose value is known before the run (\`|| '["<hosted label>"]'\`, or format() of literals and of inputs with a literal value), so the jobs that read it have no runner when resolution produces nothing`, resolver.job.location));
3569
+ else if (fallback.length === 0) violations.push(workflowViolation(resolver.workflow, "workflow-runner-resolution/resolver-fallback-not-hosted", `the literal fallback of output '${resolver.output}' of resolver job '${resolver.job.id}' is not a runner label or a non-empty list of them, so the jobs that read it have no runner when resolution produces nothing`, resolver.job.location));
3570
+ else {
3571
+ const unhosted = fallback.filter((label) => !hosted.some((pattern) => pattern.test(label)));
3572
+ if (unhosted.length > 0) violations.push(workflowViolation(resolver.workflow, "workflow-runner-resolution/resolver-fallback-not-hosted", `the literal fallback of output '${resolver.output}' of resolver job '${resolver.job.id}' names ${unhosted.map((label) => `'${label}'`).join(", ")}, which ${unhosted.length === 1 ? "matches" : "match"} no pattern of hostedLabels, so it is not known to offer a runner when resolution produces nothing; name a hosted label, or add the label's pattern to hostedLabels (extending DEFAULT_HOSTED_LABELS) if it is hosted, by GitHub or by a third party`, resolver.job.location));
3573
+ }
3345
3574
  return violations;
3346
3575
  }
3347
3576
  function customLabels(job, hosted) {
@@ -3361,14 +3590,14 @@ function repeatedLabels(workflow, hosted) {
3361
3590
  /**
3362
3591
  * A self-hosted or custom runner label named literally in more than one job of a workflow is resolved once instead: a resolver job decides the runner and the others read it with `fromJson(needs.<resolver>.outputs.<name>)`. The resolver must have a `timeout-minutes` and its output a literal fallback that names only hosted runner labels, so jobs still get a runner when resolution fails or the self-hosted fleet is down.
3363
3592
  *
3364
- * A label is custom when it matches none of `hostedLabels`; the default patterns recognise GitHub's standard images, so a larger runner is named by a custom label and counts. Labels are compared within a workflow file, not across files. A resolver that is a reusable workflow is followed when it is a file of this repository (the output is traced through `on.workflow_call.outputs` to the job that sets it); a resolver in another repository cannot be read and is not judged. An output is taken to have a fallback when its expression has `||` followed by a string literal, and the last such literal is read as JSON (a label or a list of labels) and matched against `hostedLabels`; a fallback that is not valid JSON, or names any other label, is reported.
3593
+ * A label is custom when it matches none of `hostedLabels`; the default patterns recognise GitHub's standard images, so a larger runner is named by a custom label and counts. Labels are compared within a workflow file, not across files. A resolver that is a reusable workflow is followed when it is a file of this repository (the output is traced through `on.workflow_call.outputs` to the job that sets it); a resolver in another repository cannot be read and is not judged. An output's fallback is the first operand after the first of its expression's top-level `||` chain whose value is the same on every run: a string literal, or `format()` of literals and of inputs of the reusable workflow, read from the literal the caller passes or else the input's default. It is read as JSON (a label or a list of labels) and matched against `hostedLabels`; a fallback that is not valid JSON, or names any other label, is reported, and an output with no such operand has no fallback.
3365
3594
  */
3366
3595
  const workflowRunnerResolution = async ({ cwd, options }) => {
3367
3596
  const hosted = (options.hostedLabels ?? DEFAULT_HOSTED_LABELS).map((source) => new RegExp(source, "u"));
3368
3597
  const selected = await loadWorkflows(cwd, options);
3369
3598
  const available = await loadWorkflows(cwd, { workflows: DEFAULT_WORKFLOWS });
3370
3599
  const resolvers = /* @__PURE__ */ new Map();
3371
- for (const workflow of selected) for (const resolver of resolversOf(workflow, available)) resolvers.set(`${resolver.workflow.file}#${resolver.job.id}#${resolver.output}`, resolver);
3600
+ for (const workflow of selected) for (const resolver of resolversOf(workflow, available)) resolvers.set(`${resolver.workflow.file}#${resolver.job.id}#${resolver.output}#${JSON.stringify(resolver.inputs)}`, resolver);
3372
3601
  return [...selected.flatMap((workflow) => repeatedLabels(workflow, hosted)), ...[...resolvers.values()].flatMap((resolver) => resolverViolations(resolver, hosted))];
3373
3602
  };
3374
3603
  //#endregion
@@ -3734,7 +3963,10 @@ const checks = z.strictObject({
3734
3963
  dependabot: z.exactOptional(path),
3735
3964
  renovate: z.exactOptional(path)
3736
3965
  }))),
3737
- "workflow-merge-group": z.exactOptional(enabled(z.strictObject(selection))),
3966
+ "workflow-merge-group": z.exactOptional(enabled(z.strictObject({
3967
+ ...selection,
3968
+ ...settings
3969
+ }))),
3738
3970
  "workflow-action-pinning": z.exactOptional(enabled(z.strictObject({
3739
3971
  ...selection,
3740
3972
  thirdParty: z.exactOptional(pinningLevel),
@@ -3813,6 +4045,25 @@ function online(spec) {
3813
4045
  }
3814
4046
  };
3815
4047
  }
4048
+ function settingsAware(spec) {
4049
+ return {
4050
+ name: spec.name,
4051
+ description: spec.description,
4052
+ requiresLayout: false,
4053
+ requiresGitHub: false,
4054
+ isEnabled: (checks) => spec.select(checks) !== void 0,
4055
+ run: async ({ cwd, checks, configFiles, github }) => {
4056
+ const options = spec.select(checks);
4057
+ if (options === void 0) throw new ConformanceError(`the check '${spec.name}' is not enabled`);
4058
+ return spec.check({
4059
+ cwd,
4060
+ options,
4061
+ ...github === void 0 ? {} : { github },
4062
+ ...configFiles === void 0 ? {} : { configFiles }
4063
+ });
4064
+ }
4065
+ };
4066
+ }
3816
4067
  function importCheck(spec) {
3817
4068
  return {
3818
4069
  name: spec.name,
@@ -3974,7 +4225,7 @@ const registry = {
3974
4225
  select: (checks) => enabledOptions(checks["workflow-update-bot-cooldown"]),
3975
4226
  check: workflowUpdateBotCooldown
3976
4227
  }),
3977
- "workflow-merge-group": plain({
4228
+ "workflow-merge-group": settingsAware({
3978
4229
  name: "workflow-merge-group",
3979
4230
  description: "A workflow that runs for pull requests also runs for the merge queue",
3980
4231
  select: (checks) => enabledOptions(checks["workflow-merge-group"]),
@@ -4252,4 +4503,4 @@ function proveExhaustive(input) {
4252
4503
  }) };
4253
4504
  }
4254
4505
  //#endregion
4255
- export { ConformanceError, DEFAULT_DERIVED_TYPE_INFERENCES, DEFAULT_INFERENCES, DEFAULT_WORKFLOWS, EXIT_CODES, ExhaustivenessError, PRESET_COMMIT_TYPES, RulesetsUnavailableError, accessOf, aggregateMappers, checkNames, classifyPackages, codecPairs, commandTypes, commitTypes, conformanceSchema, conformanceSection, createGitHubClient, crossSliceRules, cycleRule, deployTools, derivedTypes, dockerfilePackageManager, effectiveEnv, effectivePermissions, enginesFloor, eslint, generators, handlerCases, importCrossSlice, importCycles, importIsolatedGroups, importRankSkip, importUphill, instructionSymlinks, isCheckName, isolatedGroupRules, loadWorkflows, migrationsDirectory, packagesPattern, parseUses, parseWorkflow, proveExhaustive, rankSkipRules, registry, removeCase, runChecks, settingsMergeMethods, settingsRequiredChecks, settingsReviewThreadResolution, singleStorybook, transitiveNeeds, typeErrors, uphillRules, workflowActionPinning, workflowCredentials, workflowJobOrdering, workflowMergeGroup, workflowReleaseJob, workflowRepositoryDispatch, workflowRunnerResolution, workflowSkippableJobs, workflowUpdateBotCooldown, workflowVersionSingleSource };
4506
+ export { ConformanceError, DEFAULT_DERIVED_TYPE_INFERENCES, DEFAULT_HOSTED_LABELS, DEFAULT_INFERENCES, DEFAULT_WORKFLOWS, EXIT_CODES, ExhaustivenessError, PRESET_COMMIT_TYPES, RulesetsUnavailableError, accessOf, aggregateMappers, checkNames, classifyPackages, codecPairs, commandTypes, commitTypes, conformanceSchema, conformanceSection, createGitHubClient, crossSliceRules, cycleRule, deployTools, derivedTypes, dockerfilePackageManager, effectiveEnv, effectivePermissions, enginesFloor, eslint, generators, handlerCases, importCrossSlice, importCycles, importIsolatedGroups, importRankSkip, importUphill, instructionSymlinks, isCheckName, isolatedGroupRules, loadWorkflows, migrationsDirectory, packagesPattern, parseUses, parseWorkflow, proveExhaustive, rankSkipRules, registry, removeCase, runChecks, settingsMergeMethods, settingsRequiredChecks, settingsReviewThreadResolution, singleStorybook, transitiveNeeds, typeErrors, uphillRules, workflowActionPinning, workflowCredentials, workflowJobOrdering, workflowMergeGroup, workflowReleaseJob, workflowRepositoryDispatch, workflowRunnerResolution, workflowSkippableJobs, workflowUpdateBotCooldown, workflowVersionSingleSource };