workspace-conformance 1.1.0 → 1.2.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.cjs CHANGED
@@ -248,7 +248,7 @@ function aliasNamed(typeName) {
248
248
  /**
249
249
  * Why a command type is not derived from a schema, or `undefined` when it is. `followed` holds the aliases already followed from the exported type, so a chain of aliases is followed to its end and a loop of them is not followed twice.
250
250
  */
251
- function problem(declaration, name, inferences, followed = /* @__PURE__ */ new Set()) {
251
+ function problem$1(declaration, name, inferences, followed = /* @__PURE__ */ new Set()) {
252
252
  if (ts_morph.Node.isInterfaceDeclaration(declaration)) return {
253
253
  reason: "hand-written-interface",
254
254
  message: `${name} is a hand-written interface; derive the command from its schema`
@@ -265,7 +265,7 @@ function problem(declaration, name, inferences, followed = /* @__PURE__ */ new S
265
265
  };
266
266
  }
267
267
  const alias = aliasNamed(written.getTypeName());
268
- if (alias !== void 0 && !followed.has(alias)) return problem(alias, name, inferences, /* @__PURE__ */ new Set([...followed, alias]));
268
+ if (alias !== void 0 && !followed.has(alias)) return problem$1(alias, name, inferences, /* @__PURE__ */ new Set([...followed, alias]));
269
269
  }
270
270
  return {
271
271
  reason: "hand-written-alias",
@@ -289,7 +289,7 @@ const commandTypes = async ({ cwd, options }) => {
289
289
  for (const file of files) for (const [name, declaration] of exportedTypes(project.getSourceFileOrThrow((0, node_path.resolve)(cwd, file)))) {
290
290
  if (excluded.has(name) || seen.has(declaration)) continue;
291
291
  seen.add(declaration);
292
- const found = problem(declaration, name, inferences);
292
+ const found = problem$1(declaration, name, inferences);
293
293
  if (found !== void 0) violations.push({
294
294
  code: `command-types/${found.reason}`,
295
295
  message: found.message,
@@ -1236,6 +1236,17 @@ async function blobContent(cwd, blob) {
1236
1236
  blob
1237
1237
  ]);
1238
1238
  }
1239
+ /**
1240
+ * The URL of the remote `name` as configured, before any `url.<base>.insteadOf` rewriting (which `git remote get-url` applies and which can turn a GitHub address into a host alias), or `undefined` when the working tree has no such remote.
1241
+ */
1242
+ async function remoteUrl(cwd, name) {
1243
+ if (!(await git(cwd, ["remote"])).split("\n").includes(name)) return;
1244
+ return (await git(cwd, [
1245
+ "config",
1246
+ "--get",
1247
+ `remote.${name}.url`
1248
+ ])).trim();
1249
+ }
1239
1250
  //#endregion
1240
1251
  //#region src/checks/instruction-symlinks.ts
1241
1252
  /**
@@ -1385,7 +1396,7 @@ const deployTools = { wrangler };
1385
1396
  /**
1386
1397
  * The first of `candidates` (relative to `cwd`) that exists, or `undefined` when none does.
1387
1398
  */
1388
- function firstExisting(cwd, candidates) {
1399
+ function firstExisting$1(cwd, candidates) {
1389
1400
  return candidates.find((candidate) => (0, node_fs.existsSync)((0, node_path.resolve)(cwd, candidate)));
1390
1401
  }
1391
1402
  function assertValid(errors, file) {
@@ -1441,7 +1452,7 @@ function positionOf(text, offset) {
1441
1452
  * The config file a check reads: the one the option names, else the first of the adapter's file names that exists. `undefined` when there is none.
1442
1453
  */
1443
1454
  function locate(cwd, explicit, candidates) {
1444
- return explicit === void 0 ? firstExisting(cwd, candidates) : firstExisting(cwd, [explicit]);
1455
+ return explicit === void 0 ? firstExisting$1(cwd, candidates) : firstExisting$1(cwd, [explicit]);
1445
1456
  }
1446
1457
  function missingConfig(explicit, candidates) {
1447
1458
  return explicit === void 0 ? {
@@ -1510,6 +1521,508 @@ const migrationsDirectory = async ({ cwd, options, configFiles }) => {
1510
1521
  return violations;
1511
1522
  };
1512
1523
  //#endregion
1524
+ //#region src/settings/repository.ts
1525
+ const SLUG = /^([^/\s]+)\/([^/\s]+)$/u;
1526
+ const REMOTE = /github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/u;
1527
+ const REMOTE_NAME = "origin";
1528
+ /**
1529
+ * The repository the settings checks read: `repository` from the options, else the one the `origin` remote of `cwd` points at.
1530
+ */
1531
+ async function repositoryOf(cwd, options) {
1532
+ if (options.repository !== void 0) {
1533
+ const [, owner, name] = SLUG.exec(options.repository) ?? [];
1534
+ if (owner === void 0 || name === void 0) throw new ConformanceError(`the repository '${options.repository}' is not written owner/name`);
1535
+ return {
1536
+ owner,
1537
+ name
1538
+ };
1539
+ }
1540
+ const url = await remoteUrl(cwd, REMOTE_NAME);
1541
+ const [, owner, name] = REMOTE.exec(url ?? "") ?? [];
1542
+ if (owner === void 0 || name === void 0) throw new ConformanceError(`${cwd} has no '${REMOTE_NAME}' remote on github.com; set the repository option`);
1543
+ return {
1544
+ owner,
1545
+ name
1546
+ };
1547
+ }
1548
+ /**
1549
+ * The branch whose rules are read: `branch` from the options, else the repository's default branch.
1550
+ */
1551
+ async function branchOf(github, slug, options) {
1552
+ return options.branch ?? (await github.repository(slug)).defaultBranch;
1553
+ }
1554
+ /**
1555
+ * What a violation of a settings check names as its file.
1556
+ */
1557
+ function repositoryFile(slug) {
1558
+ return `${slug.owner}/${slug.name}`;
1559
+ }
1560
+ //#endregion
1561
+ //#region src/checks/settings-merge-methods.ts
1562
+ const METHODS = [
1563
+ "rebase",
1564
+ "squash",
1565
+ "merge"
1566
+ ];
1567
+ /**
1568
+ * 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.
1569
+ *
1570
+ * Limits: it reads the repository settings and the rules of rulesets that apply to the branch; classic branch protection and a merge queue's own merge method are not read, so a repository whose queue owns the strategy is judged by what it still allows for a direct merge.
1571
+ */
1572
+ const settingsMergeMethods = async ({ cwd, options, github }) => {
1573
+ const slug = await repositoryOf(cwd, options);
1574
+ const file = repositoryFile(slug);
1575
+ const settings = await github.repository(slug);
1576
+ const rules = await github.branchRules(slug, await branchOf(github, slug, options));
1577
+ const wanted = options.allowed ?? "rebase";
1578
+ const repositoryAllows = {
1579
+ rebase: settings.allowRebaseMerge,
1580
+ squash: settings.allowSquashMerge,
1581
+ merge: settings.allowMergeCommit
1582
+ };
1583
+ const available = METHODS.filter((method) => repositoryAllows[method] && rules.pullRequests.every((rule) => rule.allowedMergeMethods === void 0 || rule.allowedMergeMethods.includes(method)));
1584
+ const violations = available.filter((method) => method !== wanted).map((method) => ({
1585
+ code: "settings-merge-methods/method-enabled",
1586
+ message: `${file} allows ${method} merges; only ${wanted} should be allowed`,
1587
+ file
1588
+ }));
1589
+ if (!available.includes(wanted)) violations.push({
1590
+ code: "settings-merge-methods/method-unavailable",
1591
+ message: `${file} does not allow ${wanted} merges`,
1592
+ file
1593
+ });
1594
+ return violations;
1595
+ };
1596
+ //#endregion
1597
+ //#region src/workflows/model.ts
1598
+ /**
1599
+ * The access `permissions` grants to `scope`, or `undefined` when the permissions are not declared and the repository default applies.
1600
+ */
1601
+ function accessOf(permissions, scope) {
1602
+ if (permissions.kind === "default") return;
1603
+ return permissions.kind === "all" ? permissions.access : permissions.scopes.get(scope) ?? "none";
1604
+ }
1605
+ /**
1606
+ * The text of a scalar value; `undefined` for anything that is not a string, number or boolean.
1607
+ */
1608
+ function scalarText(value) {
1609
+ return typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? String(value) : void 0;
1610
+ }
1611
+ /**
1612
+ * Where a node of the document is: the key for a mapping entry, the item for a sequence entry, else the node itself. Positions are counted from 1.
1613
+ */
1614
+ function locationAt(document, lineCounter, path) {
1615
+ const parent = path.length === 0 ? document.contents : document.getIn(path.slice(0, -1), true);
1616
+ const last = path.at(-1);
1617
+ let range;
1618
+ if ((0, yaml.isMap)(parent) && typeof last === "string") {
1619
+ const pair = parent.items.find((item) => (0, yaml.isScalar)(item.key) && item.key.value === last);
1620
+ range = (0, yaml.isScalar)(pair?.key) ? pair.key.range : void 0;
1621
+ } else {
1622
+ const target = (0, yaml.isSeq)(parent) && typeof last === "number" ? parent.items[last] : parent;
1623
+ range = (0, yaml.isNode)(target) ? target.range : void 0;
1624
+ }
1625
+ const position = lineCounter.linePos(range?.[0] ?? 0);
1626
+ return {
1627
+ line: position.line,
1628
+ column: position.col
1629
+ };
1630
+ }
1631
+ /**
1632
+ * Parse YAML text. It throws `ConformanceError` naming `file` when the text is not valid YAML.
1633
+ */
1634
+ function parseYaml(file, source) {
1635
+ const lineCounter = new yaml.LineCounter();
1636
+ const document = (0, yaml.parseDocument)(source, {
1637
+ lineCounter,
1638
+ prettyErrors: false
1639
+ });
1640
+ const [error] = document.errors;
1641
+ if (error !== void 0) throw new ConformanceError(`${file}: not valid YAML: ${error.message}`, { cause: error });
1642
+ return {
1643
+ value: document.toJS(),
1644
+ locate: (path) => locationAt(document, lineCounter, path)
1645
+ };
1646
+ }
1647
+ /**
1648
+ * A reader for one file, so the helpers can name the field they read when the file is not a workflow.
1649
+ */
1650
+ var Reader = class {
1651
+ file;
1652
+ parsed;
1653
+ constructor(file, parsed) {
1654
+ this.file = file;
1655
+ this.parsed = parsed;
1656
+ }
1657
+ fail(what) {
1658
+ throw new ConformanceError(`${this.file}: ${what}`);
1659
+ }
1660
+ location(path) {
1661
+ return this.parsed.locate(path);
1662
+ }
1663
+ record(value, what) {
1664
+ if (value === void 0 || value === null) return {};
1665
+ if (!isRecord(value)) return this.fail(`${what} must be a mapping`);
1666
+ return value;
1667
+ }
1668
+ text(value, what) {
1669
+ if (value === void 0 || value === null) return;
1670
+ return scalarText(value) ?? this.fail(`${what} must be a string, number or boolean`);
1671
+ }
1672
+ texts(value, what) {
1673
+ return Object.fromEntries(Object.entries(this.record(value, what)).map(([key, entry]) => [key, this.text(entry, `${what}.${key}`) ?? ""]));
1674
+ }
1675
+ list(value, what) {
1676
+ if (value === void 0 || value === null) return [];
1677
+ return (Array.isArray(value) ? value : [value]).map((item) => this.text(item, what) ?? this.fail(`${what} must be a string or a list of strings`));
1678
+ }
1679
+ };
1680
+ const PERMISSION_ACCESS = /* @__PURE__ */ new Set([
1681
+ "read",
1682
+ "write",
1683
+ "none"
1684
+ ]);
1685
+ function isPermissionAccess(value) {
1686
+ return PERMISSION_ACCESS.has(value);
1687
+ }
1688
+ function permissionsOf(reader, value, what) {
1689
+ if (value === void 0 || value === null) return { kind: "default" };
1690
+ if (value === "read-all") return {
1691
+ kind: "all",
1692
+ access: "read"
1693
+ };
1694
+ if (value === "write-all") return {
1695
+ kind: "all",
1696
+ access: "write"
1697
+ };
1698
+ const scopes = /* @__PURE__ */ new Map();
1699
+ for (const [scope, access] of Object.entries(reader.record(value, what))) {
1700
+ const text = reader.text(access, `${what}.${scope}`);
1701
+ if (text === void 0 || !isPermissionAccess(text)) return reader.fail(`${what}.${scope} must be read, write or none`);
1702
+ scopes.set(scope, text);
1703
+ }
1704
+ return {
1705
+ kind: "scopes",
1706
+ scopes
1707
+ };
1708
+ }
1709
+ function runsOnOf(reader, value, what) {
1710
+ const mapping = isRecord(value) ? value : void 0;
1711
+ const entries = reader.list(mapping === void 0 ? value : mapping["labels"], what);
1712
+ const group = mapping === void 0 ? void 0 : reader.text(mapping["group"], `${what}.group`);
1713
+ return {
1714
+ labels: entries.filter((entry) => !entry.includes("${{")),
1715
+ expressions: entries.filter((entry) => entry.includes("${{")),
1716
+ group
1717
+ };
1718
+ }
1719
+ function stepOf(reader, value, jobPath, index) {
1720
+ const path = [
1721
+ ...jobPath,
1722
+ "steps",
1723
+ index
1724
+ ];
1725
+ const what = path.join(".");
1726
+ const step = reader.record(value, what);
1727
+ return {
1728
+ index,
1729
+ id: reader.text(step["id"], `${what}.id`),
1730
+ name: reader.text(step["name"], `${what}.name`),
1731
+ uses: reader.text(step["uses"], `${what}.uses`),
1732
+ run: reader.text(step["run"], `${what}.run`),
1733
+ with: reader.texts(step["with"], `${what}.with`),
1734
+ env: reader.texts(step["env"], `${what}.env`),
1735
+ condition: reader.text(step["if"], `${what}.if`),
1736
+ location: reader.location(path)
1737
+ };
1738
+ }
1739
+ function jobOf(reader, id, value) {
1740
+ const path = ["jobs", id];
1741
+ const what = path.join(".");
1742
+ const job = reader.record(value, what);
1743
+ const steps = job["steps"];
1744
+ if (steps !== void 0 && !Array.isArray(steps)) return reader.fail(`${what}.steps must be a list`);
1745
+ return {
1746
+ id,
1747
+ name: reader.text(job["name"], `${what}.name`),
1748
+ needs: reader.list(job["needs"], `${what}.needs`),
1749
+ condition: reader.text(job["if"], `${what}.if`),
1750
+ permissions: permissionsOf(reader, job["permissions"], `${what}.permissions`),
1751
+ env: reader.texts(job["env"], `${what}.env`),
1752
+ runsOn: runsOnOf(reader, job["runs-on"], `${what}.runs-on`),
1753
+ timeoutMinutes: reader.text(job["timeout-minutes"], `${what}.timeout-minutes`),
1754
+ uses: reader.text(job["uses"], `${what}.uses`),
1755
+ with: reader.texts(job["with"], `${what}.with`),
1756
+ outputs: reader.texts(job["outputs"], `${what}.outputs`),
1757
+ steps: (steps ?? []).map((step, index) => stepOf(reader, step, path, index)),
1758
+ location: reader.location(path)
1759
+ };
1760
+ }
1761
+ function triggerLocationsOf(reader, triggers, value) {
1762
+ const events = [...triggers.keys()];
1763
+ return new Map(events.map((event, index) => [event, reader.location(Array.isArray(value) ? ["on", index] : isRecord(value) ? ["on", event] : ["on"])]));
1764
+ }
1765
+ function triggersOf(reader, value) {
1766
+ if (typeof value === "string") return /* @__PURE__ */ new Map([[value, void 0]]);
1767
+ if (Array.isArray(value)) return new Map(reader.list(value, "on").map((event) => [event, void 0]));
1768
+ return new Map(Object.entries(reader.record(value, "on")));
1769
+ }
1770
+ /**
1771
+ * Parse the text of a workflow file into the model the checks read. It throws `ConformanceError` when the file is not YAML or is not shaped like a workflow, since a check cannot judge what it cannot read.
1772
+ */
1773
+ function parseWorkflow(file, source) {
1774
+ const parsed = parseYaml(file, source);
1775
+ const reader = new Reader(file, parsed);
1776
+ const root = reader.record(parsed.value, "the workflow");
1777
+ const triggers = triggersOf(reader, root["on"]);
1778
+ const call = triggers.get("workflow_call");
1779
+ const outputs = reader.record(reader.record(call, "on.workflow_call")["outputs"], "on.workflow_call.outputs");
1780
+ return {
1781
+ file,
1782
+ name: reader.text(root["name"], "name"),
1783
+ triggers,
1784
+ triggerLocations: triggerLocationsOf(reader, triggers, root["on"]),
1785
+ permissions: permissionsOf(reader, root["permissions"], "permissions"),
1786
+ env: reader.texts(root["env"], "env"),
1787
+ jobs: Object.entries(reader.record(root["jobs"], "jobs")).map(([id, job]) => jobOf(reader, id, job)),
1788
+ 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`) ?? ""]))
1789
+ };
1790
+ }
1791
+ /**
1792
+ * The permissions that apply to a job: its own when it declares any, else the workflow's. A job's declaration replaces the workflow's whole, it is not merged with it.
1793
+ */
1794
+ function effectivePermissions(workflow, job) {
1795
+ return job.permissions.kind === "default" ? workflow.permissions : job.permissions;
1796
+ }
1797
+ /**
1798
+ * The environment a step runs with: the workflow's, the job's and the step's, the later winning.
1799
+ */
1800
+ function effectiveEnv(workflow, job, step) {
1801
+ return {
1802
+ ...workflow.env,
1803
+ ...job.env,
1804
+ ...step?.env
1805
+ };
1806
+ }
1807
+ /**
1808
+ * Every job `jobId` waits for, directly or through other jobs, not including itself. A `needs` that names no job of the workflow is followed no further.
1809
+ */
1810
+ function transitiveNeeds(workflow, jobId) {
1811
+ const byId = new Map(workflow.jobs.map((job) => [job.id, job]));
1812
+ const reached = /* @__PURE__ */ new Set();
1813
+ const pending = [...byId.get(jobId)?.needs ?? []];
1814
+ for (let next = pending.pop(); next !== void 0; next = pending.pop()) if (!reached.has(next)) {
1815
+ reached.add(next);
1816
+ pending.push(...byId.get(next)?.needs ?? []);
1817
+ }
1818
+ reached.delete(jobId);
1819
+ return reached;
1820
+ }
1821
+ /**
1822
+ * Split a `uses` value into its parts.
1823
+ */
1824
+ function parseUses(uses) {
1825
+ if (uses.startsWith("./")) return {
1826
+ kind: "local",
1827
+ path: uses
1828
+ };
1829
+ if (uses.startsWith("docker://")) {
1830
+ const reference = uses.slice(9);
1831
+ const at = reference.indexOf("@");
1832
+ return at === -1 ? {
1833
+ kind: "docker",
1834
+ image: reference,
1835
+ digest: void 0
1836
+ } : {
1837
+ kind: "docker",
1838
+ image: reference.slice(0, at),
1839
+ digest: reference.slice(at + 1)
1840
+ };
1841
+ }
1842
+ const at = uses.lastIndexOf("@");
1843
+ const [owner = "", repository = "", ...path] = (at === -1 ? uses : uses.slice(0, at)).split("/");
1844
+ return {
1845
+ kind: "remote",
1846
+ owner,
1847
+ repository,
1848
+ path: path.join("/"),
1849
+ ref: at === -1 ? void 0 : uses.slice(at + 1)
1850
+ };
1851
+ }
1852
+ /**
1853
+ * Whether `value` contains an expression, which is only known when the workflow runs.
1854
+ */
1855
+ function hasExpression(value) {
1856
+ return value.includes("${{");
1857
+ }
1858
+ //#endregion
1859
+ //#region src/workflows/load.ts
1860
+ /**
1861
+ * Where GitHub looks for workflows: only this directory, and only the files directly in it.
1862
+ */
1863
+ const DEFAULT_WORKFLOWS = [".github/workflows/*.yml", ".github/workflows/*.yaml"];
1864
+ /**
1865
+ * Read and parse the selected workflow files, sorted by path.
1866
+ */
1867
+ async function loadWorkflows(cwd, selection) {
1868
+ const files = await findFiles(cwd, [...selection.workflows ?? DEFAULT_WORKFLOWS, ...excludedFrom(selection.exclude)]);
1869
+ return Promise.all(files.map(async (file) => parseWorkflow(file, await (0, node_fs_promises.readFile)((0, node_path.resolve)(cwd, file), "utf8"))));
1870
+ }
1871
+ //#endregion
1872
+ //#region src/workflows/shared.ts
1873
+ /**
1874
+ * A violation about a place in a workflow file.
1875
+ */
1876
+ function workflowViolation(workflow, code, message, location) {
1877
+ return {
1878
+ code,
1879
+ message,
1880
+ file: workflow.file,
1881
+ location
1882
+ };
1883
+ }
1884
+ /**
1885
+ * The commands of a `run` script as logical lines: a line ended by a backslash is joined to the next one, comment lines and blank lines are dropped.
1886
+ */
1887
+ function commandLines(run) {
1888
+ const lines = [];
1889
+ let continued = "";
1890
+ for (const line of run.split("\n")) {
1891
+ const joined = `${continued}${line.trim()}`;
1892
+ if (joined.endsWith("\\")) continued = `${joined.slice(0, -1).trimEnd()} `;
1893
+ else {
1894
+ continued = "";
1895
+ if (joined !== "" && !joined.startsWith("#")) lines.push(joined);
1896
+ }
1897
+ }
1898
+ if (continued !== "") lines.push(continued.trim());
1899
+ return lines;
1900
+ }
1901
+ /**
1902
+ * Variable assignments that prefix a command (`HUSKY=0 pnpm exec ...`).
1903
+ */
1904
+ const LEADING_ASSIGNMENTS = /^(?:[A-Za-z_]\w*=\S*\s+)+/u;
1905
+ /**
1906
+ * The simple commands of a logical line, without the variable assignments that prefix them: it is split at `&&`, `||`, `;`, `|` and `&` that are outside quotes and outside `${{ }}` expressions, and that are not part of a redirection (`2>&1`, `&>file`), so text inside a quoted string (an `echo` of a command) is not mistaken for a command.
1907
+ */
1908
+ function commandSegments(line) {
1909
+ const segments = [];
1910
+ let current = "";
1911
+ let quote;
1912
+ for (let index = 0; index < line.length; index += 1) {
1913
+ const rest = line.slice(index);
1914
+ const character = rest.charAt(0);
1915
+ if (quote !== void 0) {
1916
+ current += character;
1917
+ quote = character === quote ? void 0 : quote;
1918
+ } else if (rest.startsWith("${{")) {
1919
+ const end = rest.indexOf("}}");
1920
+ const expression = end === -1 ? rest : rest.slice(0, end + 2);
1921
+ current += expression;
1922
+ index += expression.length - 1;
1923
+ } else if (character === "\"" || character === "'") {
1924
+ current += character;
1925
+ quote = character;
1926
+ } else if (character === "&" && (current.endsWith(">") || current.endsWith("<") || rest.startsWith("&>"))) current += character;
1927
+ else if (character === "&" || character === "|" || character === ";") {
1928
+ segments.push(current);
1929
+ current = "";
1930
+ } else current += character;
1931
+ }
1932
+ segments.push(current);
1933
+ return segments.map((segment) => segment.trim().replace(LEADING_ASSIGNMENTS, "")).filter((segment) => segment !== "");
1934
+ }
1935
+ /**
1936
+ * The simple commands of every line of a `run` script.
1937
+ */
1938
+ function scriptCommands(run) {
1939
+ return commandLines(run ?? "").flatMap(commandSegments);
1940
+ }
1941
+ /**
1942
+ * Whether a step uses the action `owner/repository`, at any path or ref.
1943
+ */
1944
+ function stepUses(step, action) {
1945
+ const target = step.uses?.split("@")[0]?.toLowerCase();
1946
+ return target === action.toLowerCase() || target?.startsWith(`${action.toLowerCase()}/`) === true;
1947
+ }
1948
+ /**
1949
+ * The jobs among `ids` that exist in the workflow, in the workflow's order.
1950
+ */
1951
+ function jobsNamed(workflow, ids) {
1952
+ return workflow.jobs.filter((job) => ids.includes(job.id));
1953
+ }
1954
+ /**
1955
+ * Whether an `if` expression calls `always()`.
1956
+ */
1957
+ function callsAlways(condition) {
1958
+ return condition !== void 0 && /\balways\(\)/u.test(condition);
1959
+ }
1960
+ /**
1961
+ * The default ids of junction jobs, shared by the checks that look for one.
1962
+ */
1963
+ const DEFAULT_JUNCTION_JOBS = ["required-checks"];
1964
+ //#endregion
1965
+ //#region src/checks/settings-required-checks.ts
1966
+ /**
1967
+ * The branch requires the check of every junction job of the workflows, and requires branches to be up to date before merging (strict mode). The check name of a job is its `name`, else its id.
1968
+ *
1969
+ * The junction job is the one that reports for the whole workflow, so requiring it requires everything it needs, including jobs a path filter may skip. Codes: `no-required-checks` (no `required_status_checks` rule applies), `required-check-missing` (a junction job's check is not among the required ones), `not-strict` (no rule sets the strict policy) and `no-junction-job` (no workflow has a job with an id from `junctionJobs`).
1970
+ *
1971
+ * Limits: it reads the rules of rulesets that apply to the branch; classic branch protection is not read, so a repository that uses it is reported as having no required checks. A junction job in a reusable workflow, or a name that is an expression, is not resolved.
1972
+ */
1973
+ const settingsRequiredChecks = async ({ cwd, options, github }) => {
1974
+ const slug = await repositoryOf(cwd, options);
1975
+ const file = repositoryFile(slug);
1976
+ const rules = await github.branchRules(slug, await branchOf(github, slug, options));
1977
+ const junctions = (await loadWorkflows(cwd, options)).flatMap((workflow) => jobsNamed(workflow, options.junctionJobs ?? DEFAULT_JUNCTION_JOBS).map((job) => ({
1978
+ workflow,
1979
+ name: job.name ?? job.id
1980
+ })));
1981
+ const violations = [];
1982
+ if (junctions.length === 0) violations.push({
1983
+ code: "settings-required-checks/no-junction-job",
1984
+ message: "no workflow has a junction job, so there is no check that reports for the whole workflow to require",
1985
+ file
1986
+ });
1987
+ if (rules.requiredStatusChecks.length === 0) {
1988
+ violations.push({
1989
+ code: "settings-required-checks/no-required-checks",
1990
+ message: `no rule requires status checks on the branch of ${file}`,
1991
+ file
1992
+ });
1993
+ return violations;
1994
+ }
1995
+ const required = new Set(rules.requiredStatusChecks.flatMap((rule) => rule.contexts));
1996
+ for (const { workflow, name } of junctions.filter((junction) => !required.has(junction.name))) violations.push({
1997
+ code: "settings-required-checks/required-check-missing",
1998
+ message: `${file} does not require the check '${name}' of ${workflow.file}`,
1999
+ file
2000
+ });
2001
+ if (!rules.requiredStatusChecks.some((rule) => rule.strict)) violations.push({
2002
+ code: "settings-required-checks/not-strict",
2003
+ message: `${file} does not require branches to be up to date before merging`,
2004
+ file
2005
+ });
2006
+ return violations;
2007
+ };
2008
+ //#endregion
2009
+ //#region src/checks/settings-review-thread-resolution.ts
2010
+ /**
2011
+ * A pull request cannot merge while a review conversation is unresolved: a `pull_request` rule that applies to the branch sets `required_review_thread_resolution`.
2012
+ *
2013
+ * Limits: it reads the rules of rulesets; classic branch protection is not read.
2014
+ */
2015
+ const settingsReviewThreadResolution = async ({ cwd, options, github }) => {
2016
+ const slug = await repositoryOf(cwd, options);
2017
+ const rules = await github.branchRules(slug, await branchOf(github, slug, options));
2018
+ const file = repositoryFile(slug);
2019
+ return rules.pullRequests.some((rule) => rule.requiredReviewThreadResolution) ? [] : [{
2020
+ code: "settings-review-thread-resolution/not-required",
2021
+ message: `no rule requires review conversations to be resolved before merging on the branch of ${file}`,
2022
+ file
2023
+ }];
2024
+ };
2025
+ //#endregion
1513
2026
  //#region src/checks/single-storybook.ts
1514
2027
  const STORYBOOK_DIRECTORY = ".storybook";
1515
2028
  /**
@@ -1525,6 +2038,718 @@ const singleStorybook = async ({ cwd, options }) => {
1525
2038
  }));
1526
2039
  };
1527
2040
  //#endregion
2041
+ //#region src/checks/workflow-action-pinning.ts
2042
+ const FULL_COMMIT_SHA = /^[0-9a-f]{40}$/u;
2043
+ const DOCKER_DIGEST = /^sha256:[0-9a-f]{64}$/u;
2044
+ /**
2045
+ * Whether `repository` (`owner/repository`) matches an `allow` entry, which is `owner/repository` or `owner/*`.
2046
+ */
2047
+ function allowed(owner, repository, allow) {
2048
+ return allow.some((entry) => entry.toLowerCase() === `${owner}/${repository}`.toLowerCase() || entry.toLowerCase() === `${owner}/*`.toLowerCase());
2049
+ }
2050
+ /**
2051
+ * Why a reference breaks the policy, with the code of the violation, or `undefined` when it follows it.
2052
+ */
2053
+ function problem(reference, options) {
2054
+ if (reference.kind === "local") return;
2055
+ if (reference.kind === "docker") return reference.digest !== void 0 && DOCKER_DIGEST.test(reference.digest) ? void 0 : {
2056
+ code: "docker-not-pinned",
2057
+ reason: "a Docker image must be pinned by digest (@sha256:...), because a tag can be moved"
2058
+ };
2059
+ const { owner, repository, ref } = reference;
2060
+ if (allowed(owner, repository, options.allow ?? [])) return;
2061
+ if (ref === void 0 || ref === "") return {
2062
+ code: "missing-ref",
2063
+ reason: "it names no ref"
2064
+ };
2065
+ return ((options.organisations ?? []).some((organisation) => organisation.toLowerCase() === owner.toLowerCase()) ? options.sameOrganisation ?? "ref" : options.thirdParty ?? "sha") === "sha" && !FULL_COMMIT_SHA.test(ref) ? {
2066
+ code: "not-pinned",
2067
+ reason: `ref '${ref}' is not a full commit SHA, and a tag or branch can be moved to different code`
2068
+ } : void 0;
2069
+ }
2070
+ /**
2071
+ * Every `uses` of a step or job follows the pinning policy. By default an action or reusable workflow from outside the organisations in the options must be pinned by full commit SHA (a Docker image by digest), and one owned by an organisation listed in `organisations` may use any ref; `thirdParty` and `sameOrganisation` set either to `sha` or `ref`, and `allow` exempts repositories. Local actions and workflows (`./...`) are not judged.
2072
+ *
2073
+ * The check encodes whichever policy the options state and is never silent about it: with nothing configured every remote reference must be a SHA. Limits: it checks the form of the ref, not that the SHA belongs to the repository or to a release; a SHA written with a trailing version comment is read as the SHA; composite actions are not read.
2074
+ */
2075
+ const workflowActionPinning = async ({ cwd, options }) => {
2076
+ const violations = [];
2077
+ for (const workflow of await loadWorkflows(cwd, options)) {
2078
+ const uses = [...workflow.jobs.flatMap((job) => job.uses === void 0 ? [] : [{
2079
+ value: job.uses,
2080
+ location: job.location
2081
+ }]), ...workflow.jobs.flatMap((job) => job.steps.flatMap((step) => step.uses === void 0 ? [] : [{
2082
+ value: step.uses,
2083
+ location: step.location
2084
+ }]))].sort((left, right) => left.location.line - right.location.line || left.location.column - right.location.column);
2085
+ for (const { value, location } of uses) {
2086
+ const found = problem(parseUses(value), options);
2087
+ if (found !== void 0) violations.push(workflowViolation(workflow, `workflow-action-pinning/${found.code}`, `${value}: ${found.reason}`, location));
2088
+ }
2089
+ }
2090
+ return violations;
2091
+ };
2092
+ //#endregion
2093
+ //#region src/checks/workflow-credentials.ts
2094
+ /**
2095
+ * The actions that sign an attestation, which needs an OIDC identity and the right to store the attestation.
2096
+ */
2097
+ const ATTESTATION_ACTIONS = [
2098
+ "actions/attest",
2099
+ "actions/attest-build-provenance",
2100
+ "actions/attest-sbom"
2101
+ ];
2102
+ /**
2103
+ * The scopes an attestation step needs written.
2104
+ */
2105
+ const ATTESTATION_SCOPES = ["id-token", "attestations"];
2106
+ /**
2107
+ * The options a package manager takes before its subcommand: flags such as `-r`, and the options that select a workspace package or directory, with their value after a space or an equals sign.
2108
+ */
2109
+ const PACKAGE_MANAGER_OPTIONS = String.raw`(?:\s+(?:(?:--filter|-F|--workspace|-w|--dir|-C)(?:\s+|=)\S+|--?[\w-]+))*`;
2110
+ /**
2111
+ * Commands that publish a package to a registry.
2112
+ */
2113
+ 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");
2114
+ /**
2115
+ * The variables a token for the registry is passed in.
2116
+ */
2117
+ const TOKEN_VARIABLES = ["NODE_AUTH_TOKEN", "NPM_TOKEN"];
2118
+ const PROVENANCE_FLAG = /--provenance(?:=true)?(?:\s|$)/u;
2119
+ /**
2120
+ * Whether the `package.json` in the working directory asks for provenance with `publishConfig.provenance: true`, which applies to every publish of that package.
2121
+ */
2122
+ async function requestsProvenance(cwd) {
2123
+ const file = (0, node_path.resolve)(cwd, "package.json");
2124
+ if (!(0, node_fs.existsSync)(file)) return false;
2125
+ const manifest = JSON.parse(await (0, node_fs_promises.readFile)(file, "utf8"));
2126
+ const config = isRecord(manifest) ? manifest["publishConfig"] : void 0;
2127
+ return isRecord(config) && config["provenance"] === true;
2128
+ }
2129
+ function attestationViolations(workflow, job) {
2130
+ const permissions = effectivePermissions(workflow, job);
2131
+ return job.steps.filter((step) => ATTESTATION_ACTIONS.some((action) => stepUses(step, action))).flatMap((step) => {
2132
+ const missing = ATTESTATION_SCOPES.filter((scope) => accessOf(permissions, scope) !== "write").map((scope) => `${scope}: write`).join(" and ");
2133
+ const problem = permissions.kind === "default" ? `declares no permissions; declare ${missing}` : `does not grant ${missing}`;
2134
+ return missing === "" ? [] : [workflowViolation(workflow, "workflow-credentials/attestation-permissions", `job '${job.id}' creates an attestation but ${problem}`, step.location)];
2135
+ });
2136
+ }
2137
+ /**
2138
+ * 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.
2139
+ */
2140
+ function passesToken(workflow, job) {
2141
+ return [void 0, ...job.steps].some((step) => TOKEN_VARIABLES.some((variable) => (effectiveEnv(workflow, job, step)[variable] ?? "") !== ""));
2142
+ }
2143
+ function tokenlessPublishViolations(workflow, job, provenanceInManifest) {
2144
+ if (accessOf(effectivePermissions(workflow, job), "id-token") !== "write" || passesToken(workflow, job)) return [];
2145
+ return job.steps.flatMap((step) => {
2146
+ const publishes = scriptCommands(step.run).filter((line) => PUBLISH_COMMAND.test(line));
2147
+ if (publishes.length === 0) return [];
2148
+ const env = effectiveEnv(workflow, job, step);
2149
+ const blanked = TOKEN_VARIABLES.every((variable) => env[variable] === "");
2150
+ const provenance = provenanceInManifest || publishes.some((line) => PROVENANCE_FLAG.test(line)) || env["NPM_CONFIG_PROVENANCE"] === "true";
2151
+ 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 ${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)];
2152
+ });
2153
+ }
2154
+ /**
2155
+ * Credentials are granted to the steps that need them, and a publish with no token cannot quietly use one.
2156
+ *
2157
+ * `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.
2158
+ *
2159
+ * `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.
2160
+ *
2161
+ * 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.
2162
+ */
2163
+ const workflowCredentials = async ({ cwd, options }) => {
2164
+ const provenanceInManifest = await requestsProvenance(cwd);
2165
+ return (await loadWorkflows(cwd, options)).flatMap((workflow) => workflow.jobs.flatMap((job) => [...attestationViolations(workflow, job), ...tokenlessPublishViolations(workflow, job, provenanceInManifest)]));
2166
+ };
2167
+ //#endregion
2168
+ //#region src/checks/workflow-job-ordering.ts
2169
+ const DEFAULT_RELEASE_JOBS = ["release"];
2170
+ const DEFAULT_DEPLOY_JOBS = ["deploy"];
2171
+ const DEFAULT_DOCS_DEPLOY_JOBS = ["docs-deploy"];
2172
+ const DEFAULT_BRANCH = "main";
2173
+ /**
2174
+ * The expression that always names the repository's default branch.
2175
+ */
2176
+ const DEFAULT_BRANCH_EXPRESSION$1 = /^\$\{\{\s*github\.event\.repository\.default_branch\s*\}\}$/u;
2177
+ /**
2178
+ * The text a junction job decides on: its `if`, and the conditions and scripts of its steps.
2179
+ */
2180
+ function decisionText(job) {
2181
+ return [job.condition, ...job.steps.flatMap((step) => [
2182
+ step.condition,
2183
+ step.run,
2184
+ ...Object.values(step.env),
2185
+ ...Object.values(step.with)
2186
+ ])].filter((text) => text !== void 0).join("\n");
2187
+ }
2188
+ /**
2189
+ * 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`.
2190
+ */
2191
+ const RESULT_SOURCE = /needs\.(?:\*|[\w-]+)\.result|toJSON\(\s*needs\s*\)/u;
2192
+ const FAILURE_WORD = /['"]failure['"]/u;
2193
+ const CANCELLED_WORD = /['"]cancelled['"]/u;
2194
+ const SKIPPED_OR_NOT_SUCCESS = /needs\.(?:\*|[\w-]+)\.result[^\n]*(?:['"]skipped['"]|!==?\s*['"]success['"])|!\s*contains\(\s*needs\.\*\.result\s*,\s*['"]success['"]/u;
2195
+ /**
2196
+ * An `if` that compares the ref or the event name.
2197
+ */
2198
+ const COMPARES_REF_OR_EVENT = /github\.(?:ref|ref_name|event_name)\s*==/u;
2199
+ /**
2200
+ * An expression written inside the `${{ }}` that marks it as one.
2201
+ */
2202
+ const EXPRESSION_WRAPPER = /^\$\{\{\s*([\s\S]*?)\s*\}\}$/u;
2203
+ /**
2204
+ * The operands of `operator` at the top level of an expression: not inside parentheses or quotes.
2205
+ */
2206
+ function operands(expression, operator) {
2207
+ const parts = [];
2208
+ let depth = 0;
2209
+ let quote;
2210
+ let start = 0;
2211
+ for (let index = 0; index < expression.length; index += 1) {
2212
+ const character = expression.charAt(index);
2213
+ if (quote !== void 0) quote = character === quote ? void 0 : quote;
2214
+ else if (character === "'" || character === "\"") quote = character;
2215
+ else if (character === "(") depth += 1;
2216
+ else if (character === ")") depth -= 1;
2217
+ else if (depth === 0 && expression.startsWith(operator, index)) {
2218
+ parts.push(expression.slice(start, index));
2219
+ index += operator.length - 1;
2220
+ start = index + 1;
2221
+ }
2222
+ }
2223
+ parts.push(expression.slice(start));
2224
+ return parts.map((part) => part.trim());
2225
+ }
2226
+ /**
2227
+ * Whether the first parenthesis of an expression is closed by its last character, so the parentheses enclose all of it, unlike `(a) && (b)`.
2228
+ */
2229
+ function enclosedByOneGroup(expression) {
2230
+ let depth = 0;
2231
+ for (let index = 0; index < expression.length; index += 1) {
2232
+ depth += expression.charAt(index) === "(" ? 1 : expression.charAt(index) === ")" ? -1 : 0;
2233
+ if (depth === 0) return index === expression.length - 1;
2234
+ }
2235
+ return false;
2236
+ }
2237
+ /**
2238
+ * The expression without the `${{ }}` that wraps it, or the parentheses that enclose all of it.
2239
+ */
2240
+ function unwrapped(expression) {
2241
+ const trimmed = expression.trim();
2242
+ const inner = EXPRESSION_WRAPPER.exec(trimmed)?.[1] ?? trimmed;
2243
+ return enclosedByOneGroup(inner) ? unwrapped(inner.slice(1, -1)) : inner;
2244
+ }
2245
+ /**
2246
+ * 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`.
2247
+ */
2248
+ function keepsOutOfPullRequests(expression) {
2249
+ if (expression === void 0) return false;
2250
+ const text = unwrapped(expression);
2251
+ const alternatives = operands(text, "||");
2252
+ if (alternatives.length > 1) return alternatives.every(keepsOutOfPullRequests);
2253
+ const conjuncts = operands(text, "&&");
2254
+ if (conjuncts.length > 1) return conjuncts.some(keepsOutOfPullRequests);
2255
+ return COMPARES_REF_OR_EVENT.test(text) && !text.includes("pull_request") && !text.includes("merge_group");
2256
+ }
2257
+ const OUTCOME_ACTION = "re-actors/alls-green";
2258
+ function releaseAndDocsOrdering(workflow, options) {
2259
+ const releases = jobsNamed(workflow, options.releaseJobs ?? DEFAULT_RELEASE_JOBS);
2260
+ const deploys = jobsNamed(workflow, options.deployJobs ?? DEFAULT_DEPLOY_JOBS);
2261
+ const docs = jobsNamed(workflow, options.docsDeployJobs ?? DEFAULT_DOCS_DEPLOY_JOBS);
2262
+ const violations = [];
2263
+ for (const release of releases) {
2264
+ const waitsFor = transitiveNeeds(workflow, release.id);
2265
+ for (const deploy of deploys.filter((candidate) => candidate.id !== release.id && !waitsFor.has(candidate.id))) violations.push(workflowViolation(workflow, "workflow-job-ordering/release-before-deploy", `release job '${release.id}' does not need deploy job '${deploy.id}', so a release can be published after a failed deploy`, release.location));
2266
+ }
2267
+ for (const doc of docs) {
2268
+ const waitsFor = transitiveNeeds(workflow, doc.id);
2269
+ for (const release of releases.filter((candidate) => candidate.id !== doc.id && !waitsFor.has(candidate.id))) violations.push(workflowViolation(workflow, "workflow-job-ordering/docs-deploy-before-release", `documentation job '${doc.id}' does not need release job '${release.id}', so it can publish documentation for a release that did not happen`, doc.location));
2270
+ const branch = options.defaultBranch ?? DEFAULT_BRANCH;
2271
+ for (const checkout of doc.steps.filter((step) => stepUses(step, "actions/checkout"))) {
2272
+ const ref = checkout.with["ref"]?.trim();
2273
+ if (!(ref !== void 0 && (DEFAULT_BRANCH_EXPRESSION$1.test(ref) || ref === branch || ref === `refs/heads/${branch}`))) violations.push(workflowViolation(workflow, "workflow-job-ordering/docs-deploy-stale-checkout", `documentation job '${doc.id}' checks out ${ref === void 0 ? "the triggering commit" : `'${ref}'`}, which predates the release commit; check out the default branch afresh with ref: \${{ github.event.repository.default_branch }}`, checkout.location));
2274
+ }
2275
+ }
2276
+ return violations;
2277
+ }
2278
+ /**
2279
+ * The jobs a pull request's required check need not wait for: those the options exempt, those whose `if` keeps them to events other than pull requests and merge groups, and those that need such a job and so are skipped with it.
2280
+ */
2281
+ function exemptFromJunction(workflow, junctionExempt) {
2282
+ const direct = /* @__PURE__ */ new Set([...junctionExempt, ...workflow.jobs.filter((job) => keepsOutOfPullRequests(job.condition)).map((job) => job.id)]);
2283
+ return new Set(workflow.jobs.filter((job) => direct.has(job.id) || [...transitiveNeeds(workflow, job.id)].some((needed) => direct.has(needed))).map((job) => job.id));
2284
+ }
2285
+ function junctionOrdering(workflow, options) {
2286
+ const violations = [];
2287
+ const exempt = exemptFromJunction(workflow, options.junctionExempt ?? []);
2288
+ for (const junction of jobsNamed(workflow, options.junctionJobs ?? DEFAULT_JUNCTION_JOBS)) {
2289
+ 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));
2290
+ 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));
2291
+ 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));
2292
+ const text = decisionText(junction);
2293
+ const reportsOutcome = junction.steps.some((step) => stepUses(step, OUTCOME_ACTION));
2294
+ 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));
2295
+ 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));
2296
+ }
2297
+ return violations;
2298
+ }
2299
+ /**
2300
+ * 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`.
2301
+ *
2302
+ * 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.
2303
+ */
2304
+ const workflowJobOrdering = async ({ cwd, options }) => (await loadWorkflows(cwd, options)).flatMap((workflow) => [...releaseAndDocsOrdering(workflow, options), ...junctionOrdering(workflow, options)]);
2305
+ //#endregion
2306
+ //#region src/checks/workflow-merge-group.ts
2307
+ /**
2308
+ * 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.
2309
+ *
2310
+ * 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.
2311
+ */
2312
+ const workflowMergeGroup = async ({ cwd, options }) => (await loadWorkflows(cwd, options)).flatMap((workflow) => {
2313
+ const location = workflow.triggerLocations.get("pull_request");
2314
+ 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)];
2315
+ });
2316
+ //#endregion
2317
+ //#region src/checks/workflow-repository-dispatch.ts
2318
+ const DEFAULT_BRANCHES = ["main", "master"];
2319
+ const DEFAULT_BRANCH_EXPRESSION = /\$\{\{\s*github\.event\.repository\.default_branch\s*\}\}/u;
2320
+ const DEFAULT_TOKEN = /\b(?:secrets\.GITHUB_TOKEN|github\.token)\b/u;
2321
+ /**
2322
+ * The start of a `git push` command: `git`, the global options that take a value (`-C <directory>`, `-c <name=value>`) or are written `--name=value`, then `push`.
2323
+ */
2324
+ const PUSH = /^git(?:\s+(?:-[Cc]\s+\S+|--[\w-]+=\S+))*\s+push\b/u;
2325
+ const BRANCH_CREATION = /^git\s+(?:checkout\s+-[bB]|switch\s+-[cC])\b/u;
2326
+ const AUTO_MERGE = /^gh\s+pr\s+merge\b.*\s--auto\b/u;
2327
+ const PULL_REQUEST_CREATION = /^gh\s+pr\s+create\b|^gh\s+api\b.*\brepos\/[^\s]+\/pulls\b/u;
2328
+ const DISPATCH_TARGETS = [/^(?:gh\s+api|curl)\b.*\brepos\/([^\s/]+\/[^\s/]+)\/dispatches\b/u, /^gh\s+workflow\s+run\b.*\s(?:-R|--repo)[\s=]+(\S+)/u];
2329
+ const AUTO_COMMIT_ACTION = "stefanzweifel/git-auto-commit-action";
2330
+ const PULL_REQUEST_ACTION = "peter-evans/create-pull-request";
2331
+ const DISPATCH_ACTION = "peter-evans/repository-dispatch";
2332
+ const CHECKOUT_ACTION = "actions/checkout";
2333
+ function isDefaultBranch(branch, branches) {
2334
+ const name = branch.replace(/^refs\/heads\//u, "");
2335
+ return branches.includes(name) || DEFAULT_BRANCH_EXPRESSION.test(name);
2336
+ }
2337
+ /**
2338
+ * The `git push` options that take their value as the next word, which would otherwise be read as the remote.
2339
+ */
2340
+ const PUSH_OPTIONS_WITH_VALUE = /* @__PURE__ */ new Set([
2341
+ "-o",
2342
+ "--push-option",
2343
+ "--repo",
2344
+ "--receive-pack",
2345
+ "--exec"
2346
+ ]);
2347
+ /**
2348
+ * A URL with credentials in it (an expression such as `${{ secrets.TOKEN }}` may be part of them), which authenticates a push itself instead of using what the checkout left in git config.
2349
+ */
2350
+ const URL_WITH_CREDENTIALS = /[a-z][a-z0-9+.-]*:\/\/(?:[^\s/@{]|\$\{\{[^}]*\}\})*@/iu;
2351
+ /**
2352
+ * The words of a `git push` command line after `push`, without the quotes that wrap a word.
2353
+ */
2354
+ function pushWords(line) {
2355
+ return line.replace(PUSH, "").split(/\s+/u).map((word) => word.replace(/^(["'])(.*)\1$/u, "$2")).filter((word) => word !== "");
2356
+ }
2357
+ /**
2358
+ * The refspecs of a `git push` command line, after the remote, the options and the values of the options that take one.
2359
+ */
2360
+ function pushRefspecs(line) {
2361
+ const positional = [];
2362
+ const words = pushWords(line)[Symbol.iterator]();
2363
+ for (const word of words) if (PUSH_OPTIONS_WITH_VALUE.has(word)) words.next();
2364
+ else if (!word.startsWith("-")) positional.push(word);
2365
+ return positional.slice(1);
2366
+ }
2367
+ /**
2368
+ * Whether a `git push` command line pushes to the default branch. With a refspec it is the destination (a leading `+` only forces the update, and a bare `HEAD` is the checked-out branch); without one it is the branch the handler has checked out, which is the default branch unless the job created another or checked one out by `ref`.
2369
+ */
2370
+ function pushesToDefault(line, onOtherBranch, branches) {
2371
+ const refspecs = pushRefspecs(line);
2372
+ if (refspecs.length === 0) return !onOtherBranch;
2373
+ return refspecs.some((refspec) => {
2374
+ const destination = refspec.replace(/^\+/u, "");
2375
+ const named = destination.includes(":") ? destination.slice(destination.lastIndexOf(":") + 1) : destination;
2376
+ return named === "HEAD" ? !onOtherBranch : isDefaultBranch(named, branches);
2377
+ });
2378
+ }
2379
+ /**
2380
+ * Whether a `git push` command line authenticates with what the checkout left in git config, which it does unless the command line names a URL carrying its own credentials.
2381
+ */
2382
+ function pushesWithGitConfig(line) {
2383
+ return !URL_WITH_CREDENTIALS.test(line);
2384
+ }
2385
+ function usesDefaultToken(workflow, job, step) {
2386
+ return DEFAULT_TOKEN.test([
2387
+ step.run,
2388
+ ...Object.values(effectiveEnv(workflow, job, step)),
2389
+ ...Object.values(step.with)
2390
+ ].filter((text) => text !== void 0).join("\n"));
2391
+ }
2392
+ /**
2393
+ * Whether the job's checkout leaves the default token in the git config, so a `git push` authenticates as it.
2394
+ */
2395
+ function checkoutKeepsDefaultToken(job) {
2396
+ return job.steps.some((step) => stepUses(step, CHECKOUT_ACTION) && (step.with["token"] === void 0 || DEFAULT_TOKEN.test(step.with["token"])) && step.with["persist-credentials"] !== "false");
2397
+ }
2398
+ function checkoutRefOtherThanDefault(job, branches) {
2399
+ return job.steps.some((step) => stepUses(step, CHECKOUT_ACTION) && step.with["ref"] !== void 0 && !isDefaultBranch(step.with["ref"], branches));
2400
+ }
2401
+ function stepViolations(context, step, onOtherBranch) {
2402
+ const { workflow, job, branches } = context;
2403
+ const lines = scriptCommands(step.run);
2404
+ const found = [];
2405
+ const report = (reason, message) => {
2406
+ found.push(workflowViolation(workflow, `workflow-repository-dispatch/${reason}`, message, step.location));
2407
+ };
2408
+ const pushes = lines.filter((line) => PUSH.test(line));
2409
+ const autoCommits = stepUses(step, AUTO_COMMIT_ACTION);
2410
+ const autoCommitBranch = step.with["branch"];
2411
+ if (autoCommits && !onOtherBranch && (autoCommitBranch === void 0 || isDefaultBranch(autoCommitBranch, branches)) || pushes.some((line) => pushesToDefault(line, onOtherBranch, branches))) report("pushes-default-branch", `job '${job.id}' pushes to the default branch from a repository_dispatch handler, which anyone who can dispatch can trigger, bypassing review; push a branch and open a pull request`);
2412
+ if (!context.assumeRequiredChecks && lines.some((line) => AUTO_MERGE.test(line))) report("auto-merge-without-required-checks", `job '${job.id}' merges with --auto, which merges at once when the base branch has no required checks; require status checks on the default branch (or set assumeRequiredChecks once it does)`);
2413
+ const pushesAsDefaultToken = (pushes.some(pushesWithGitConfig) || autoCommits) && checkoutKeepsDefaultToken(job);
2414
+ const createsPullRequestAsDefaultToken = lines.some((line) => PULL_REQUEST_CREATION.test(line)) && usesDefaultToken(workflow, job, step) || stepUses(step, PULL_REQUEST_ACTION) && (step.with["token"] === void 0 || DEFAULT_TOKEN.test(step.with["token"]));
2415
+ if (pushesAsDefaultToken || createsPullRequestAsDefaultToken) report("default-token-triggers-nothing", `job '${job.id}' pushes or opens a pull request as GITHUB_TOKEN, and events GitHub Actions creates with that token start no workflows, so nothing validates the result; use a token from a GitHub App or a personal access token`);
2416
+ const targets = [...lines.flatMap((line) => DISPATCH_TARGETS.map((pattern) => pattern.exec(line)?.[1])), ...stepUses(step, DISPATCH_ACTION) ? [step.with["repository"]] : []].filter((target) => target !== void 0);
2417
+ for (const target of targets.filter((candidate) => !candidate.includes("github.repository"))) if (usesDefaultToken(workflow, job, step)) report("dispatch-other-repository-with-default-token", `job '${job.id}' dispatches to ${target} with GITHUB_TOKEN, which is limited to this repository; use a token that has access to ${target}`);
2418
+ return found;
2419
+ }
2420
+ function jobViolations(context) {
2421
+ let onOtherBranch = checkoutRefOtherThanDefault(context.job, context.branches);
2422
+ const found = [];
2423
+ for (const step of context.job.steps) {
2424
+ found.push(...stepViolations(context, step, onOtherBranch));
2425
+ onOtherBranch ||= scriptCommands(step.run).some((line) => BRANCH_CREATION.test(line));
2426
+ }
2427
+ return found;
2428
+ }
2429
+ /**
2430
+ * A workflow that handles `repository_dispatch` can be started by anyone with write access through the API, so what it does with the repository is held to a higher bar.
2431
+ *
2432
+ * `workflow-repository-dispatch/pushes-default-branch`: a `git push` to the default branch, or one with no refspec while the default branch is checked out, or `git-auto-commit-action` on it. `auto-merge-without-required-checks`: `gh pr merge --auto`, which merges at once when the base branch requires no checks (the file cannot show the rule; `assumeRequiredChecks` states it, and `settings-required-checks` verifies it). `default-token-triggers-nothing`: a push (the checkout leaves `GITHUB_TOKEN` in git config unless it is given a `token`, and a push to a URL carrying its own credentials does not use it) or a pull request opened as `GITHUB_TOKEN`; events created with it start no workflows, apart from `workflow_dispatch` and `repository_dispatch`, so the pushed commit gets no checks. `dispatch-other-repository-with-default-token`: a dispatch to a repository other than `github.repository` with `GITHUB_TOKEN`, whose access is limited to its own repository. Dispatching in the same repository with `GITHUB_TOKEN` is allowed by GitHub and is not reported.
2433
+ *
2434
+ * Limits: commands are read as text, so a script file or composite action is not seen; a push is judged by its command line, so a branch created by a helper is not seen, and a quoted word that contains a space is not read as one word; a dispatch target written literally as this repository's own name is reported, since the file does not know the name; a job that calls a reusable workflow is not read.
2435
+ */
2436
+ const workflowRepositoryDispatch = async ({ cwd, options }) => (await loadWorkflows(cwd, options)).filter((workflow) => workflow.triggers.has("repository_dispatch")).flatMap((workflow) => workflow.jobs.flatMap((job) => jobViolations({
2437
+ workflow,
2438
+ job,
2439
+ branches: options.defaultBranches ?? DEFAULT_BRANCHES,
2440
+ assumeRequiredChecks: options.assumeRequiredChecks === true
2441
+ })));
2442
+ //#endregion
2443
+ //#region src/checks/workflow-runner-resolution.ts
2444
+ /**
2445
+ * GitHub's own images: `ubuntu`, `windows` and `macos`, as `-latest`, a version, or a version with an architecture or size suffix, and `ubuntu-slim`.
2446
+ */
2447
+ const DEFAULT_HOSTED_LABELS = ["^(?:ubuntu|windows|macos)-(?:latest|slim|[0-9][0-9.]*)(?:-(?:arm|intel|xlarge|large))*$"];
2448
+ /**
2449
+ * `fromJson(needs.<job>.outputs.<name>)` in a `runs-on`.
2450
+ */
2451
+ const RESOLVED_RUNNER = /fromJSON\(\s*needs\.([\w-]+)\.outputs\.([\w-]+)\s*\)/giu;
2452
+ /**
2453
+ * `${{ jobs.<job>.outputs.<name> }}` as the value of a reusable workflow's output.
2454
+ */
2455
+ const JOB_OUTPUT_REFERENCE = /^\$\{\{\s*jobs\.([\w-]+)\.outputs\.([\w-]+)\s*\}\}$/u;
2456
+ /**
2457
+ * A fallback in an output expression: `||` followed by a string literal, whose body may hold `''` for a quote.
2458
+ */
2459
+ const LITERAL_FALLBACK = /\|\|\s*'((?:[^']|'')*)'/gu;
2460
+ /**
2461
+ * The resolver behind each `fromJson(needs.<job>.outputs.<name>)` in a `runs-on` of the workflow; none for one this check cannot read.
2462
+ */
2463
+ function resolversOf(workflow, workflows) {
2464
+ return workflow.jobs.flatMap((job) => job.runsOn.expressions.flatMap((expression) => [...expression.matchAll(RESOLVED_RUNNER)].flatMap((match) => {
2465
+ const caller = workflow.jobs.find((candidate) => candidate.id === match[1]);
2466
+ const output = match[2];
2467
+ const resolver = caller === void 0 || output === void 0 ? void 0 : implementation(workflow, workflows, caller, output);
2468
+ return resolver === void 0 ? [] : [resolver];
2469
+ })));
2470
+ }
2471
+ /**
2472
+ * The job that produces `output` for `caller`: the job itself, or for a call of a local reusable workflow the job in that workflow the output is forwarded from. `undefined` when the reusable workflow is not a file of this repository, which this check cannot read.
2473
+ */
2474
+ function implementation(workflow, workflows, caller, output) {
2475
+ if (caller.uses === void 0) return {
2476
+ workflow,
2477
+ job: caller,
2478
+ output
2479
+ };
2480
+ const reference = parseUses(caller.uses);
2481
+ if (reference.kind !== "local") return;
2482
+ const called = workflows.find((candidate) => `./${candidate.file}` === reference.path);
2483
+ if (called === void 0) throw new ConformanceError(`${workflow.file}: job '${caller.id}' calls ${caller.uses}, which is not a workflow file`);
2484
+ const forwarded = JOB_OUTPUT_REFERENCE.exec(called.callOutputs[output] ?? "");
2485
+ const job = called.jobs.find((candidate) => candidate.id === forwarded?.[1]);
2486
+ const jobOutput = forwarded?.[2];
2487
+ return job === void 0 || jobOutput === void 0 ? void 0 : {
2488
+ workflow: called,
2489
+ job,
2490
+ output: jobOutput
2491
+ };
2492
+ }
2493
+ /**
2494
+ * 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.
2495
+ */
2496
+ function fallbackLabels(expression) {
2497
+ const literal = [...expression.matchAll(LITERAL_FALLBACK)].at(-1)?.[1]?.replaceAll("''", "'");
2498
+ if (literal === void 0) return;
2499
+ let parsed;
2500
+ try {
2501
+ parsed = JSON.parse(literal);
2502
+ } catch {
2503
+ return [];
2504
+ }
2505
+ const items = Array.isArray(parsed) ? parsed : [parsed];
2506
+ const labels = items.filter((item) => typeof item === "string");
2507
+ return labels.length === items.length ? labels : [];
2508
+ }
2509
+ function resolverViolations(resolver, hosted) {
2510
+ const violations = [];
2511
+ 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));
2512
+ const fallback = fallbackLabels(resolver.job.outputs[resolver.output] ?? "");
2513
+ 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));
2514
+ 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));
2515
+ return violations;
2516
+ }
2517
+ function customLabels(job, hosted) {
2518
+ return /* @__PURE__ */ new Set([...job.runsOn.labels.filter((label) => !hosted.some((pattern) => pattern.test(label))), ...job.runsOn.group === void 0 ? [] : [`group:${job.runsOn.group}`]]);
2519
+ }
2520
+ function repeatedLabels(workflow, hosted) {
2521
+ const labelsByJob = workflow.jobs.map((job) => ({
2522
+ job,
2523
+ labels: customLabels(job, hosted)
2524
+ }));
2525
+ const users = (label) => labelsByJob.filter((entry) => entry.labels.has(label)).map((entry) => entry.job.id);
2526
+ return labelsByJob.flatMap(({ job, labels }) => {
2527
+ const repeated = [...labels].filter((label) => users(label).length > 1);
2528
+ return repeated.length === 0 ? [] : [workflowViolation(workflow, "workflow-runner-resolution/repeated-label", `job '${job.id}' names the runner label${repeated.length === 1 ? "" : "s"} ${repeated.map((label) => `'${label}'`).join(", ")} literally, like ${[...new Set(repeated.flatMap(users))].filter((id) => id !== job.id).map((id) => `'${id}'`).join(", ")}; resolve it once in a resolver job and read it with fromJson`, job.location)];
2529
+ });
2530
+ }
2531
+ /**
2532
+ * 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.
2533
+ *
2534
+ * 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.
2535
+ */
2536
+ const workflowRunnerResolution = async ({ cwd, options }) => {
2537
+ const hosted = (options.hostedLabels ?? DEFAULT_HOSTED_LABELS).map((source) => new RegExp(source, "u"));
2538
+ const selected = await loadWorkflows(cwd, options);
2539
+ const available = await loadWorkflows(cwd, { workflows: DEFAULT_WORKFLOWS });
2540
+ const resolvers = /* @__PURE__ */ new Map();
2541
+ for (const workflow of selected) for (const resolver of resolversOf(workflow, available)) resolvers.set(`${resolver.workflow.file}#${resolver.job.id}#${resolver.output}`, resolver);
2542
+ return [...selected.flatMap((workflow) => repeatedLabels(workflow, hosted)), ...[...resolvers.values()].flatMap((resolver) => resolverViolations(resolver, hosted))];
2543
+ };
2544
+ //#endregion
2545
+ //#region src/checks/workflow-skippable-jobs.ts
2546
+ const DEFAULT_PATH_FILTER_ACTIONS = [
2547
+ "dorny/paths-filter",
2548
+ "tj-actions/changed-files",
2549
+ "step-security/changed-files"
2550
+ ];
2551
+ /**
2552
+ * The events whose `paths` and `paths-ignore` decide whether the whole workflow runs for a pull request.
2553
+ */
2554
+ const PULL_REQUEST_EVENTS = ["pull_request", "pull_request_target"];
2555
+ /**
2556
+ * The jobs a job `if` reads outputs from: `needs.<job>.outputs.<name>`.
2557
+ */
2558
+ const NEEDS_OUTPUT = /\bneeds\.([\w-]+)\.outputs\./gu;
2559
+ function hasPathFilter(workflow, event) {
2560
+ const configuration = workflow.triggers.get(event);
2561
+ return isRecord(configuration) && ("paths" in configuration || "paths-ignore" in configuration);
2562
+ }
2563
+ /**
2564
+ * The jobs a job's `if` reads path filter outputs from.
2565
+ */
2566
+ function filterJobsRead(workflow, job, actions) {
2567
+ const read = [...(job.condition ?? "").matchAll(NEEDS_OUTPUT)].map((match) => match[1]);
2568
+ return workflow.jobs.filter((candidate) => read.includes(candidate.id) && candidate.steps.some((step) => actions.some((action) => stepUses(step, action)))).map((candidate) => candidate.id);
2569
+ }
2570
+ /**
2571
+ * Keeping work off a pull request must not leave a required check without a result. A `paths` or `paths-ignore` filter on the trigger of a workflow with a junction job skips the whole workflow, so the required check stays pending forever; a job whose own `if` reads a path filter's outputs is skipped, which is only safe when a junction job with `if: always()` needs it and reports for it. Filter the steps of the job instead and the job still runs and reports.
2572
+ *
2573
+ * Limits: a workflow is taken to produce a required check when it has a junction job, so a trigger path filter in a workflow without one is not reported; a path filter is recognised by the actions in the options, so a script that computes changed paths itself is not seen, and a job gated on the output of such a script is not reported; only the filters of the `pull_request` and `pull_request_target` triggers are read, so a `paths` filter on `push` is not reported; only `needs.<job>.outputs` in the job's own `if` is read. Whether the junction job really reports is judged by `workflow-job-ordering`.
2574
+ */
2575
+ const workflowSkippableJobs = async ({ cwd, options }) => {
2576
+ const violations = [];
2577
+ for (const workflow of await loadWorkflows(cwd, options)) {
2578
+ const junctions = jobsNamed(workflow, options.junctionJobs ?? DEFAULT_JUNCTION_JOBS);
2579
+ if (junctions.length > 0) {
2580
+ for (const [event, location] of workflow.triggerLocations) if (PULL_REQUEST_EVENTS.includes(event) && hasPathFilter(workflow, event)) violations.push(workflowViolation(workflow, "workflow-skippable-jobs/trigger-path-filter", `${event} has a paths filter, so the workflow does not run, and its required check stays pending, when no matching file changed; filter the steps of the jobs instead`, location));
2581
+ }
2582
+ for (const job of workflow.jobs) {
2583
+ const filters = filterJobsRead(workflow, job, options.pathFilterActions ?? DEFAULT_PATH_FILTER_ACTIONS);
2584
+ const reported = junctions.some((junction) => junction.needs.includes(job.id));
2585
+ if (filters.length > 0 && !reported) violations.push(workflowViolation(workflow, "workflow-skippable-jobs/job-gated-by-path-filter", `job '${job.id}' is skipped by an if on the path filter in '${filters.join("', '")}' and no junction job reports for it; put the condition on its steps, or add the job to the needs of a junction job with if: always()`, job.location));
2586
+ }
2587
+ }
2588
+ return violations;
2589
+ };
2590
+ //#endregion
2591
+ //#region src/checks/workflow-update-bot-cooldown.ts
2592
+ const DEPENDABOT_FILES = [".github/dependabot.yml", ".github/dependabot.yaml"];
2593
+ const RENOVATE_FILES = [
2594
+ "renovate.json",
2595
+ ".renovaterc.json",
2596
+ ".renovaterc",
2597
+ ".github/renovate.json"
2598
+ ];
2599
+ /**
2600
+ * The cooldown settings that delay a release by a number of days.
2601
+ */
2602
+ const COOLDOWN_DAYS = [
2603
+ "default-days",
2604
+ "semver-major-days",
2605
+ "semver-minor-days",
2606
+ "semver-patch-days"
2607
+ ];
2608
+ const RENOVATE_SETTING = "minimumReleaseAge";
2609
+ function firstExisting(cwd, explicit, defaults) {
2610
+ if (explicit !== void 0) {
2611
+ if (!(0, node_fs.existsSync)((0, node_path.resolve)(cwd, explicit))) throw new ConformanceError(`${explicit} does not exist`);
2612
+ return explicit;
2613
+ }
2614
+ return defaults.find((file) => (0, node_fs.existsSync)((0, node_path.resolve)(cwd, file)));
2615
+ }
2616
+ function hasCooldown(update) {
2617
+ if (!isRecord(update) || !isRecord(update["cooldown"])) return false;
2618
+ const cooldown = update["cooldown"];
2619
+ return COOLDOWN_DAYS.some((setting) => typeof cooldown[setting] === "number" && cooldown[setting] > 0);
2620
+ }
2621
+ async function dependabotViolations(cwd, file) {
2622
+ const parsed = parseYaml(file, await (0, node_fs_promises.readFile)((0, node_path.resolve)(cwd, file), "utf8"));
2623
+ return (isRecord(parsed.value) && Array.isArray(parsed.value["updates"]) ? parsed.value["updates"] : []).flatMap((update, index) => hasCooldown(update) ? [] : [{
2624
+ code: "workflow-update-bot-cooldown/dependabot-no-cooldown",
2625
+ message: `the update entry for ${isRecord(update) && typeof update["package-ecosystem"] === "string" ? update["package-ecosystem"] : `entry ${String(index)}`} has no cooldown with a number of days above zero, so Dependabot proposes a release the day it is published`,
2626
+ file,
2627
+ location: parsed.locate(["updates", index])
2628
+ }]);
2629
+ }
2630
+ /**
2631
+ * Whether a `minimumReleaseAge` value waits for a positive time: a duration string such as `3 days`, whose number is above zero. `0 days` and `null` (which clears the setting) wait for nothing.
2632
+ */
2633
+ function delaysRelease(age) {
2634
+ const amount = typeof age === "string" ? /^\s*(\d+(?:\.\d+)?)\s*[a-z]/iu.exec(age)?.[1] : void 0;
2635
+ return amount !== void 0 && Number(amount) > 0;
2636
+ }
2637
+ function setsMinimumReleaseAge(value) {
2638
+ if (!isRecord(value)) return false;
2639
+ const rules = Array.isArray(value["packageRules"]) ? value["packageRules"] : [];
2640
+ const presets = Array.isArray(value["extends"]) ? value["extends"] : [];
2641
+ return delaysRelease(value[RENOVATE_SETTING]) || rules.some((rule) => isRecord(rule) && delaysRelease(rule[RENOVATE_SETTING])) || presets.some((preset) => typeof preset === "string" && preset.includes(RENOVATE_SETTING));
2642
+ }
2643
+ async function renovateViolations(cwd, file) {
2644
+ const errors = [];
2645
+ const value = (0, jsonc_parser.parse)(await (0, node_fs_promises.readFile)((0, node_path.resolve)(cwd, file), "utf8"), errors, { allowTrailingComma: true });
2646
+ const [error] = errors;
2647
+ if (error !== void 0) throw new ConformanceError(`${file}: not valid JSON: ${(0, jsonc_parser.printParseErrorCode)(error.error)} at offset ${String(error.offset)}`);
2648
+ return setsMinimumReleaseAge(value) ? [] : [{
2649
+ code: "workflow-update-bot-cooldown/renovate-no-minimum-release-age",
2650
+ message: `${file} sets no ${RENOVATE_SETTING} above zero, so Renovate proposes a release the day it is published`,
2651
+ file
2652
+ }];
2653
+ }
2654
+ /**
2655
+ * The update bots wait before proposing a release, so a compromised release is withdrawn before it is installed. Dependabot: every `updates` entry needs a `cooldown` with `default-days` or one of the `semver-*-days` above zero. Renovate: `minimumReleaseAge` must be set to a duration above zero at the top level or in a `packageRules` entry.
2656
+ *
2657
+ * Limits: the bots' own rules differ, since Dependabot's cooldown does not apply to security updates, and a Renovate preset is not resolved (an `extends` entry counts only when its name contains `minimumReleaseAge`), so a setting that comes from a preset under another name is reported. Renovate configs in JSON5 syntax beyond comments and trailing commas, and a `renovate` key in `package.json`, are not read. A repository with neither bot has nothing to report.
2658
+ */
2659
+ const workflowUpdateBotCooldown = async ({ cwd, options }) => {
2660
+ const dependabot = firstExisting(cwd, options.dependabot, DEPENDABOT_FILES);
2661
+ const renovate = firstExisting(cwd, options.renovate, RENOVATE_FILES);
2662
+ return [...dependabot === void 0 ? [] : await dependabotViolations(cwd, dependabot), ...renovate === void 0 ? [] : await renovateViolations(cwd, renovate)];
2663
+ };
2664
+ //#endregion
2665
+ //#region src/checks/workflow-version-single-source.ts
2666
+ const TOOL_VERSIONS = ".tool-versions";
2667
+ const NVMRC = ".nvmrc";
2668
+ const SETUP_ACTIONS = [
2669
+ {
2670
+ action: "actions/setup-node",
2671
+ versionInput: "node-version",
2672
+ fileInput: "node-version-file",
2673
+ tools: ["nodejs", "node"]
2674
+ },
2675
+ {
2676
+ action: "actions/setup-python",
2677
+ versionInput: "python-version",
2678
+ fileInput: "python-version-file",
2679
+ tools: ["python"]
2680
+ },
2681
+ {
2682
+ action: "actions/setup-go",
2683
+ versionInput: "go-version",
2684
+ fileInput: "go-version-file",
2685
+ tools: ["golang", "go"]
2686
+ },
2687
+ {
2688
+ action: "actions/setup-java",
2689
+ versionInput: "java-version",
2690
+ fileInput: "java-version-file",
2691
+ tools: ["java"]
2692
+ },
2693
+ {
2694
+ action: "actions/setup-dotnet",
2695
+ versionInput: "dotnet-version",
2696
+ fileInput: "global-json-file",
2697
+ tools: ["dotnet", "dotnet-core"]
2698
+ },
2699
+ {
2700
+ action: "ruby/setup-ruby",
2701
+ versionInput: "ruby-version",
2702
+ fileInput: void 0,
2703
+ tools: ["ruby"]
2704
+ },
2705
+ {
2706
+ action: "oven-sh/setup-bun",
2707
+ versionInput: "bun-version",
2708
+ fileInput: "bun-version-file",
2709
+ tools: ["bun"]
2710
+ },
2711
+ {
2712
+ action: "denoland/setup-deno",
2713
+ versionInput: "deno-version",
2714
+ fileInput: "deno-version-file",
2715
+ tools: ["deno"]
2716
+ },
2717
+ {
2718
+ action: "pnpm/action-setup",
2719
+ versionInput: "version",
2720
+ fileInput: void 0,
2721
+ tools: ["pnpm"]
2722
+ },
2723
+ {
2724
+ action: "hashicorp/setup-terraform",
2725
+ versionInput: "terraform_version",
2726
+ fileInput: void 0,
2727
+ tools: ["terraform"]
2728
+ }
2729
+ ];
2730
+ /**
2731
+ * The tool names a `.tool-versions` file lists. Each line is a tool name followed by versions; a comment starts with `#`.
2732
+ */
2733
+ function listedTools(content) {
2734
+ return new Set(content.split("\n").map((line) => line.replace(/#.*$/u, "").trim().split(/\s+/u)).filter((fields) => fields.length >= 2).map((fields) => String(fields[0])));
2735
+ }
2736
+ /**
2737
+ * A setup step keeps a runtime version in the workflow when a version file already says it: `.nvmrc` for Node, `.tool-versions` for any tool it lists. The step reads the file instead (`node-version-file`, or `mise` or `asdf` actions), so there is one place to change.
2738
+ *
2739
+ * A version is a literal when it contains no expression, so `${{ matrix.node }}` is not reported: a matrix tests versions on purpose. A range or alias such as `lts/*` is a literal. Only the setup actions listed in the README are recognised, only the files in the working directory are looked for, and composite actions are not read.
2740
+ */
2741
+ const workflowVersionSingleSource = async ({ cwd, options }) => {
2742
+ const toolVersions = (0, node_fs.existsSync)((0, node_path.resolve)(cwd, TOOL_VERSIONS)) ? listedTools(await (0, node_fs_promises.readFile)((0, node_path.resolve)(cwd, TOOL_VERSIONS), "utf8")) : /* @__PURE__ */ new Set();
2743
+ const hasNvmrc = (0, node_fs.existsSync)((0, node_path.resolve)(cwd, NVMRC));
2744
+ const violations = [];
2745
+ for (const workflow of await loadWorkflows(cwd, options)) for (const job of workflow.jobs) for (const step of job.steps) for (const setup of SETUP_ACTIONS.filter((candidate) => stepUses(step, candidate.action))) {
2746
+ const version = step.with[setup.versionInput];
2747
+ const source = setup.action === "actions/setup-node" && hasNvmrc ? NVMRC : setup.tools.find((tool) => toolVersions.has(tool)) === void 0 ? void 0 : TOOL_VERSIONS;
2748
+ if (version !== void 0 && !hasExpression(version) && source !== void 0) violations.push(workflowViolation(workflow, "workflow-version-single-source/literal-version", `${setup.action} sets ${setup.versionInput}: ${version}, but ${source} already holds the version; read it from there${setup.fileInput === void 0 ? "" : ` with ${setup.fileInput}`}`, step.location));
2749
+ }
2750
+ return violations;
2751
+ };
2752
+ //#endregion
1528
2753
  //#region src/config.ts
1529
2754
  const path = zod.z.string().min(1);
1530
2755
  const paths = zod.z.array(path).min(1);
@@ -1542,6 +2767,15 @@ function compiles(source) {
1542
2767
  */
1543
2768
  const pattern = zod.z.string().min(1).refine(compiles, "not a valid regular expression");
1544
2769
  const patterns = zod.z.array(pattern);
2770
+ const selection = {
2771
+ workflows: zod.z.exactOptional(paths),
2772
+ exclude: zod.z.exactOptional(paths)
2773
+ };
2774
+ const pinningLevel = zod.z.enum(["sha", "ref"]);
2775
+ const settings = {
2776
+ repository: zod.z.exactOptional(zod.z.string().regex(/^[^/\s]+\/[^/\s]+$/u, "expected owner/name")),
2777
+ branch: zod.z.exactOptional(zod.z.string().min(1))
2778
+ };
1545
2779
  const importGraph = zod.z.strictObject({
1546
2780
  exclude: zod.z.exactOptional(patterns),
1547
2781
  doNotFollow: zod.z.exactOptional(patterns),
@@ -1600,7 +2834,58 @@ const checks = zod.z.strictObject({
1600
2834
  database: zod.z.exactOptional(zod.z.string().min(1)),
1601
2835
  environment: zod.z.exactOptional(zod.z.string().min(1)),
1602
2836
  packageJsons: zod.z.exactOptional(paths)
1603
- })))
2837
+ }))),
2838
+ "workflow-job-ordering": zod.z.exactOptional(enabled(zod.z.strictObject({
2839
+ ...selection,
2840
+ releaseJobs: zod.z.exactOptional(names),
2841
+ deployJobs: zod.z.exactOptional(names),
2842
+ docsDeployJobs: zod.z.exactOptional(names),
2843
+ junctionJobs: zod.z.exactOptional(names),
2844
+ junctionExempt: zod.z.exactOptional(names),
2845
+ defaultBranch: zod.z.exactOptional(zod.z.string().min(1))
2846
+ }))),
2847
+ "workflow-skippable-jobs": zod.z.exactOptional(enabled(zod.z.strictObject({
2848
+ ...selection,
2849
+ junctionJobs: zod.z.exactOptional(names),
2850
+ pathFilterActions: zod.z.exactOptional(names)
2851
+ }))),
2852
+ "workflow-runner-resolution": zod.z.exactOptional(enabled(zod.z.strictObject({
2853
+ ...selection,
2854
+ hostedLabels: zod.z.exactOptional(patterns)
2855
+ }))),
2856
+ "workflow-version-single-source": zod.z.exactOptional(enabled(zod.z.strictObject(selection))),
2857
+ "workflow-credentials": zod.z.exactOptional(enabled(zod.z.strictObject(selection))),
2858
+ "workflow-repository-dispatch": zod.z.exactOptional(enabled(zod.z.strictObject({
2859
+ ...selection,
2860
+ defaultBranches: zod.z.exactOptional(names),
2861
+ assumeRequiredChecks: zod.z.exactOptional(zod.z.boolean())
2862
+ }))),
2863
+ "workflow-update-bot-cooldown": zod.z.exactOptional(enabled(zod.z.strictObject({
2864
+ dependabot: zod.z.exactOptional(path),
2865
+ renovate: zod.z.exactOptional(path)
2866
+ }))),
2867
+ "workflow-merge-group": zod.z.exactOptional(enabled(zod.z.strictObject(selection))),
2868
+ "workflow-action-pinning": zod.z.exactOptional(enabled(zod.z.strictObject({
2869
+ ...selection,
2870
+ thirdParty: zod.z.exactOptional(pinningLevel),
2871
+ sameOrganisation: zod.z.exactOptional(pinningLevel),
2872
+ organisations: zod.z.exactOptional(names),
2873
+ allow: zod.z.exactOptional(names)
2874
+ }))),
2875
+ "settings-merge-methods": zod.z.exactOptional(enabled(zod.z.strictObject({
2876
+ ...settings,
2877
+ allowed: zod.z.exactOptional(zod.z.enum([
2878
+ "rebase",
2879
+ "squash",
2880
+ "merge"
2881
+ ]))
2882
+ }))),
2883
+ "settings-required-checks": zod.z.exactOptional(enabled(zod.z.strictObject({
2884
+ ...settings,
2885
+ ...selection,
2886
+ junctionJobs: zod.z.exactOptional(names)
2887
+ }))),
2888
+ "settings-review-thread-resolution": zod.z.exactOptional(enabled(zod.z.strictObject(settings)))
1604
2889
  });
1605
2890
  /**
1606
2891
  * The Standard Schema for {@link ConformanceConfig}. It rejects unknown keys at every level, so a misspelt check name or option fails when the section loads.
@@ -1611,6 +2896,76 @@ const conformanceSchema = zod.z.strictObject({ checks });
1611
2896
  */
1612
2897
  const conformanceSection = (0, _exadev_config.defineSection)("conformance", conformanceSchema);
1613
2898
  //#endregion
2899
+ //#region src/github.ts
2900
+ const repositorySchema = zod.z.object({
2901
+ default_branch: zod.z.string(),
2902
+ allow_rebase_merge: zod.z.optional(zod.z.boolean()),
2903
+ allow_squash_merge: zod.z.optional(zod.z.boolean()),
2904
+ allow_merge_commit: zod.z.optional(zod.z.boolean())
2905
+ });
2906
+ const rulesSchema = zod.z.array(zod.z.looseObject({
2907
+ type: zod.z.string(),
2908
+ parameters: zod.z.optional(zod.z.looseObject({
2909
+ required_status_checks: zod.z.optional(zod.z.array(zod.z.object({ context: zod.z.string() }))),
2910
+ strict_required_status_checks_policy: zod.z.optional(zod.z.boolean()),
2911
+ required_review_thread_resolution: zod.z.optional(zod.z.boolean()),
2912
+ allowed_merge_methods: zod.z.optional(zod.z.array(zod.z.string()))
2913
+ }))
2914
+ }));
2915
+ const API_ROOT = "https://api.github.com";
2916
+ const API_VERSION = "2022-11-28";
2917
+ /**
2918
+ * How many rules the API returns in one page, its maximum.
2919
+ */
2920
+ const PAGE_SIZE = 100;
2921
+ /**
2922
+ * A {@link GitHubClient} over the REST API.
2923
+ */
2924
+ function createGitHubClient(options) {
2925
+ const request = options.fetch ?? fetch;
2926
+ const root = options.apiUrl ?? API_ROOT;
2927
+ async function get(path) {
2928
+ const response = await request(`${root}${path}`, { headers: {
2929
+ Accept: "application/vnd.github+json",
2930
+ Authorization: `Bearer ${options.token}`,
2931
+ "X-GitHub-Api-Version": API_VERSION
2932
+ } });
2933
+ if (!response.ok) throw new ConformanceError(`GET ${path} failed: ${String(response.status)} ${response.statusText}`);
2934
+ return response.json();
2935
+ }
2936
+ return {
2937
+ repository: async (slug) => {
2938
+ const parsed = repositorySchema.parse(await get(`/repos/${slug.owner}/${slug.name}`));
2939
+ const { allow_rebase_merge: allowRebaseMerge, allow_squash_merge: allowSquashMerge, allow_merge_commit: allowMergeCommit } = parsed;
2940
+ if (allowRebaseMerge === void 0 || allowSquashMerge === void 0 || allowMergeCommit === void 0) throw new ConformanceError(`GET /repos/${slug.owner}/${slug.name} did not report the merge-method settings, which GitHub shows only to a token with write access to the repository`);
2941
+ return {
2942
+ defaultBranch: parsed.default_branch,
2943
+ allowRebaseMerge,
2944
+ allowSquashMerge,
2945
+ allowMergeCommit
2946
+ };
2947
+ },
2948
+ branchRules: async (slug, branch) => {
2949
+ const rules = [];
2950
+ for (let page = 1, more = true; more; page += 1) {
2951
+ const found = rulesSchema.parse(await get(`/repos/${slug.owner}/${slug.name}/rules/branches/${encodeURIComponent(branch)}?per_page=${String(PAGE_SIZE)}&page=${String(page)}`));
2952
+ rules.push(...found);
2953
+ more = found.length === PAGE_SIZE;
2954
+ }
2955
+ return {
2956
+ requiredStatusChecks: rules.filter((rule) => rule.type === "required_status_checks").map((rule) => ({
2957
+ contexts: (rule.parameters?.required_status_checks ?? []).map((check) => check.context),
2958
+ strict: rule.parameters?.strict_required_status_checks_policy === true
2959
+ })),
2960
+ pullRequests: rules.filter((rule) => rule.type === "pull_request").map((rule) => ({
2961
+ requiredReviewThreadResolution: rule.parameters?.required_review_thread_resolution === true,
2962
+ allowedMergeMethods: rule.parameters?.allowed_merge_methods
2963
+ }))
2964
+ };
2965
+ }
2966
+ };
2967
+ }
2968
+ //#endregion
1614
2969
  //#region src/registry.ts
1615
2970
  /**
1616
2971
  * The options of an enabled check, or `undefined` when the setting is absent or `false`.
@@ -1623,6 +2978,7 @@ function plain(spec) {
1623
2978
  name: spec.name,
1624
2979
  description: spec.description,
1625
2980
  requiresLayout: false,
2981
+ requiresGitHub: false,
1626
2982
  isEnabled: (checks) => spec.select(checks) !== void 0,
1627
2983
  run: async ({ cwd, checks, configFiles }) => {
1628
2984
  const options = spec.select(checks);
@@ -1635,11 +2991,32 @@ function plain(spec) {
1635
2991
  }
1636
2992
  };
1637
2993
  }
2994
+ function online(spec) {
2995
+ return {
2996
+ name: spec.name,
2997
+ description: spec.description,
2998
+ requiresLayout: false,
2999
+ requiresGitHub: true,
3000
+ isEnabled: (checks) => spec.select(checks) !== void 0,
3001
+ run: async ({ cwd, checks, configFiles, github }) => {
3002
+ const options = spec.select(checks);
3003
+ if (options === void 0) throw new ConformanceError(`the check '${spec.name}' is not enabled`);
3004
+ if (github === void 0) throw new ConformanceError(`the check '${spec.name}' reads the repository's settings and needs a GitHub client`);
3005
+ return spec.check({
3006
+ cwd,
3007
+ options,
3008
+ github,
3009
+ ...configFiles === void 0 ? {} : { configFiles }
3010
+ });
3011
+ }
3012
+ };
3013
+ }
1638
3014
  function importCheck(spec) {
1639
3015
  return {
1640
3016
  name: spec.name,
1641
3017
  description: spec.description,
1642
3018
  requiresLayout: true,
3019
+ requiresGitHub: false,
1643
3020
  isEnabled: (checks) => spec.select(checks) !== void 0,
1644
3021
  importGraph: {
1645
3022
  spec: spec.importSpec,
@@ -1722,6 +3099,78 @@ const registry = {
1722
3099
  description: "The directory a schema generator writes migrations to is the one the deploy tool applies them from, and no script applies them with the generator",
1723
3100
  select: (checks) => enabledOptions(checks["migrations-directory"]),
1724
3101
  check: migrationsDirectory
3102
+ }),
3103
+ "workflow-job-ordering": plain({
3104
+ name: "workflow-job-ordering",
3105
+ description: "Release, deploy and junction jobs are ordered so a failure stops what follows",
3106
+ select: (checks) => enabledOptions(checks["workflow-job-ordering"]),
3107
+ check: workflowJobOrdering
3108
+ }),
3109
+ "workflow-skippable-jobs": plain({
3110
+ name: "workflow-skippable-jobs",
3111
+ description: "A path filter does not leave a required check pending or skip a job no junction job reports for",
3112
+ select: (checks) => enabledOptions(checks["workflow-skippable-jobs"]),
3113
+ check: workflowSkippableJobs
3114
+ }),
3115
+ "workflow-runner-resolution": plain({
3116
+ name: "workflow-runner-resolution",
3117
+ description: "A self-hosted or custom runner label is resolved once by a resolver job, not repeated in several jobs",
3118
+ select: (checks) => enabledOptions(checks["workflow-runner-resolution"]),
3119
+ check: workflowRunnerResolution
3120
+ }),
3121
+ "workflow-version-single-source": plain({
3122
+ name: "workflow-version-single-source",
3123
+ description: "Setup steps read a runtime version from .nvmrc or .tool-versions instead of holding a literal",
3124
+ select: (checks) => enabledOptions(checks["workflow-version-single-source"]),
3125
+ check: workflowVersionSingleSource
3126
+ }),
3127
+ "workflow-credentials": plain({
3128
+ name: "workflow-credentials",
3129
+ description: "Attestation steps have the permissions they need, and a tokenless publish cannot quietly use a token",
3130
+ select: (checks) => enabledOptions(checks["workflow-credentials"]),
3131
+ check: workflowCredentials
3132
+ }),
3133
+ "workflow-repository-dispatch": plain({
3134
+ name: "workflow-repository-dispatch",
3135
+ description: "A repository_dispatch handler does not push to the default branch, auto-merge unguarded, or rely on the default token",
3136
+ select: (checks) => enabledOptions(checks["workflow-repository-dispatch"]),
3137
+ check: workflowRepositoryDispatch
3138
+ }),
3139
+ "workflow-update-bot-cooldown": plain({
3140
+ name: "workflow-update-bot-cooldown",
3141
+ description: "Dependabot and Renovate wait before proposing a new release",
3142
+ select: (checks) => enabledOptions(checks["workflow-update-bot-cooldown"]),
3143
+ check: workflowUpdateBotCooldown
3144
+ }),
3145
+ "workflow-merge-group": plain({
3146
+ name: "workflow-merge-group",
3147
+ description: "A workflow that runs for pull requests also runs for the merge queue",
3148
+ select: (checks) => enabledOptions(checks["workflow-merge-group"]),
3149
+ check: workflowMergeGroup
3150
+ }),
3151
+ "workflow-action-pinning": plain({
3152
+ name: "workflow-action-pinning",
3153
+ description: "Actions and reusable workflows are pinned as the configured policy requires",
3154
+ select: (checks) => enabledOptions(checks["workflow-action-pinning"]),
3155
+ check: workflowActionPinning
3156
+ }),
3157
+ "settings-merge-methods": online({
3158
+ name: "settings-merge-methods",
3159
+ description: "The repository allows only one merge method",
3160
+ select: (checks) => enabledOptions(checks["settings-merge-methods"]),
3161
+ check: settingsMergeMethods
3162
+ }),
3163
+ "settings-required-checks": online({
3164
+ name: "settings-required-checks",
3165
+ description: "The default branch requires the junction job check, up to date with the base",
3166
+ select: (checks) => enabledOptions(checks["settings-required-checks"]),
3167
+ check: settingsRequiredChecks
3168
+ }),
3169
+ "settings-review-thread-resolution": online({
3170
+ name: "settings-review-thread-resolution",
3171
+ description: "A pull request cannot merge while a review conversation is unresolved",
3172
+ select: (checks) => enabledOptions(checks["settings-review-thread-resolution"]),
3173
+ check: settingsReviewThreadResolution
1725
3174
  })
1726
3175
  };
1727
3176
  /**
@@ -1763,14 +3212,18 @@ async function layoutOf(options, needed) {
1763
3212
  if (loaded === void 0) throw new ConformanceError(`${needed.join(", ")} read the workspace layout, and there is no 'layout' section in ${options.cwd}; define it in exadev.config.ts or in exadev.layout.config.ts`);
1764
3213
  return loaded;
1765
3214
  }
1766
- function selected(config, requested) {
3215
+ function selected(config, requested, online) {
1767
3216
  const enabled = checkNames.filter((name) => registry[name].isEnabled(config.checks));
1768
3217
  if (requested === void 0) {
1769
3218
  if (enabled.length === 0) throw new ConformanceError("no check is enabled; add one to the 'checks' of the 'conformance' section");
1770
- return enabled;
3219
+ const runnable = enabled.filter((name) => online || !registry[name].requiresGitHub);
3220
+ if (runnable.length === 0) throw new ConformanceError(`only checks that read the repository's settings are enabled (${enabled.join(", ")}); they run only with a GitHub client (--settings on the command line)`);
3221
+ return runnable;
1771
3222
  }
1772
3223
  const disabled = requested.filter((name) => !enabled.includes(name));
1773
3224
  if (disabled.length > 0) throw new ConformanceError(`${disabled.join(", ")} ${disabled.length === 1 ? "is" : "are"} not enabled in the 'conformance' section`);
3225
+ const offline = requested.filter((name) => !online && registry[name].requiresGitHub);
3226
+ if (offline.length > 0) throw new ConformanceError(`${offline.join(", ")} read${offline.length === 1 ? "s" : ""} the repository's settings and need${offline.length === 1 ? "s" : ""} a GitHub client (--settings on the command line)`);
1774
3227
  return checkNames.filter((name) => requested.includes(name));
1775
3228
  }
1776
3229
  /**
@@ -1809,7 +3262,7 @@ function readImported(imported, name) {
1809
3262
  */
1810
3263
  async function runChecks(options) {
1811
3264
  const config = await conformanceOf(options);
1812
- const names = selected(config, options.checks);
3265
+ const names = selected(config, options.checks, options.github !== void 0);
1813
3266
  const layout = await layoutOf(options, names.filter((name) => registry[name].requiresLayout));
1814
3267
  const imported = await runImportGraphChecks(options.cwd, layout, names, config.checks);
1815
3268
  const results = [];
@@ -1819,7 +3272,8 @@ async function runChecks(options) {
1819
3272
  cwd: options.cwd,
1820
3273
  checks: config.checks,
1821
3274
  layout,
1822
- configFiles: options.configFiles
3275
+ configFiles: options.configFiles,
3276
+ github: options.github
1823
3277
  }) : readImported(imported, name);
1824
3278
  results.push({
1825
3279
  check: name,
@@ -1929,9 +3383,11 @@ function proveExhaustive(input) {
1929
3383
  //#endregion
1930
3384
  exports.ConformanceError = ConformanceError;
1931
3385
  exports.DEFAULT_INFERENCES = DEFAULT_INFERENCES;
3386
+ exports.DEFAULT_WORKFLOWS = DEFAULT_WORKFLOWS;
1932
3387
  exports.EXIT_CODES = EXIT_CODES;
1933
3388
  exports.ExhaustivenessError = ExhaustivenessError;
1934
3389
  exports.PRESET_COMMIT_TYPES = PRESET_COMMIT_TYPES;
3390
+ exports.accessOf = accessOf;
1935
3391
  exports.aggregateMappers = aggregateMappers;
1936
3392
  exports.checkNames = checkNames;
1937
3393
  exports.classifyPackages = classifyPackages;
@@ -1939,10 +3395,13 @@ exports.commandTypes = commandTypes;
1939
3395
  exports.commitTypes = commitTypes;
1940
3396
  exports.conformanceSchema = conformanceSchema;
1941
3397
  exports.conformanceSection = conformanceSection;
3398
+ exports.createGitHubClient = createGitHubClient;
1942
3399
  exports.crossSliceRules = crossSliceRules;
1943
3400
  exports.cycleRule = cycleRule;
1944
3401
  exports.deployTools = deployTools;
1945
3402
  exports.dockerfilePackageManager = dockerfilePackageManager;
3403
+ exports.effectiveEnv = effectiveEnv;
3404
+ exports.effectivePermissions = effectivePermissions;
1946
3405
  exports.generators = generators;
1947
3406
  exports.handlerCases = handlerCases;
1948
3407
  exports.importCrossSlice = importCrossSlice;
@@ -1953,13 +3412,29 @@ exports.importUphill = importUphill;
1953
3412
  exports.instructionSymlinks = instructionSymlinks;
1954
3413
  exports.isCheckName = isCheckName;
1955
3414
  exports.isolatedGroupRules = isolatedGroupRules;
3415
+ exports.loadWorkflows = loadWorkflows;
1956
3416
  exports.migrationsDirectory = migrationsDirectory;
1957
3417
  exports.packagesPattern = packagesPattern;
3418
+ exports.parseUses = parseUses;
3419
+ exports.parseWorkflow = parseWorkflow;
1958
3420
  exports.proveExhaustive = proveExhaustive;
1959
3421
  exports.rankSkipRules = rankSkipRules;
1960
3422
  exports.registry = registry;
1961
3423
  exports.removeCase = removeCase;
1962
3424
  exports.runChecks = runChecks;
3425
+ exports.settingsMergeMethods = settingsMergeMethods;
3426
+ exports.settingsRequiredChecks = settingsRequiredChecks;
3427
+ exports.settingsReviewThreadResolution = settingsReviewThreadResolution;
1963
3428
  exports.singleStorybook = singleStorybook;
3429
+ exports.transitiveNeeds = transitiveNeeds;
1964
3430
  exports.typeErrors = typeErrors;
1965
3431
  exports.uphillRules = uphillRules;
3432
+ exports.workflowActionPinning = workflowActionPinning;
3433
+ exports.workflowCredentials = workflowCredentials;
3434
+ exports.workflowJobOrdering = workflowJobOrdering;
3435
+ exports.workflowMergeGroup = workflowMergeGroup;
3436
+ exports.workflowRepositoryDispatch = workflowRepositoryDispatch;
3437
+ exports.workflowRunnerResolution = workflowRunnerResolution;
3438
+ exports.workflowSkippableJobs = workflowSkippableJobs;
3439
+ exports.workflowUpdateBotCooldown = workflowUpdateBotCooldown;
3440
+ exports.workflowVersionSingleSource = workflowVersionSingleSource;