workspace-conformance 1.4.0 → 1.6.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
@@ -100,6 +100,23 @@ function expandTemplate(template, values) {
100
100
  return value;
101
101
  });
102
102
  }
103
+ const NAME_PLACEHOLDER = "{name}";
104
+ /**
105
+ * What a name template must be, as a configuration error says it. It does not quote the template, as no configuration error quotes a value.
106
+ */
107
+ const NAME_TEMPLATE_REQUIREMENT = "needs {name} exactly once, other text beside it and no other placeholder";
108
+ const ANY_PLACEHOLDER = /\{[a-z]+\}/u;
109
+ /**
110
+ * `template` split at `{name}`, or `undefined` unless it holds `{name}` exactly once, other text beside it and no other placeholder. Without other text every name would match, and with `{name}` twice a name could be split more than one way.
111
+ */
112
+ function parseNameTemplate(template) {
113
+ const [before, after, ...rest] = template.split(NAME_PLACEHOLDER);
114
+ if (before === void 0 || after === void 0 || rest.length > 0 || before + after === "" || ANY_PLACEHOLDER.test(before + after)) return;
115
+ return {
116
+ before,
117
+ after
118
+ };
119
+ }
103
120
  //#endregion
104
121
  //#region src/ts-project.ts
105
122
  /**
@@ -213,6 +230,97 @@ const aggregateMappers = async ({ cwd, options }) => {
213
230
  return violations.sort((a, b) => a.file.localeCompare(b.file) || (a.location?.line ?? 0) - (b.location?.line ?? 0) || a.message.localeCompare(b.message));
214
231
  };
215
232
  //#endregion
233
+ //#region src/checks/codec-pairs.ts
234
+ function collect(container, prefix, found, entered) {
235
+ for (const [name, declarations] of container.getExportedDeclarations()) {
236
+ const value = declarations.find((declaration) => !ts_morph.Node.isInterfaceDeclaration(declaration) && !ts_morph.Node.isTypeAliasDeclaration(declaration) && !ts_morph.Node.isModuleDeclaration(declaration) && !ts_morph.Node.isSourceFile(declaration));
237
+ if (value !== void 0) found.set(`${prefix}${name}`, value);
238
+ for (const member of declarations) if (ts_morph.Node.isModuleDeclaration(member)) collect(member, `${prefix}${name}.`, found, entered);
239
+ else if (ts_morph.Node.isSourceFile(member) && !entered.has(member)) collect(member, `${prefix}${name}.`, found, /* @__PURE__ */ new Set([...entered, member]));
240
+ }
241
+ }
242
+ /**
243
+ * The values `file` exports, by name, wherever they are declared: a re-export counts. Those inside an exported namespace, or in a module re-exported as a namespace (`export * as ns from './x'`), are included under their qualified name (`Namespace.value`). Interfaces and type aliases are types, not codecs, and a namespace is where codecs live rather than one itself, so all three are left out.
244
+ */
245
+ function exportedValues(file) {
246
+ const found = /* @__PURE__ */ new Map();
247
+ collect(file, "", found, /* @__PURE__ */ new Set([file]));
248
+ return found;
249
+ }
250
+ /**
251
+ * Where the declaration's name is written, or where the declaration starts when it has no name node.
252
+ */
253
+ function valueLocation(declaration) {
254
+ const nameNode = ts_morph.Node.hasName(declaration) ? declaration.getNameNode() : void 0;
255
+ const { line, column } = declaration.getSourceFile().getLineAndColumnAtPos((nameNode ?? declaration).getStart());
256
+ return {
257
+ line,
258
+ column
259
+ };
260
+ }
261
+ /**
262
+ * The template of an option, which the configuration has already validated; a template that does not parse is a configuration error naming the option.
263
+ */
264
+ function templateOf(options, option) {
265
+ const template = parseNameTemplate(options[option]);
266
+ if (template === void 0) throw new ConformanceError(`checks.codec-pairs.${option}: ${NAME_TEMPLATE_REQUIREMENT}`);
267
+ return template;
268
+ }
269
+ /**
270
+ * The shared name a qualified export name gives under `template`, or `undefined` when its last segment does not fit the template. The namespace part is kept apart so a counterpart is looked for in the same namespace.
271
+ */
272
+ function sharedName(qualified, template) {
273
+ const namespace = qualified.slice(0, qualified.lastIndexOf(".") + 1);
274
+ const local = qualified.slice(namespace.length);
275
+ if (local.length <= template.before.length + template.after.length || !local.startsWith(template.before) || !local.endsWith(template.after)) return;
276
+ return {
277
+ namespace,
278
+ name: local.slice(template.before.length, local.length - template.after.length)
279
+ };
280
+ }
281
+ /**
282
+ * Every encoder a codec file exports has the decoder its name implies, exported by the same file, and every decoder the encoder. A name is an encoder or a decoder when it fits that template; other exports are not codecs and are not judged. A half re-exported from another file counts, and is reported where it is declared.
283
+ *
284
+ * It checks that the counterpart is exported, not that the two round-trip: that is a property test of the repository's own.
285
+ */
286
+ const codecPairs = async ({ cwd, options }) => {
287
+ const { project, files } = await createProject(cwd, options.tsConfig, options.codecs, {
288
+ check: "codec-pairs",
289
+ files: "codecs"
290
+ });
291
+ const excluded = new Set(options.exclude);
292
+ const encoder = templateOf(options, "encoder");
293
+ const decoder = templateOf(options, "decoder");
294
+ const halves = [{
295
+ template: encoder,
296
+ counterpart: decoder,
297
+ missing: "decoder"
298
+ }, {
299
+ template: decoder,
300
+ counterpart: encoder,
301
+ missing: "encoder"
302
+ }];
303
+ const violations = [];
304
+ for (const file of files) {
305
+ const values = exportedValues(project.getSourceFileOrThrow((0, node_path.resolve)(cwd, file)));
306
+ for (const [qualified, declaration] of values) {
307
+ if (excluded.has(qualified)) continue;
308
+ for (const { template, counterpart, missing } of halves) {
309
+ const shared = sharedName(qualified, template);
310
+ if (shared === void 0) continue;
311
+ const expected = `${shared.namespace}${counterpart.before}${shared.name}${counterpart.after}`;
312
+ if (!values.has(expected)) violations.push({
313
+ code: `codec-pairs/missing-${missing}`,
314
+ message: `${qualified} has no ${missing}: ${file} does not export ${expected}`,
315
+ file: relativePosix(cwd, declaration.getSourceFile().getFilePath()),
316
+ location: valueLocation(declaration)
317
+ });
318
+ }
319
+ }
320
+ }
321
+ return violations.sort((a, b) => a.file.localeCompare(b.file) || (a.location?.line ?? 0) - (b.location?.line ?? 0) || a.message.localeCompare(b.message));
322
+ };
323
+ //#endregion
216
324
  //#region src/checks/type-references.ts
217
325
  /**
218
326
  * The identifier a reference ends in: `infer` in `z.infer`, the name itself when it is not qualified.
@@ -2020,6 +2128,9 @@ function runsOnOf(reader, value, what) {
2020
2128
  group
2021
2129
  };
2022
2130
  }
2131
+ function environmentOf(reader, value, what) {
2132
+ return isRecord(value) ? reader.text(value["name"], `${what}.name`) : reader.text(value, what);
2133
+ }
2023
2134
  function stepOf(reader, value, jobPath, index) {
2024
2135
  const path = [
2025
2136
  ...jobPath,
@@ -2052,6 +2163,7 @@ function jobOf(reader, id, value) {
2052
2163
  needs: reader.list(job["needs"], `${what}.needs`),
2053
2164
  condition: reader.text(job["if"], `${what}.if`),
2054
2165
  permissions: permissionsOf(reader, job["permissions"], `${what}.permissions`),
2166
+ environment: environmentOf(reader, job["environment"], `${what}.environment`),
2055
2167
  env: reader.texts(job["env"], `${what}.env`),
2056
2168
  runsOn: runsOnOf(reader, job["runs-on"], `${what}.runs-on`),
2057
2169
  timeoutMinutes: reader.text(job["timeout-minutes"], `${what}.timeout-minutes`),
@@ -2087,6 +2199,7 @@ function parseWorkflow(file, source) {
2087
2199
  triggers,
2088
2200
  triggerLocations: triggerLocationsOf(reader, triggers, root["on"]),
2089
2201
  permissions: permissionsOf(reader, root["permissions"], "permissions"),
2202
+ permissionsLocation: root["permissions"] === void 0 ? void 0 : reader.location(["permissions"]),
2090
2203
  env: reader.texts(root["env"], "env"),
2091
2204
  jobs: Object.entries(reader.record(root["jobs"], "jobs")).map(([id, job]) => jobOf(reader, id, job)),
2092
2205
  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`) ?? ""]))
@@ -2265,6 +2378,14 @@ function callsAlways(condition) {
2265
2378
  * The default ids of junction jobs, shared by the checks that look for one.
2266
2379
  */
2267
2380
  const DEFAULT_JUNCTION_JOBS = ["required-checks"];
2381
+ /**
2382
+ * The default ids of release jobs, shared by the checks that look for one.
2383
+ */
2384
+ const DEFAULT_RELEASE_JOBS = ["release"];
2385
+ /**
2386
+ * The variables a token for the npm registry is passed in.
2387
+ */
2388
+ const REGISTRY_TOKEN_VARIABLES = ["NODE_AUTH_TOKEN", "NPM_TOKEN"];
2268
2389
  //#endregion
2269
2390
  //#region src/checks/settings-required-checks.ts
2270
2391
  /**
@@ -2415,10 +2536,6 @@ const PACKAGE_MANAGER_OPTIONS = String.raw`(?:\s+(?:(?:--filter|-F|--workspace|-
2415
2536
  * Commands that publish a package to a registry.
2416
2537
  */
2417
2538
  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");
2418
- /**
2419
- * The variables a token for the registry is passed in.
2420
- */
2421
- const TOKEN_VARIABLES = ["NODE_AUTH_TOKEN", "NPM_TOKEN"];
2422
2539
  const PROVENANCE_FLAG = /--provenance(?:=true)?(?:\s|$)/u;
2423
2540
  /**
2424
2541
  * Whether the `package.json` in the working directory asks for provenance with `publishConfig.provenance: true`, which applies to every publish of that package.
@@ -2442,7 +2559,7 @@ function attestationViolations(workflow, job) {
2442
2559
  * 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.
2443
2560
  */
2444
2561
  function passesToken(workflow, job) {
2445
- return [void 0, ...job.steps].some((step) => TOKEN_VARIABLES.some((variable) => (effectiveEnv(workflow, job, step)[variable] ?? "") !== ""));
2562
+ return [void 0, ...job.steps].some((step) => REGISTRY_TOKEN_VARIABLES.some((variable) => (effectiveEnv(workflow, job, step)[variable] ?? "") !== ""));
2446
2563
  }
2447
2564
  function tokenlessPublishViolations(workflow, job, provenanceInManifest) {
2448
2565
  if (accessOf(effectivePermissions(workflow, job), "id-token") !== "write" || passesToken(workflow, job)) return [];
@@ -2450,9 +2567,9 @@ function tokenlessPublishViolations(workflow, job, provenanceInManifest) {
2450
2567
  const publishes = scriptCommands(step.run).filter((line) => PUBLISH_COMMAND.test(line));
2451
2568
  if (publishes.length === 0) return [];
2452
2569
  const env = effectiveEnv(workflow, job, step);
2453
- const blanked = TOKEN_VARIABLES.every((variable) => env[variable] === "");
2570
+ const blanked = REGISTRY_TOKEN_VARIABLES.every((variable) => env[variable] === "");
2454
2571
  const provenance = provenanceInManifest || publishes.some((line) => PROVENANCE_FLAG.test(line)) || env["NPM_CONFIG_PROVENANCE"] === "true";
2455
- 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)];
2572
+ return blanked || provenance ? [] : [workflowViolation(workflow, "workflow-credentials/tokenless-publish-unprotected", `job '${job.id}' publishes with an OIDC identity and no token, but does not set ${REGISTRY_TOKEN_VARIABLES.join(" and ")} to the empty string, so a token inherited from the environment is used when the OIDC exchange fails; set them to the empty string to prevent that, or request provenance so that such a publish at least carries a traceable attestation`, step.location)];
2456
2573
  });
2457
2574
  }
2458
2575
  /**
@@ -2470,7 +2587,6 @@ const workflowCredentials = async ({ cwd, options }) => {
2470
2587
  };
2471
2588
  //#endregion
2472
2589
  //#region src/checks/workflow-job-ordering.ts
2473
- const DEFAULT_RELEASE_JOBS = ["release"];
2474
2590
  const DEFAULT_DEPLOY_JOBS = ["deploy"];
2475
2591
  const DEFAULT_DOCS_DEPLOY_JOBS = ["docs-deploy"];
2476
2592
  const DEFAULT_BRANCH = "main";
@@ -2618,6 +2734,71 @@ const workflowMergeGroup = async ({ cwd, options }) => (await loadWorkflows(cwd,
2618
2734
  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)];
2619
2735
  });
2620
2736
  //#endregion
2737
+ //#region src/checks/workflow-release-job.ts
2738
+ const CHECKOUT_ACTION$1 = "actions/checkout";
2739
+ /**
2740
+ * A value that reads the `secrets` context inside an expression.
2741
+ */
2742
+ const FROM_SECRET = /\$\{\{[^}]*\bsecrets\./u;
2743
+ /**
2744
+ * The release jobs named for a message: `release job 'a'` or `release jobs 'a' and 'b'`.
2745
+ */
2746
+ function releaseJobsText(releases) {
2747
+ const ids = releases.map((job) => `'${job.id}'`);
2748
+ return `release job${ids.length === 1 ? "" : "s"} ${ids.join(" and ")}`;
2749
+ }
2750
+ function grantsIdToken(permissions) {
2751
+ return accessOf(permissions, "id-token") === "write";
2752
+ }
2753
+ function idTokenViolations(workflow, releases) {
2754
+ const releaseIds = new Set(releases.map((job) => job.id));
2755
+ const named = releaseJobsText(releases);
2756
+ const workflowLevel = grantsIdToken(workflow.permissions) && workflow.permissionsLocation !== void 0 ? [workflowViolation(workflow, "workflow-release-job/id-token-workflow-level", `the workflow's permissions grant id-token: write to every job that declares none of its own; grant it on ${named} only`, workflow.permissionsLocation)] : [];
2757
+ const jobLevel = workflow.jobs.filter((job) => !releaseIds.has(job.id) && grantsIdToken(job.permissions)).map((job) => workflowViolation(workflow, "workflow-release-job/id-token-outside-release", `job '${job.id}' is granted id-token: write but is not a release job, so it can mint the OIDC identity npm trusted publishing accepts for this workflow; only ${named} may hold it`, job.location));
2758
+ return [...workflowLevel, ...jobLevel];
2759
+ }
2760
+ /**
2761
+ * What a checkout that does not set `persist-credentials: false` leaves in git config.
2762
+ */
2763
+ function persistedCredential(step) {
2764
+ if (step.with["ssh-key"] !== void 0) return "the SSH key";
2765
+ return step.with["token"] === void 0 ? "the job token" : "the token it was given";
2766
+ }
2767
+ function releaseJobViolations(workflow, job) {
2768
+ if (job.uses !== void 0) return [];
2769
+ const environment = job.environment === void 0 ? [workflowViolation(workflow, "workflow-release-job/missing-environment", `release job '${job.id}' declares no environment, so nothing outside the workflow file stops a branch's copy of it publishing; run it in a protected environment restricted to the default branch`, job.location)] : [];
2770
+ const checkouts = job.steps.filter((step) => stepUses(step, CHECKOUT_ACTION$1) && step.with["persist-credentials"] !== "false").map((step) => workflowViolation(workflow, "workflow-release-job/persisted-credentials", `release job '${job.id}' checks out without persist-credentials: false, which leaves ${persistedCredential(step)} in git config for every later step`, step.location));
2771
+ return [...environment, ...checkouts];
2772
+ }
2773
+ /**
2774
+ * The token variables an environment sets from a secret.
2775
+ */
2776
+ function secretTokens(env) {
2777
+ return REGISTRY_TOKEN_VARIABLES.filter((variable) => FROM_SECRET.test(env[variable] ?? ""));
2778
+ }
2779
+ function tokenBesideOidcViolations(workflow, job) {
2780
+ if (!grantsIdToken(effectivePermissions(workflow, job))) return [];
2781
+ const report = (variables, location) => variables.length === 0 ? [] : [workflowViolation(workflow, "workflow-release-job/token-beside-oidc", `job '${job.id}' holds id-token: write and passes ${variables.join(" and ")} from a secret, a long-lived npm token beside the OIDC identity; publish through trusted publishing and blank the token variables`, location)];
2782
+ return [...report(secretTokens(effectiveEnv(workflow, job, void 0)), job.location), ...job.steps.flatMap((step) => report(secretTokens(step.env), step.location))];
2783
+ }
2784
+ /**
2785
+ * npm binds a trusted publisher to the repository and the workflow file, not to a branch, so anyone who can push a branch can edit that branch's copy of the workflow, drop an `if` that keeps publishing to the default branch, and publish. What stops that has to live outside the file: a protected environment that only the default branch may deploy to, named in npm's trusted publisher too. This check holds the release job to the shape that makes that protection hold.
2786
+ *
2787
+ * In a workflow with a release job (by id, `releaseJobs`, `release` by default): `workflow-release-job/id-token-workflow-level`, the workflow's own `permissions` grant `id-token: write` (directly or through `write-all`), which every job that declares none inherits; `id-token-outside-release`, a job that is not a release job declares `id-token: write` itself, a job that calls a reusable workflow included; `missing-environment`, a release job declares no `environment`; `persisted-credentials`, a release job runs `actions/checkout` without `persist-credentials: false` (literally; an expression is unknown and reported), so the job token, a `token` input or an `ssh-key` stays in git config for every later step. A release job that pushes as a deploy key provisions the key in a step and hands it to git through that step's `GIT_SSH_COMMAND`, after a checkout that persists nothing, and is accepted as written; semantic-release authenticates its own push (`repositoryUrl` as written, else the URL with `GITHUB_TOKEN` or `GH_TOKEN` from the environment), so it needs nothing from the checkout.
2788
+ *
2789
+ * In every workflow: `token-beside-oidc`, a job whose effective permissions grant `id-token: write` sets `NODE_AUTH_TOKEN` or `NPM_TOKEN` from the `secrets` context in its workflow, job or step `env`.
2790
+ *
2791
+ * Limits: the environment's protection rules and npm's trusted publisher settings are not visible in the file and are not read; a release job that calls a reusable workflow is judged only for `id-token`, since `environment` and steps belong to the called workflow; a token passed through `with`, written to an `.npmrc` by a script, or inherited through a composite action is not seen.
2792
+ */
2793
+ const workflowReleaseJob = async ({ cwd, options }) => {
2794
+ return (await loadWorkflows(cwd, options)).flatMap((workflow) => {
2795
+ const releases = jobsNamed(workflow, options.releaseJobs ?? DEFAULT_RELEASE_JOBS);
2796
+ const releaseFindings = releases.length === 0 ? [] : idTokenViolations(workflow, releases);
2797
+ const jobFindings = workflow.jobs.flatMap((job) => [...releases.includes(job) ? releaseJobViolations(workflow, job) : [], ...tokenBesideOidcViolations(workflow, job)]);
2798
+ return [...releaseFindings, ...jobFindings];
2799
+ });
2800
+ };
2801
+ //#endregion
2621
2802
  //#region src/checks/workflow-repository-dispatch.ts
2622
2803
  const DEFAULT_BRANCHES = ["main", "master"];
2623
2804
  const DEFAULT_BRANCH_EXPRESSION = /\$\{\{\s*github\.event\.repository\.default_branch\s*\}\}/u;
@@ -3071,6 +3252,10 @@ function compiles(source) {
3071
3252
  */
3072
3253
  const pattern = zod.z.string().min(1).refine(compiles, "not a valid regular expression");
3073
3254
  const patterns = zod.z.array(pattern);
3255
+ /**
3256
+ * A name template of `codec-pairs`.
3257
+ */
3258
+ const nameTemplate = zod.z.string().refine((template) => parseNameTemplate(template) !== void 0, NAME_TEMPLATE_REQUIREMENT);
3074
3259
  const severity = zod.z.enum(["warn", "error"]);
3075
3260
  const requiredRules = zod.z.record(zod.z.string().min(1), severity);
3076
3261
  const selection = {
@@ -3103,6 +3288,16 @@ const checks = zod.z.strictObject({
3103
3288
  exclude: zod.z.exactOptional(names),
3104
3289
  tsConfig: zod.z.exactOptional(path)
3105
3290
  }))),
3291
+ "codec-pairs": zod.z.exactOptional(enabled(zod.z.strictObject({
3292
+ codecs: paths,
3293
+ encoder: nameTemplate,
3294
+ decoder: nameTemplate,
3295
+ exclude: zod.z.exactOptional(names),
3296
+ tsConfig: zod.z.exactOptional(path)
3297
+ }).refine((options) => options.encoder !== options.decoder, {
3298
+ path: ["decoder"],
3299
+ message: "the decoder template is the encoder template, so every name would be its own pair"
3300
+ }))),
3106
3301
  "command-types": zod.z.exactOptional(enabled(zod.z.strictObject({
3107
3302
  commands: paths,
3108
3303
  exclude: zod.z.exactOptional(names),
@@ -3180,6 +3375,10 @@ const checks = zod.z.strictObject({
3180
3375
  }))),
3181
3376
  "workflow-version-single-source": zod.z.exactOptional(enabled(zod.z.strictObject(selection))),
3182
3377
  "workflow-credentials": zod.z.exactOptional(enabled(zod.z.strictObject(selection))),
3378
+ "workflow-release-job": zod.z.exactOptional(enabled(zod.z.strictObject({
3379
+ ...selection,
3380
+ releaseJobs: zod.z.exactOptional(names)
3381
+ }))),
3183
3382
  "workflow-repository-dispatch": zod.z.exactOptional(enabled(zod.z.strictObject({
3184
3383
  ...selection,
3185
3384
  defaultBranches: zod.z.exactOptional(names),
@@ -3359,6 +3558,12 @@ const registry = {
3359
3558
  select: (checks) => enabledOptions(checks["aggregate-mappers"]),
3360
3559
  check: aggregateMappers
3361
3560
  }),
3561
+ "codec-pairs": plain({
3562
+ name: "codec-pairs",
3563
+ description: "Every exported encoder in a codec file has a decoder under the naming templates, and every decoder an encoder",
3564
+ select: (checks) => enabledOptions(checks["codec-pairs"]),
3565
+ check: codecPairs
3566
+ }),
3362
3567
  "command-types": plain({
3363
3568
  name: "command-types",
3364
3569
  description: "Every exported command type is derived from a schema, not hand-written",
@@ -3467,6 +3672,12 @@ const registry = {
3467
3672
  select: (checks) => enabledOptions(checks["workflow-credentials"]),
3468
3673
  check: workflowCredentials
3469
3674
  }),
3675
+ "workflow-release-job": plain({
3676
+ name: "workflow-release-job",
3677
+ description: "The release job alone holds id-token, runs in an environment and persists no credentials, and no job with id-token passes an npm token secret",
3678
+ select: (checks) => enabledOptions(checks["workflow-release-job"]),
3679
+ check: workflowReleaseJob
3680
+ }),
3470
3681
  "workflow-repository-dispatch": plain({
3471
3682
  name: "workflow-repository-dispatch",
3472
3683
  description: "A repository_dispatch handler does not push to the default branch, auto-merge unguarded, or rely on the default token",
@@ -3729,6 +3940,7 @@ exports.accessOf = accessOf;
3729
3940
  exports.aggregateMappers = aggregateMappers;
3730
3941
  exports.checkNames = checkNames;
3731
3942
  exports.classifyPackages = classifyPackages;
3943
+ exports.codecPairs = codecPairs;
3732
3944
  exports.commandTypes = commandTypes;
3733
3945
  exports.commitTypes = commitTypes;
3734
3946
  exports.conformanceSchema = conformanceSchema;
@@ -3773,6 +3985,7 @@ exports.workflowActionPinning = workflowActionPinning;
3773
3985
  exports.workflowCredentials = workflowCredentials;
3774
3986
  exports.workflowJobOrdering = workflowJobOrdering;
3775
3987
  exports.workflowMergeGroup = workflowMergeGroup;
3988
+ exports.workflowReleaseJob = workflowReleaseJob;
3776
3989
  exports.workflowRepositoryDispatch = workflowRepositoryDispatch;
3777
3990
  exports.workflowRunnerResolution = workflowRunnerResolution;
3778
3991
  exports.workflowSkippableJobs = workflowSkippableJobs;
package/dist/index.d.cts CHANGED
@@ -255,6 +255,10 @@ interface Job {
255
255
  readonly needs: readonly string[];
256
256
  readonly condition: string | undefined;
257
257
  readonly permissions: Permissions;
258
+ /**
259
+ * The name of the deployment environment the job runs in, written as `environment: <name>` or `environment: { name: <name> }`; `undefined` when it names none.
260
+ */
261
+ readonly environment: string | undefined;
258
262
  readonly env: Readonly<Record<string, string>>;
259
263
  readonly runsOn: RunsOn;
260
264
  readonly timeoutMinutes: string | undefined;
@@ -282,6 +286,10 @@ interface Workflow {
282
286
  */
283
287
  readonly triggerLocations: ReadonlyMap<string, SourceLocation>;
284
288
  readonly permissions: Permissions;
289
+ /**
290
+ * Where the workflow's `permissions` is written; `undefined` when it declares none.
291
+ */
292
+ readonly permissionsLocation: SourceLocation | undefined;
285
293
  readonly env: Readonly<Record<string, string>>;
286
294
  readonly jobs: readonly Job[];
287
295
  /**
@@ -376,6 +384,31 @@ interface AggregateMappersOptions {
376
384
  */
377
385
  readonly tsConfig?: string;
378
386
  }
387
+ /**
388
+ * Options of the `codec-pairs` check: every encoder a codec file exports has a decoder it also exports, and every decoder an encoder.
389
+ */
390
+ interface CodecPairsOptions {
391
+ /**
392
+ * Globs of the codec files, relative to the directory the checks run in. Each file is judged on its own exports, re-exports included.
393
+ */
394
+ readonly codecs: readonly string[];
395
+ /**
396
+ * The name of an encoder, with `{name}` standing for the name it shares with its decoder, such as `encode{name}` or `{name}Encoder`. `{name}` appears exactly once, beside other text, and is the only placeholder.
397
+ */
398
+ readonly encoder: string;
399
+ /**
400
+ * The name of a decoder, in the same form as `encoder` and different from it.
401
+ */
402
+ readonly decoder: string;
403
+ /**
404
+ * Names of exported encoders and decoders that need no counterpart, such as an encoder for a format that is only ever written.
405
+ */
406
+ readonly exclude?: readonly string[];
407
+ /**
408
+ * The tsconfig that supplies compiler options and module resolution; `tsconfig.json` when omitted.
409
+ */
410
+ readonly tsConfig?: string;
411
+ }
379
412
  /**
380
413
  * Options of the `command-types` check: every command type a file exports is derived from a schema.
381
414
  */
@@ -642,6 +675,15 @@ type WorkflowVersionSingleSourceOptions = WorkflowSelection;
642
675
  * Options of the `workflow-credentials` check.
643
676
  */
644
677
  type WorkflowCredentialsOptions = WorkflowSelection;
678
+ /**
679
+ * Options of the `workflow-release-job` check: the job that publishes is the only one that can mint the OIDC identity, runs in an environment and leaves no credentials behind.
680
+ */
681
+ interface WorkflowReleaseJobOptions extends WorkflowSelection {
682
+ /**
683
+ * Ids of release jobs: the jobs that publish, and the only ones that may hold `id-token: write`. `release` when omitted.
684
+ */
685
+ readonly releaseJobs?: readonly string[];
686
+ }
645
687
  /**
646
688
  * Options of the `workflow-repository-dispatch` check: what a handler of `repository_dispatch` may do.
647
689
  */
@@ -737,6 +779,7 @@ type SettingsReviewThreadResolutionOptions = SettingsOptions;
737
779
  */
738
780
  interface CheckOptionsByName {
739
781
  readonly "aggregate-mappers": AggregateMappersOptions;
782
+ readonly "codec-pairs": CodecPairsOptions;
740
783
  readonly "command-types": CommandTypesOptions;
741
784
  readonly "derived-types": DerivedTypesOptions;
742
785
  readonly "import-uphill": ImportGraphOptions;
@@ -755,6 +798,7 @@ interface CheckOptionsByName {
755
798
  readonly "workflow-runner-resolution": WorkflowRunnerResolutionOptions;
756
799
  readonly "workflow-version-single-source": WorkflowVersionSingleSourceOptions;
757
800
  readonly "workflow-credentials": WorkflowCredentialsOptions;
801
+ readonly "workflow-release-job": WorkflowReleaseJobOptions;
758
802
  readonly "workflow-repository-dispatch": WorkflowRepositoryDispatchOptions;
759
803
  readonly "workflow-update-bot-cooldown": WorkflowUpdateBotCooldownOptions;
760
804
  readonly "workflow-merge-group": WorkflowMergeGroupOptions;
@@ -776,6 +820,14 @@ type CheckName = keyof CheckOptionsByName;
776
820
  */
777
821
  export declare const aggregateMappers: CheckFunction<AggregateMappersOptions>;
778
822
  //#endregion
823
+ //#region src/checks/codec-pairs.d.ts
824
+ /**
825
+ * Every encoder a codec file exports has the decoder its name implies, exported by the same file, and every decoder the encoder. A name is an encoder or a decoder when it fits that template; other exports are not codecs and are not judged. A half re-exported from another file counts, and is reported where it is declared.
826
+ *
827
+ * It checks that the counterpart is exported, not that the two round-trip: that is a property test of the repository's own.
828
+ */
829
+ export declare const codecPairs: CheckFunction<CodecPairsOptions>;
830
+ //#endregion
779
831
  //#region src/checks/command-types.d.ts
780
832
  /**
781
833
  * The last names of the generics that turn a schema into its type, used when the options name none: `z.infer`, `z.input`, `z.output`, `z.TypeOf` and Valibot's `InferInput` and `InferOutput`.
@@ -1054,6 +1106,18 @@ export declare const workflowJobOrdering: CheckFunction<WorkflowJobOrderingOptio
1054
1106
  */
1055
1107
  export declare const workflowMergeGroup: CheckFunction<WorkflowMergeGroupOptions>;
1056
1108
  //#endregion
1109
+ //#region src/checks/workflow-release-job.d.ts
1110
+ /**
1111
+ * npm binds a trusted publisher to the repository and the workflow file, not to a branch, so anyone who can push a branch can edit that branch's copy of the workflow, drop an `if` that keeps publishing to the default branch, and publish. What stops that has to live outside the file: a protected environment that only the default branch may deploy to, named in npm's trusted publisher too. This check holds the release job to the shape that makes that protection hold.
1112
+ *
1113
+ * In a workflow with a release job (by id, `releaseJobs`, `release` by default): `workflow-release-job/id-token-workflow-level`, the workflow's own `permissions` grant `id-token: write` (directly or through `write-all`), which every job that declares none inherits; `id-token-outside-release`, a job that is not a release job declares `id-token: write` itself, a job that calls a reusable workflow included; `missing-environment`, a release job declares no `environment`; `persisted-credentials`, a release job runs `actions/checkout` without `persist-credentials: false` (literally; an expression is unknown and reported), so the job token, a `token` input or an `ssh-key` stays in git config for every later step. A release job that pushes as a deploy key provisions the key in a step and hands it to git through that step's `GIT_SSH_COMMAND`, after a checkout that persists nothing, and is accepted as written; semantic-release authenticates its own push (`repositoryUrl` as written, else the URL with `GITHUB_TOKEN` or `GH_TOKEN` from the environment), so it needs nothing from the checkout.
1114
+ *
1115
+ * In every workflow: `token-beside-oidc`, a job whose effective permissions grant `id-token: write` sets `NODE_AUTH_TOKEN` or `NPM_TOKEN` from the `secrets` context in its workflow, job or step `env`.
1116
+ *
1117
+ * Limits: the environment's protection rules and npm's trusted publisher settings are not visible in the file and are not read; a release job that calls a reusable workflow is judged only for `id-token`, since `environment` and steps belong to the called workflow; a token passed through `with`, written to an `.npmrc` by a script, or inherited through a composite action is not seen.
1118
+ */
1119
+ export declare const workflowReleaseJob: CheckFunction<WorkflowReleaseJobOptions>;
1120
+ //#endregion
1057
1121
  //#region src/checks/workflow-repository-dispatch.d.ts
1058
1122
  /**
1059
1123
  * 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.
@@ -1322,4 +1386,4 @@ export declare function proveExhaustive(input: {
1322
1386
  readonly compilerOptions?: CompilerOptions;
1323
1387
  }): ExhaustivenessProof;
1324
1388
  //#endregion
1325
- export type { AggregateMappersOptions, BranchRules, CaseRemoval, CheckContext, CheckFunction, CheckName, CheckOptionsByName, CheckResult, ChecksConfig, CommandTypesOptions, CommitTypesOptions, ConformanceConfig, DeployDirectory, DeploySelector, DeployToolAdapter, DeployToolName, DerivedTypesOptions, DerivedTypesPair, DiscoveredPackage, DockerfilePackageManagerOptions, EslintOptions, EslintRequiredSeverity, EslintSample, ExhaustivenessProof, GeneratorAdapter, GeneratorName, GitHubCheckContext, GitHubCheckFunction, GitHubClient, GitHubClientOptions, ImportGraphOptions, InstructionSymlinksOptions, Job, LayoutCheckContext, LayoutCheckFunction, MigrationsDirectoryOptions, PermissionAccess, Permissions, PinningLevel, RegisteredCheck, RepositorySettings, RepositorySlug, RunChecksOptions, RunResult, RunsOn, SettingsMergeMethodsOptions, SettingsOptions, SettingsRequiredChecksOptions, SettingsReviewThreadResolutionOptions, SingleStorybookOptions, SourceLocation, Step, TypeErrorReport, UsesReference, Violation, Workflow, WorkflowActionPinningOptions, WorkflowCredentialsOptions, WorkflowJobOrderingOptions, WorkflowMergeGroupOptions, WorkflowRepositoryDispatchOptions, WorkflowRunnerResolutionOptions, WorkflowSelection, WorkflowSkippableJobsOptions, WorkflowUpdateBotCooldownOptions, WorkflowVersionSingleSourceOptions, WorkspacePackage };
1389
+ export type { AggregateMappersOptions, BranchRules, CaseRemoval, CheckContext, CheckFunction, CheckName, CheckOptionsByName, CheckResult, ChecksConfig, CodecPairsOptions, CommandTypesOptions, CommitTypesOptions, ConformanceConfig, DeployDirectory, DeploySelector, DeployToolAdapter, DeployToolName, DerivedTypesOptions, DerivedTypesPair, DiscoveredPackage, DockerfilePackageManagerOptions, EslintOptions, EslintRequiredSeverity, EslintSample, ExhaustivenessProof, GeneratorAdapter, GeneratorName, GitHubCheckContext, GitHubCheckFunction, GitHubClient, GitHubClientOptions, ImportGraphOptions, InstructionSymlinksOptions, Job, LayoutCheckContext, LayoutCheckFunction, MigrationsDirectoryOptions, PermissionAccess, Permissions, PinningLevel, RegisteredCheck, RepositorySettings, RepositorySlug, RunChecksOptions, RunResult, RunsOn, SettingsMergeMethodsOptions, SettingsOptions, SettingsRequiredChecksOptions, SettingsReviewThreadResolutionOptions, SingleStorybookOptions, SourceLocation, Step, TypeErrorReport, UsesReference, Violation, Workflow, WorkflowActionPinningOptions, WorkflowCredentialsOptions, WorkflowJobOrderingOptions, WorkflowMergeGroupOptions, WorkflowReleaseJobOptions, WorkflowRepositoryDispatchOptions, WorkflowRunnerResolutionOptions, WorkflowSelection, WorkflowSkippableJobsOptions, WorkflowUpdateBotCooldownOptions, WorkflowVersionSingleSourceOptions, WorkspacePackage };
package/dist/index.d.ts CHANGED
@@ -255,6 +255,10 @@ interface Job {
255
255
  readonly needs: readonly string[];
256
256
  readonly condition: string | undefined;
257
257
  readonly permissions: Permissions;
258
+ /**
259
+ * The name of the deployment environment the job runs in, written as `environment: <name>` or `environment: { name: <name> }`; `undefined` when it names none.
260
+ */
261
+ readonly environment: string | undefined;
258
262
  readonly env: Readonly<Record<string, string>>;
259
263
  readonly runsOn: RunsOn;
260
264
  readonly timeoutMinutes: string | undefined;
@@ -282,6 +286,10 @@ interface Workflow {
282
286
  */
283
287
  readonly triggerLocations: ReadonlyMap<string, SourceLocation>;
284
288
  readonly permissions: Permissions;
289
+ /**
290
+ * Where the workflow's `permissions` is written; `undefined` when it declares none.
291
+ */
292
+ readonly permissionsLocation: SourceLocation | undefined;
285
293
  readonly env: Readonly<Record<string, string>>;
286
294
  readonly jobs: readonly Job[];
287
295
  /**
@@ -376,6 +384,31 @@ interface AggregateMappersOptions {
376
384
  */
377
385
  readonly tsConfig?: string;
378
386
  }
387
+ /**
388
+ * Options of the `codec-pairs` check: every encoder a codec file exports has a decoder it also exports, and every decoder an encoder.
389
+ */
390
+ interface CodecPairsOptions {
391
+ /**
392
+ * Globs of the codec files, relative to the directory the checks run in. Each file is judged on its own exports, re-exports included.
393
+ */
394
+ readonly codecs: readonly string[];
395
+ /**
396
+ * The name of an encoder, with `{name}` standing for the name it shares with its decoder, such as `encode{name}` or `{name}Encoder`. `{name}` appears exactly once, beside other text, and is the only placeholder.
397
+ */
398
+ readonly encoder: string;
399
+ /**
400
+ * The name of a decoder, in the same form as `encoder` and different from it.
401
+ */
402
+ readonly decoder: string;
403
+ /**
404
+ * Names of exported encoders and decoders that need no counterpart, such as an encoder for a format that is only ever written.
405
+ */
406
+ readonly exclude?: readonly string[];
407
+ /**
408
+ * The tsconfig that supplies compiler options and module resolution; `tsconfig.json` when omitted.
409
+ */
410
+ readonly tsConfig?: string;
411
+ }
379
412
  /**
380
413
  * Options of the `command-types` check: every command type a file exports is derived from a schema.
381
414
  */
@@ -642,6 +675,15 @@ type WorkflowVersionSingleSourceOptions = WorkflowSelection;
642
675
  * Options of the `workflow-credentials` check.
643
676
  */
644
677
  type WorkflowCredentialsOptions = WorkflowSelection;
678
+ /**
679
+ * Options of the `workflow-release-job` check: the job that publishes is the only one that can mint the OIDC identity, runs in an environment and leaves no credentials behind.
680
+ */
681
+ interface WorkflowReleaseJobOptions extends WorkflowSelection {
682
+ /**
683
+ * Ids of release jobs: the jobs that publish, and the only ones that may hold `id-token: write`. `release` when omitted.
684
+ */
685
+ readonly releaseJobs?: readonly string[];
686
+ }
645
687
  /**
646
688
  * Options of the `workflow-repository-dispatch` check: what a handler of `repository_dispatch` may do.
647
689
  */
@@ -737,6 +779,7 @@ type SettingsReviewThreadResolutionOptions = SettingsOptions;
737
779
  */
738
780
  interface CheckOptionsByName {
739
781
  readonly "aggregate-mappers": AggregateMappersOptions;
782
+ readonly "codec-pairs": CodecPairsOptions;
740
783
  readonly "command-types": CommandTypesOptions;
741
784
  readonly "derived-types": DerivedTypesOptions;
742
785
  readonly "import-uphill": ImportGraphOptions;
@@ -755,6 +798,7 @@ interface CheckOptionsByName {
755
798
  readonly "workflow-runner-resolution": WorkflowRunnerResolutionOptions;
756
799
  readonly "workflow-version-single-source": WorkflowVersionSingleSourceOptions;
757
800
  readonly "workflow-credentials": WorkflowCredentialsOptions;
801
+ readonly "workflow-release-job": WorkflowReleaseJobOptions;
758
802
  readonly "workflow-repository-dispatch": WorkflowRepositoryDispatchOptions;
759
803
  readonly "workflow-update-bot-cooldown": WorkflowUpdateBotCooldownOptions;
760
804
  readonly "workflow-merge-group": WorkflowMergeGroupOptions;
@@ -776,6 +820,14 @@ type CheckName = keyof CheckOptionsByName;
776
820
  */
777
821
  export declare const aggregateMappers: CheckFunction<AggregateMappersOptions>;
778
822
  //#endregion
823
+ //#region src/checks/codec-pairs.d.ts
824
+ /**
825
+ * Every encoder a codec file exports has the decoder its name implies, exported by the same file, and every decoder the encoder. A name is an encoder or a decoder when it fits that template; other exports are not codecs and are not judged. A half re-exported from another file counts, and is reported where it is declared.
826
+ *
827
+ * It checks that the counterpart is exported, not that the two round-trip: that is a property test of the repository's own.
828
+ */
829
+ export declare const codecPairs: CheckFunction<CodecPairsOptions>;
830
+ //#endregion
779
831
  //#region src/checks/command-types.d.ts
780
832
  /**
781
833
  * The last names of the generics that turn a schema into its type, used when the options name none: `z.infer`, `z.input`, `z.output`, `z.TypeOf` and Valibot's `InferInput` and `InferOutput`.
@@ -1054,6 +1106,18 @@ export declare const workflowJobOrdering: CheckFunction<WorkflowJobOrderingOptio
1054
1106
  */
1055
1107
  export declare const workflowMergeGroup: CheckFunction<WorkflowMergeGroupOptions>;
1056
1108
  //#endregion
1109
+ //#region src/checks/workflow-release-job.d.ts
1110
+ /**
1111
+ * npm binds a trusted publisher to the repository and the workflow file, not to a branch, so anyone who can push a branch can edit that branch's copy of the workflow, drop an `if` that keeps publishing to the default branch, and publish. What stops that has to live outside the file: a protected environment that only the default branch may deploy to, named in npm's trusted publisher too. This check holds the release job to the shape that makes that protection hold.
1112
+ *
1113
+ * In a workflow with a release job (by id, `releaseJobs`, `release` by default): `workflow-release-job/id-token-workflow-level`, the workflow's own `permissions` grant `id-token: write` (directly or through `write-all`), which every job that declares none inherits; `id-token-outside-release`, a job that is not a release job declares `id-token: write` itself, a job that calls a reusable workflow included; `missing-environment`, a release job declares no `environment`; `persisted-credentials`, a release job runs `actions/checkout` without `persist-credentials: false` (literally; an expression is unknown and reported), so the job token, a `token` input or an `ssh-key` stays in git config for every later step. A release job that pushes as a deploy key provisions the key in a step and hands it to git through that step's `GIT_SSH_COMMAND`, after a checkout that persists nothing, and is accepted as written; semantic-release authenticates its own push (`repositoryUrl` as written, else the URL with `GITHUB_TOKEN` or `GH_TOKEN` from the environment), so it needs nothing from the checkout.
1114
+ *
1115
+ * In every workflow: `token-beside-oidc`, a job whose effective permissions grant `id-token: write` sets `NODE_AUTH_TOKEN` or `NPM_TOKEN` from the `secrets` context in its workflow, job or step `env`.
1116
+ *
1117
+ * Limits: the environment's protection rules and npm's trusted publisher settings are not visible in the file and are not read; a release job that calls a reusable workflow is judged only for `id-token`, since `environment` and steps belong to the called workflow; a token passed through `with`, written to an `.npmrc` by a script, or inherited through a composite action is not seen.
1118
+ */
1119
+ export declare const workflowReleaseJob: CheckFunction<WorkflowReleaseJobOptions>;
1120
+ //#endregion
1057
1121
  //#region src/checks/workflow-repository-dispatch.d.ts
1058
1122
  /**
1059
1123
  * 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.
@@ -1322,4 +1386,4 @@ export declare function proveExhaustive(input: {
1322
1386
  readonly compilerOptions?: CompilerOptions;
1323
1387
  }): ExhaustivenessProof;
1324
1388
  //#endregion
1325
- export type { AggregateMappersOptions, BranchRules, CaseRemoval, CheckContext, CheckFunction, CheckName, CheckOptionsByName, CheckResult, ChecksConfig, CommandTypesOptions, CommitTypesOptions, ConformanceConfig, DeployDirectory, DeploySelector, DeployToolAdapter, DeployToolName, DerivedTypesOptions, DerivedTypesPair, DiscoveredPackage, DockerfilePackageManagerOptions, EslintOptions, EslintRequiredSeverity, EslintSample, ExhaustivenessProof, GeneratorAdapter, GeneratorName, GitHubCheckContext, GitHubCheckFunction, GitHubClient, GitHubClientOptions, ImportGraphOptions, InstructionSymlinksOptions, Job, LayoutCheckContext, LayoutCheckFunction, MigrationsDirectoryOptions, PermissionAccess, Permissions, PinningLevel, RegisteredCheck, RepositorySettings, RepositorySlug, RunChecksOptions, RunResult, RunsOn, SettingsMergeMethodsOptions, SettingsOptions, SettingsRequiredChecksOptions, SettingsReviewThreadResolutionOptions, SingleStorybookOptions, SourceLocation, Step, TypeErrorReport, UsesReference, Violation, Workflow, WorkflowActionPinningOptions, WorkflowCredentialsOptions, WorkflowJobOrderingOptions, WorkflowMergeGroupOptions, WorkflowRepositoryDispatchOptions, WorkflowRunnerResolutionOptions, WorkflowSelection, WorkflowSkippableJobsOptions, WorkflowUpdateBotCooldownOptions, WorkflowVersionSingleSourceOptions, WorkspacePackage };
1389
+ export type { AggregateMappersOptions, BranchRules, CaseRemoval, CheckContext, CheckFunction, CheckName, CheckOptionsByName, CheckResult, ChecksConfig, CodecPairsOptions, CommandTypesOptions, CommitTypesOptions, ConformanceConfig, DeployDirectory, DeploySelector, DeployToolAdapter, DeployToolName, DerivedTypesOptions, DerivedTypesPair, DiscoveredPackage, DockerfilePackageManagerOptions, EslintOptions, EslintRequiredSeverity, EslintSample, ExhaustivenessProof, GeneratorAdapter, GeneratorName, GitHubCheckContext, GitHubCheckFunction, GitHubClient, GitHubClientOptions, ImportGraphOptions, InstructionSymlinksOptions, Job, LayoutCheckContext, LayoutCheckFunction, MigrationsDirectoryOptions, PermissionAccess, Permissions, PinningLevel, RegisteredCheck, RepositorySettings, RepositorySlug, RunChecksOptions, RunResult, RunsOn, SettingsMergeMethodsOptions, SettingsOptions, SettingsRequiredChecksOptions, SettingsReviewThreadResolutionOptions, SingleStorybookOptions, SourceLocation, Step, TypeErrorReport, UsesReference, Violation, Workflow, WorkflowActionPinningOptions, WorkflowCredentialsOptions, WorkflowJobOrderingOptions, WorkflowMergeGroupOptions, WorkflowReleaseJobOptions, WorkflowRepositoryDispatchOptions, WorkflowRunnerResolutionOptions, WorkflowSelection, WorkflowSkippableJobsOptions, WorkflowUpdateBotCooldownOptions, WorkflowVersionSingleSourceOptions, WorkspacePackage };