workspace-conformance 1.5.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/README.md CHANGED
@@ -224,6 +224,7 @@ Every workflow check takes `workflows` (globs of the files to read, `.github/wor
224
224
  | `workflow-runner-resolution` | A custom or self-hosted runner label is not named literally in several jobs; a resolver job has a `timeout-minutes` and its output a literal hosted fallback. | `repeated-label`, `resolver-no-timeout`, `resolver-no-fallback`, `resolver-fallback-not-hosted` |
225
225
  | `workflow-version-single-source` | A setup step does not hold a literal runtime version when `.nvmrc` or `.tool-versions` already does. | `literal-version` |
226
226
  | `workflow-credentials` | Attestation steps have `id-token: write` and `attestations: write`; a publish that passes no token blanks the token variables, or at least requests provenance so that a fallback publish is traceable. | `attestation-permissions`, `tokenless-publish-unprotected` |
227
+ | `workflow-release-job` | The release job is the only job granted `id-token: write`, runs in a deployment environment and checks out with `persist-credentials: false`; no job holding `id-token: write` passes an npm token from a secret. | `id-token-workflow-level`, `id-token-outside-release`, `missing-environment`, `persisted-credentials`, `token-beside-oidc` |
227
228
  | `workflow-repository-dispatch` | A `repository_dispatch` handler does not push to the default branch, auto-merge without a required-checks rule, push or open pull requests as `GITHUB_TOKEN`, or dispatch to another repository with it. | `pushes-default-branch`, `auto-merge-without-required-checks`, `default-token-triggers-nothing`, `dispatch-other-repository-with-default-token` |
228
229
  | `workflow-update-bot-cooldown` | Dependabot has a `cooldown` in every update entry, and Renovate sets `minimumReleaseAge`. | `dependabot-no-cooldown`, `renovate-no-minimum-release-age` |
229
230
  | `workflow-merge-group` | A workflow that runs for `pull_request` also has a `merge_group` trigger. | `missing-trigger` |
@@ -241,6 +242,10 @@ Codes are `<check>/<code>`, so `workflow-job-ordering/release-before-deploy`. Th
241
242
 
242
243
  **`workflow-credentials`**: an attestation step (`actions/attest`, `actions/attest-build-provenance`, `actions/attest-sbom`) needs `id-token: write` and `attestations: write` in the job's effective permissions. Permissions declared nowhere are the repository's default, which the file cannot show and which does not grant `id-token`, so they are reported. A job with `id-token: write` that runs a publish command (`npm`, `pnpm` or `yarn npm publish`, with options such as `-r` or `--filter <package>` before `publish`, `semantic-release`, `changeset publish`, `lerna publish`, bare or behind `pnpm`, `pnpm exec`, `yarn` or `npx`) and passes no token must set `NODE_AUTH_TOKEN` and `NPM_TOKEN` to the empty string (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); provenance does not stop an inherited token being used, it makes that publish traceable. Limits: commands are read as text where a command starts, so a script file or composite action is not seen; provenance set in another `package.json` is not read; a job is tokenless only if no token variable has a non-empty value anywhere in it; a job that calls a reusable workflow is not read. `registry-url` is deliberately not reported, see below.
243
244
 
245
+ **`workflow-release-job`** takes `releaseJobs` (`release`, the same default as `workflow-job-ordering`). npm binds a trusted publisher to the repository and the workflow file, not to a branch ([trusted publishers](https://docs.npmjs.com/trusted-publishers), [archived](https://web.archive.org/web/20260921070919/https://docs.npmjs.com/trusted-publishers/)), so anyone who can push a branch can edit that branch's copy of the workflow, remove the `if` that keeps publishing to the default branch, and publish. The protection has to live outside the file: a deployment environment whose deployment branch policy allows only the default branch, named as the environment of the trusted publisher on npm, so an OIDC token minted anywhere else is refused. In a workflow with a release job, `id-token-workflow-level` reports workflow-level `permissions` that grant `id-token: write` (directly or through `write-all`), since every job that declares none inherits it, and `id-token-outside-release` reports any other job that declares it, a job that calls a reusable workflow included, because the OIDC identity is the publishing credential and only the release job should be able to mint it. `missing-environment` reports a release job with no `environment` (a name, or a mapping with a `name`). `persisted-credentials` reports a release job's `actions/checkout` that does not set `persist-credentials: false` literally, since the job token, a `token` input or an `ssh-key` would otherwise stay in git config for every later step, the dependency install and third-party actions included. There is no exception for a release that pushes: semantic-release pushes to its `repositoryUrl` as written and, only when that fails, to the same URL with `GITHUB_TOKEN` or `GH_TOKEN` from the environment embedded (`lib/get-git-auth-url.js` of `semantic-release`), so it never needs the checkout's credentials. A deploy-key release job, the shape ExaDev's own release jobs use, checks out with `persist-credentials: false`, writes the key to a file in the step before the release, and hands it to git only through the `GIT_SSH_COMMAND` in the `env` of the steps that push; it is accepted as written, and the clean fixture holds one. In every workflow, `token-beside-oidc` reports a job whose effective permissions grant `id-token: write` and whose workflow, job or step `env` sets `NODE_AUTH_TOKEN` or `NPM_TOKEN` from the `secrets` context: a long-lived token beside the OIDC identity is a second way to publish that no environment protection or trusted publisher setting covers. Limits: the environment's protection rules and the npm trusted publisher settings cannot be seen in the file and are not read (`missing-environment` checks only that an environment is named); a release job that calls a reusable workflow is judged only for `id-token`, since `environment` and the steps belong to the called workflow; a token passed through `with`, written to an `.npmrc` by a script, or set inside a composite action is not seen.
246
+
247
+ What zizmor already covers, and what this check adds, as observed with zizmor 1.30.1 on this check's fixtures ([audit rules](https://docs.zizmor.sh/audits/), [archived](https://web.archive.org/web/20260929212113/https://docs.zizmor.sh/audits/)): `excessive-permissions` reports a workflow-level `id-token: write`, but not `id-token: write` declared by a second job, which only `undocumented-permissions` mentions when the grant has no comment. `artipacked` reports every `actions/checkout` without `persist-credentials: false`, in every job and every persona, at `help` severity for `actions/checkout` v6 and later, which keeps the credentials under `$RUNNER_TEMP` rather than in `.git/config`; this check reports it as a violation in the release job, where a step that reads git config has the publishing job's push credential. `secrets-outside-env` (auditor persona only) reports a `secrets` reference in a job with no environment, so it catches a missing environment only when the release job reads a secret; a tokenless OIDC release job with no environment produces no zizmor finding at all. `use-trusted-publishing` reports an `npm publish` that uses a token, but is silent once the job also has `id-token: write`, so an `NPM_TOKEN` from a secret beside `id-token: write` in a job with an environment produces no zizmor finding. No zizmor audit relates the grants of one job to the others in a workflow, or requires an environment for the job that holds `id-token: write`.
248
+
244
249
  **`workflow-repository-dispatch`** takes `defaultBranches` (`main` and `master`; the `github.event.repository.default_branch` expression always counts) and `assumeRequiredChecks`. It reads workflows with a `repository_dispatch` trigger, which anyone with write access can start through the API. `pushes-default-branch` reports a `git push` whose destination is the default branch (a leading `+` is ignored, a bare `HEAD` is the checked-out branch, quotes around a word are removed, `git -C <directory>` and `git -c <setting>` before `push` are read, and options that take a value such as `-o` are skipped), a push with no refspec while the default branch is checked out (no `git checkout -b` or `git switch -c` earlier in the job, no non-default `ref` on the checkout) and `stefanzweifel/git-auto-commit-action` on the default branch. `auto-merge-without-required-checks` reports `gh pr merge --auto`, which merges at once when the base branch requires no checks; the file cannot show the rule, so `assumeRequiredChecks: true` states it and `settings-required-checks` verifies it. `default-token-triggers-nothing` reports a push whose checkout kept the default token (no `token` input; a push to a URL that carries its own credentials does not use it), `gh pr create` with `GITHUB_TOKEN` and `peter-evans/create-pull-request` with the default token: events created with `GITHUB_TOKEN` start no workflows, so the result is never checked. `dispatch-other-repository-with-default-token` reports a dispatch (`gh api repos/<o>/<r>/dispatches`, `gh workflow run -R`, `peter-evans/repository-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 not reported, because GitHub documents `workflow_dispatch` and `repository_dispatch` as the events that do create runs when made with it ([events that trigger workflows](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows), [archived](https://web.archive.org/web/20261001051219/https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows)). Limits: commands are read as text, so a script file is not seen; a dispatch target written literally as this repository's own name is reported, since the file does not know the name.
245
250
 
246
251
  **`workflow-update-bot-cooldown`** takes `dependabot` (`.github/dependabot.yml`, else `.yaml`) and `renovate` (`renovate.json`, `.renovaterc.json`, `.renovaterc` and `.github/renovate.json`, the first that exists). Every Dependabot `updates` entry needs a `cooldown` with `default-days` or one of the `semver-*-days` above zero. Renovate needs `minimumReleaseAge` at the top level or in a `packageRules` entry, as a duration above zero (`0 days` and `null` wait for nothing). Limits: Dependabot's cooldown does not apply to security updates; a Renovate preset is not resolved (an `extends` entry counts only when its name contains `minimumReleaseAge`); a Renovate config is read as JSON with comments and trailing commas, so other JSON5 syntax and a `renovate` key in `package.json` are not read. A repository with neither bot has nothing to report; a file named in the options that does not exist fails the run.
@@ -306,7 +311,7 @@ it('conforms to the workspace layout', async () => {
306
311
  });
307
312
  ```
308
313
 
309
- Every check is also exported as a function taking `{ cwd, options }` (and `layout` for the import checks): `aggregateMappers`, `codecPairs`, `commandTypes`, `derivedTypes`, `importUphill`, `importRankSkip`, `importCrossSlice`, `importIsolatedGroups`, `importCycles`, `instructionSymlinks`, `singleStorybook`, `commitTypes`, `dockerfilePackageManager`, `migrationsDirectory`, `eslint`, the workflow checks (`workflowJobOrdering`, `workflowSkippableJobs`, `workflowRunnerResolution`, `workflowVersionSingleSource`, `workflowCredentials`, `workflowRepositoryDispatch`, `workflowUpdateBotCooldown`, `workflowMergeGroup` and `workflowActionPinning`) and, taking `{ cwd, options, github }`, the settings checks (`settingsMergeMethods`, `settingsRequiredChecks` and `settingsReviewThreadResolution`). These take options and a layout as already validated and do not check them again; go through `runChecks` for validation. `parseWorkflow` and `loadWorkflows` expose the workflow model the workflow checks read. A check is a function that returns `Promise<readonly Violation[]>`, where a `Violation` is `{ code, message, file, location? }`.
314
+ Every check is also exported as a function taking `{ cwd, options }` (and `layout` for the import checks): `aggregateMappers`, `codecPairs`, `commandTypes`, `derivedTypes`, `importUphill`, `importRankSkip`, `importCrossSlice`, `importIsolatedGroups`, `importCycles`, `instructionSymlinks`, `singleStorybook`, `commitTypes`, `dockerfilePackageManager`, `migrationsDirectory`, `eslint`, the workflow checks (`workflowJobOrdering`, `workflowSkippableJobs`, `workflowRunnerResolution`, `workflowVersionSingleSource`, `workflowCredentials`, `workflowReleaseJob`, `workflowRepositoryDispatch`, `workflowUpdateBotCooldown`, `workflowMergeGroup` and `workflowActionPinning`) and, taking `{ cwd, options, github }`, the settings checks (`settingsMergeMethods`, `settingsRequiredChecks` and `settingsReviewThreadResolution`). These take options and a layout as already validated and do not check them again; go through `runChecks` for validation. `parseWorkflow` and `loadWorkflows` expose the workflow model the workflow checks read. A check is a function that returns `Promise<readonly Violation[]>`, where a `Violation` is `{ code, message, file, location? }`.
310
315
 
311
316
  ### Proving a handler map is exhaustive
312
317
 
package/dist/cli.js CHANGED
@@ -1098,6 +1098,9 @@ function runsOnOf(reader, value, what) {
1098
1098
  group
1099
1099
  };
1100
1100
  }
1101
+ function environmentOf(reader, value, what) {
1102
+ return isRecord(value) ? reader.text(value["name"], `${what}.name`) : reader.text(value, what);
1103
+ }
1101
1104
  function stepOf(reader, value, jobPath, index) {
1102
1105
  const path = [
1103
1106
  ...jobPath,
@@ -1130,6 +1133,7 @@ function jobOf(reader, id, value) {
1130
1133
  needs: reader.list(job["needs"], `${what}.needs`),
1131
1134
  condition: reader.text(job["if"], `${what}.if`),
1132
1135
  permissions: permissionsOf(reader, job["permissions"], `${what}.permissions`),
1136
+ environment: environmentOf(reader, job["environment"], `${what}.environment`),
1133
1137
  env: reader.texts(job["env"], `${what}.env`),
1134
1138
  runsOn: runsOnOf(reader, job["runs-on"], `${what}.runs-on`),
1135
1139
  timeoutMinutes: reader.text(job["timeout-minutes"], `${what}.timeout-minutes`),
@@ -1165,6 +1169,7 @@ function parseWorkflow(file, source) {
1165
1169
  triggers,
1166
1170
  triggerLocations: triggerLocationsOf(reader, triggers, root["on"]),
1167
1171
  permissions: permissionsOf(reader, root["permissions"], "permissions"),
1172
+ permissionsLocation: root["permissions"] === void 0 ? void 0 : reader.location(["permissions"]),
1168
1173
  env: reader.texts(root["env"], "env"),
1169
1174
  jobs: Object.entries(reader.record(root["jobs"], "jobs")).map(([id, job]) => jobOf(reader, id, job)),
1170
1175
  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`) ?? ""]))
@@ -1343,6 +1348,14 @@ function callsAlways(condition) {
1343
1348
  * The default ids of junction jobs, shared by the checks that look for one.
1344
1349
  */
1345
1350
  const DEFAULT_JUNCTION_JOBS = ["required-checks"];
1351
+ /**
1352
+ * The default ids of release jobs, shared by the checks that look for one.
1353
+ */
1354
+ const DEFAULT_RELEASE_JOBS = ["release"];
1355
+ /**
1356
+ * The variables a token for the npm registry is passed in.
1357
+ */
1358
+ const REGISTRY_TOKEN_VARIABLES = ["NODE_AUTH_TOKEN", "NPM_TOKEN"];
1346
1359
  //#endregion
1347
1360
  //#region src/checks/settings-required-checks.ts
1348
1361
  /**
@@ -1478,10 +1491,6 @@ const PACKAGE_MANAGER_OPTIONS = String.raw`(?:\s+(?:(?:--filter|-F|--workspace|-
1478
1491
  * Commands that publish a package to a registry.
1479
1492
  */
1480
1493
  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");
1481
- /**
1482
- * The variables a token for the registry is passed in.
1483
- */
1484
- const TOKEN_VARIABLES = ["NODE_AUTH_TOKEN", "NPM_TOKEN"];
1485
1494
  const PROVENANCE_FLAG = /--provenance(?:=true)?(?:\s|$)/u;
1486
1495
  /**
1487
1496
  * Whether the `package.json` in the working directory asks for provenance with `publishConfig.provenance: true`, which applies to every publish of that package.
@@ -1505,7 +1514,7 @@ function attestationViolations(workflow, job) {
1505
1514
  * 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.
1506
1515
  */
1507
1516
  function passesToken(workflow, job) {
1508
- return [void 0, ...job.steps].some((step) => TOKEN_VARIABLES.some((variable) => (effectiveEnv(workflow, job, step)[variable] ?? "") !== ""));
1517
+ return [void 0, ...job.steps].some((step) => REGISTRY_TOKEN_VARIABLES.some((variable) => (effectiveEnv(workflow, job, step)[variable] ?? "") !== ""));
1509
1518
  }
1510
1519
  function tokenlessPublishViolations(workflow, job, provenanceInManifest) {
1511
1520
  if (accessOf(effectivePermissions(workflow, job), "id-token") !== "write" || passesToken(workflow, job)) return [];
@@ -1513,9 +1522,9 @@ function tokenlessPublishViolations(workflow, job, provenanceInManifest) {
1513
1522
  const publishes = scriptCommands(step.run).filter((line) => PUBLISH_COMMAND.test(line));
1514
1523
  if (publishes.length === 0) return [];
1515
1524
  const env = effectiveEnv(workflow, job, step);
1516
- const blanked = TOKEN_VARIABLES.every((variable) => env[variable] === "");
1525
+ const blanked = REGISTRY_TOKEN_VARIABLES.every((variable) => env[variable] === "");
1517
1526
  const provenance = provenanceInManifest || publishes.some((line) => PROVENANCE_FLAG.test(line)) || env["NPM_CONFIG_PROVENANCE"] === "true";
1518
- 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)];
1527
+ 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)];
1519
1528
  });
1520
1529
  }
1521
1530
  /**
@@ -1533,7 +1542,6 @@ const workflowCredentials = async ({ cwd, options }) => {
1533
1542
  };
1534
1543
  //#endregion
1535
1544
  //#region src/checks/workflow-job-ordering.ts
1536
- const DEFAULT_RELEASE_JOBS = ["release"];
1537
1545
  const DEFAULT_DEPLOY_JOBS = ["deploy"];
1538
1546
  const DEFAULT_DOCS_DEPLOY_JOBS = ["docs-deploy"];
1539
1547
  const DEFAULT_BRANCH = "main";
@@ -1681,6 +1689,71 @@ const workflowMergeGroup = async ({ cwd, options }) => (await loadWorkflows(cwd,
1681
1689
  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)];
1682
1690
  });
1683
1691
  //#endregion
1692
+ //#region src/checks/workflow-release-job.ts
1693
+ const CHECKOUT_ACTION$1 = "actions/checkout";
1694
+ /**
1695
+ * A value that reads the `secrets` context inside an expression.
1696
+ */
1697
+ const FROM_SECRET = /\$\{\{[^}]*\bsecrets\./u;
1698
+ /**
1699
+ * The release jobs named for a message: `release job 'a'` or `release jobs 'a' and 'b'`.
1700
+ */
1701
+ function releaseJobsText(releases) {
1702
+ const ids = releases.map((job) => `'${job.id}'`);
1703
+ return `release job${ids.length === 1 ? "" : "s"} ${ids.join(" and ")}`;
1704
+ }
1705
+ function grantsIdToken(permissions) {
1706
+ return accessOf(permissions, "id-token") === "write";
1707
+ }
1708
+ function idTokenViolations(workflow, releases) {
1709
+ const releaseIds = new Set(releases.map((job) => job.id));
1710
+ const named = releaseJobsText(releases);
1711
+ 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)] : [];
1712
+ 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));
1713
+ return [...workflowLevel, ...jobLevel];
1714
+ }
1715
+ /**
1716
+ * What a checkout that does not set `persist-credentials: false` leaves in git config.
1717
+ */
1718
+ function persistedCredential(step) {
1719
+ if (step.with["ssh-key"] !== void 0) return "the SSH key";
1720
+ return step.with["token"] === void 0 ? "the job token" : "the token it was given";
1721
+ }
1722
+ function releaseJobViolations(workflow, job) {
1723
+ if (job.uses !== void 0) return [];
1724
+ 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)] : [];
1725
+ 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));
1726
+ return [...environment, ...checkouts];
1727
+ }
1728
+ /**
1729
+ * The token variables an environment sets from a secret.
1730
+ */
1731
+ function secretTokens(env) {
1732
+ return REGISTRY_TOKEN_VARIABLES.filter((variable) => FROM_SECRET.test(env[variable] ?? ""));
1733
+ }
1734
+ function tokenBesideOidcViolations(workflow, job) {
1735
+ if (!grantsIdToken(effectivePermissions(workflow, job))) return [];
1736
+ 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)];
1737
+ return [...report(secretTokens(effectiveEnv(workflow, job, void 0)), job.location), ...job.steps.flatMap((step) => report(secretTokens(step.env), step.location))];
1738
+ }
1739
+ /**
1740
+ * 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.
1741
+ *
1742
+ * 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.
1743
+ *
1744
+ * 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`.
1745
+ *
1746
+ * 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.
1747
+ */
1748
+ const workflowReleaseJob = async ({ cwd, options }) => {
1749
+ return (await loadWorkflows(cwd, options)).flatMap((workflow) => {
1750
+ const releases = jobsNamed(workflow, options.releaseJobs ?? DEFAULT_RELEASE_JOBS);
1751
+ const releaseFindings = releases.length === 0 ? [] : idTokenViolations(workflow, releases);
1752
+ const jobFindings = workflow.jobs.flatMap((job) => [...releases.includes(job) ? releaseJobViolations(workflow, job) : [], ...tokenBesideOidcViolations(workflow, job)]);
1753
+ return [...releaseFindings, ...jobFindings];
1754
+ });
1755
+ };
1756
+ //#endregion
1684
1757
  //#region src/checks/workflow-repository-dispatch.ts
1685
1758
  const DEFAULT_BRANCHES = ["main", "master"];
1686
1759
  const DEFAULT_BRANCH_EXPRESSION = /\$\{\{\s*github\.event\.repository\.default_branch\s*\}\}/u;
@@ -3380,6 +3453,12 @@ const registry = {
3380
3453
  select: (checks) => enabledOptions(checks["workflow-credentials"]),
3381
3454
  check: workflowCredentials
3382
3455
  }),
3456
+ "workflow-release-job": plain({
3457
+ name: "workflow-release-job",
3458
+ 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",
3459
+ select: (checks) => enabledOptions(checks["workflow-release-job"]),
3460
+ check: workflowReleaseJob
3461
+ }),
3383
3462
  "workflow-repository-dispatch": plain({
3384
3463
  name: "workflow-repository-dispatch",
3385
3464
  description: "A repository_dispatch handler does not push to the default branch, auto-merge unguarded, or rely on the default token",
@@ -3574,6 +3653,10 @@ const checks = z.strictObject({
3574
3653
  }))),
3575
3654
  "workflow-version-single-source": z.exactOptional(enabled(z.strictObject(selection))),
3576
3655
  "workflow-credentials": z.exactOptional(enabled(z.strictObject(selection))),
3656
+ "workflow-release-job": z.exactOptional(enabled(z.strictObject({
3657
+ ...selection,
3658
+ releaseJobs: z.exactOptional(names)
3659
+ }))),
3577
3660
  "workflow-repository-dispatch": z.exactOptional(enabled(z.strictObject({
3578
3661
  ...selection,
3579
3662
  defaultBranches: z.exactOptional(names),
package/dist/index.cjs CHANGED
@@ -2128,6 +2128,9 @@ function runsOnOf(reader, value, what) {
2128
2128
  group
2129
2129
  };
2130
2130
  }
2131
+ function environmentOf(reader, value, what) {
2132
+ return isRecord(value) ? reader.text(value["name"], `${what}.name`) : reader.text(value, what);
2133
+ }
2131
2134
  function stepOf(reader, value, jobPath, index) {
2132
2135
  const path = [
2133
2136
  ...jobPath,
@@ -2160,6 +2163,7 @@ function jobOf(reader, id, value) {
2160
2163
  needs: reader.list(job["needs"], `${what}.needs`),
2161
2164
  condition: reader.text(job["if"], `${what}.if`),
2162
2165
  permissions: permissionsOf(reader, job["permissions"], `${what}.permissions`),
2166
+ environment: environmentOf(reader, job["environment"], `${what}.environment`),
2163
2167
  env: reader.texts(job["env"], `${what}.env`),
2164
2168
  runsOn: runsOnOf(reader, job["runs-on"], `${what}.runs-on`),
2165
2169
  timeoutMinutes: reader.text(job["timeout-minutes"], `${what}.timeout-minutes`),
@@ -2195,6 +2199,7 @@ function parseWorkflow(file, source) {
2195
2199
  triggers,
2196
2200
  triggerLocations: triggerLocationsOf(reader, triggers, root["on"]),
2197
2201
  permissions: permissionsOf(reader, root["permissions"], "permissions"),
2202
+ permissionsLocation: root["permissions"] === void 0 ? void 0 : reader.location(["permissions"]),
2198
2203
  env: reader.texts(root["env"], "env"),
2199
2204
  jobs: Object.entries(reader.record(root["jobs"], "jobs")).map(([id, job]) => jobOf(reader, id, job)),
2200
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`) ?? ""]))
@@ -2373,6 +2378,14 @@ function callsAlways(condition) {
2373
2378
  * The default ids of junction jobs, shared by the checks that look for one.
2374
2379
  */
2375
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"];
2376
2389
  //#endregion
2377
2390
  //#region src/checks/settings-required-checks.ts
2378
2391
  /**
@@ -2523,10 +2536,6 @@ const PACKAGE_MANAGER_OPTIONS = String.raw`(?:\s+(?:(?:--filter|-F|--workspace|-
2523
2536
  * Commands that publish a package to a registry.
2524
2537
  */
2525
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");
2526
- /**
2527
- * The variables a token for the registry is passed in.
2528
- */
2529
- const TOKEN_VARIABLES = ["NODE_AUTH_TOKEN", "NPM_TOKEN"];
2530
2539
  const PROVENANCE_FLAG = /--provenance(?:=true)?(?:\s|$)/u;
2531
2540
  /**
2532
2541
  * Whether the `package.json` in the working directory asks for provenance with `publishConfig.provenance: true`, which applies to every publish of that package.
@@ -2550,7 +2559,7 @@ function attestationViolations(workflow, job) {
2550
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.
2551
2560
  */
2552
2561
  function passesToken(workflow, job) {
2553
- 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] ?? "") !== ""));
2554
2563
  }
2555
2564
  function tokenlessPublishViolations(workflow, job, provenanceInManifest) {
2556
2565
  if (accessOf(effectivePermissions(workflow, job), "id-token") !== "write" || passesToken(workflow, job)) return [];
@@ -2558,9 +2567,9 @@ function tokenlessPublishViolations(workflow, job, provenanceInManifest) {
2558
2567
  const publishes = scriptCommands(step.run).filter((line) => PUBLISH_COMMAND.test(line));
2559
2568
  if (publishes.length === 0) return [];
2560
2569
  const env = effectiveEnv(workflow, job, step);
2561
- const blanked = TOKEN_VARIABLES.every((variable) => env[variable] === "");
2570
+ const blanked = REGISTRY_TOKEN_VARIABLES.every((variable) => env[variable] === "");
2562
2571
  const provenance = provenanceInManifest || publishes.some((line) => PROVENANCE_FLAG.test(line)) || env["NPM_CONFIG_PROVENANCE"] === "true";
2563
- 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)];
2564
2573
  });
2565
2574
  }
2566
2575
  /**
@@ -2578,7 +2587,6 @@ const workflowCredentials = async ({ cwd, options }) => {
2578
2587
  };
2579
2588
  //#endregion
2580
2589
  //#region src/checks/workflow-job-ordering.ts
2581
- const DEFAULT_RELEASE_JOBS = ["release"];
2582
2590
  const DEFAULT_DEPLOY_JOBS = ["deploy"];
2583
2591
  const DEFAULT_DOCS_DEPLOY_JOBS = ["docs-deploy"];
2584
2592
  const DEFAULT_BRANCH = "main";
@@ -2726,6 +2734,71 @@ const workflowMergeGroup = async ({ cwd, options }) => (await loadWorkflows(cwd,
2726
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)];
2727
2735
  });
2728
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
2729
2802
  //#region src/checks/workflow-repository-dispatch.ts
2730
2803
  const DEFAULT_BRANCHES = ["main", "master"];
2731
2804
  const DEFAULT_BRANCH_EXPRESSION = /\$\{\{\s*github\.event\.repository\.default_branch\s*\}\}/u;
@@ -3302,6 +3375,10 @@ const checks = zod.z.strictObject({
3302
3375
  }))),
3303
3376
  "workflow-version-single-source": zod.z.exactOptional(enabled(zod.z.strictObject(selection))),
3304
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
+ }))),
3305
3382
  "workflow-repository-dispatch": zod.z.exactOptional(enabled(zod.z.strictObject({
3306
3383
  ...selection,
3307
3384
  defaultBranches: zod.z.exactOptional(names),
@@ -3595,6 +3672,12 @@ const registry = {
3595
3672
  select: (checks) => enabledOptions(checks["workflow-credentials"]),
3596
3673
  check: workflowCredentials
3597
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
+ }),
3598
3681
  "workflow-repository-dispatch": plain({
3599
3682
  name: "workflow-repository-dispatch",
3600
3683
  description: "A repository_dispatch handler does not push to the default branch, auto-merge unguarded, or rely on the default token",
@@ -3902,6 +3985,7 @@ exports.workflowActionPinning = workflowActionPinning;
3902
3985
  exports.workflowCredentials = workflowCredentials;
3903
3986
  exports.workflowJobOrdering = workflowJobOrdering;
3904
3987
  exports.workflowMergeGroup = workflowMergeGroup;
3988
+ exports.workflowReleaseJob = workflowReleaseJob;
3905
3989
  exports.workflowRepositoryDispatch = workflowRepositoryDispatch;
3906
3990
  exports.workflowRunnerResolution = workflowRunnerResolution;
3907
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
  /**
@@ -667,6 +675,15 @@ type WorkflowVersionSingleSourceOptions = WorkflowSelection;
667
675
  * Options of the `workflow-credentials` check.
668
676
  */
669
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
+ }
670
687
  /**
671
688
  * Options of the `workflow-repository-dispatch` check: what a handler of `repository_dispatch` may do.
672
689
  */
@@ -781,6 +798,7 @@ interface CheckOptionsByName {
781
798
  readonly "workflow-runner-resolution": WorkflowRunnerResolutionOptions;
782
799
  readonly "workflow-version-single-source": WorkflowVersionSingleSourceOptions;
783
800
  readonly "workflow-credentials": WorkflowCredentialsOptions;
801
+ readonly "workflow-release-job": WorkflowReleaseJobOptions;
784
802
  readonly "workflow-repository-dispatch": WorkflowRepositoryDispatchOptions;
785
803
  readonly "workflow-update-bot-cooldown": WorkflowUpdateBotCooldownOptions;
786
804
  readonly "workflow-merge-group": WorkflowMergeGroupOptions;
@@ -1088,6 +1106,18 @@ export declare const workflowJobOrdering: CheckFunction<WorkflowJobOrderingOptio
1088
1106
  */
1089
1107
  export declare const workflowMergeGroup: CheckFunction<WorkflowMergeGroupOptions>;
1090
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
1091
1121
  //#region src/checks/workflow-repository-dispatch.d.ts
1092
1122
  /**
1093
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.
@@ -1356,4 +1386,4 @@ export declare function proveExhaustive(input: {
1356
1386
  readonly compilerOptions?: CompilerOptions;
1357
1387
  }): ExhaustivenessProof;
1358
1388
  //#endregion
1359
- 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, 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
  /**
@@ -667,6 +675,15 @@ type WorkflowVersionSingleSourceOptions = WorkflowSelection;
667
675
  * Options of the `workflow-credentials` check.
668
676
  */
669
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
+ }
670
687
  /**
671
688
  * Options of the `workflow-repository-dispatch` check: what a handler of `repository_dispatch` may do.
672
689
  */
@@ -781,6 +798,7 @@ interface CheckOptionsByName {
781
798
  readonly "workflow-runner-resolution": WorkflowRunnerResolutionOptions;
782
799
  readonly "workflow-version-single-source": WorkflowVersionSingleSourceOptions;
783
800
  readonly "workflow-credentials": WorkflowCredentialsOptions;
801
+ readonly "workflow-release-job": WorkflowReleaseJobOptions;
784
802
  readonly "workflow-repository-dispatch": WorkflowRepositoryDispatchOptions;
785
803
  readonly "workflow-update-bot-cooldown": WorkflowUpdateBotCooldownOptions;
786
804
  readonly "workflow-merge-group": WorkflowMergeGroupOptions;
@@ -1088,6 +1106,18 @@ export declare const workflowJobOrdering: CheckFunction<WorkflowJobOrderingOptio
1088
1106
  */
1089
1107
  export declare const workflowMergeGroup: CheckFunction<WorkflowMergeGroupOptions>;
1090
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
1091
1121
  //#region src/checks/workflow-repository-dispatch.d.ts
1092
1122
  /**
1093
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.
@@ -1356,4 +1386,4 @@ export declare function proveExhaustive(input: {
1356
1386
  readonly compilerOptions?: CompilerOptions;
1357
1387
  }): ExhaustivenessProof;
1358
1388
  //#endregion
1359
- 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, 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.js CHANGED
@@ -2127,6 +2127,9 @@ function runsOnOf(reader, value, what) {
2127
2127
  group
2128
2128
  };
2129
2129
  }
2130
+ function environmentOf(reader, value, what) {
2131
+ return isRecord(value) ? reader.text(value["name"], `${what}.name`) : reader.text(value, what);
2132
+ }
2130
2133
  function stepOf(reader, value, jobPath, index) {
2131
2134
  const path = [
2132
2135
  ...jobPath,
@@ -2159,6 +2162,7 @@ function jobOf(reader, id, value) {
2159
2162
  needs: reader.list(job["needs"], `${what}.needs`),
2160
2163
  condition: reader.text(job["if"], `${what}.if`),
2161
2164
  permissions: permissionsOf(reader, job["permissions"], `${what}.permissions`),
2165
+ environment: environmentOf(reader, job["environment"], `${what}.environment`),
2162
2166
  env: reader.texts(job["env"], `${what}.env`),
2163
2167
  runsOn: runsOnOf(reader, job["runs-on"], `${what}.runs-on`),
2164
2168
  timeoutMinutes: reader.text(job["timeout-minutes"], `${what}.timeout-minutes`),
@@ -2194,6 +2198,7 @@ function parseWorkflow(file, source) {
2194
2198
  triggers,
2195
2199
  triggerLocations: triggerLocationsOf(reader, triggers, root["on"]),
2196
2200
  permissions: permissionsOf(reader, root["permissions"], "permissions"),
2201
+ permissionsLocation: root["permissions"] === void 0 ? void 0 : reader.location(["permissions"]),
2197
2202
  env: reader.texts(root["env"], "env"),
2198
2203
  jobs: Object.entries(reader.record(root["jobs"], "jobs")).map(([id, job]) => jobOf(reader, id, job)),
2199
2204
  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`) ?? ""]))
@@ -2372,6 +2377,14 @@ function callsAlways(condition) {
2372
2377
  * The default ids of junction jobs, shared by the checks that look for one.
2373
2378
  */
2374
2379
  const DEFAULT_JUNCTION_JOBS = ["required-checks"];
2380
+ /**
2381
+ * The default ids of release jobs, shared by the checks that look for one.
2382
+ */
2383
+ const DEFAULT_RELEASE_JOBS = ["release"];
2384
+ /**
2385
+ * The variables a token for the npm registry is passed in.
2386
+ */
2387
+ const REGISTRY_TOKEN_VARIABLES = ["NODE_AUTH_TOKEN", "NPM_TOKEN"];
2375
2388
  //#endregion
2376
2389
  //#region src/checks/settings-required-checks.ts
2377
2390
  /**
@@ -2522,10 +2535,6 @@ const PACKAGE_MANAGER_OPTIONS = String.raw`(?:\s+(?:(?:--filter|-F|--workspace|-
2522
2535
  * Commands that publish a package to a registry.
2523
2536
  */
2524
2537
  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");
2525
- /**
2526
- * The variables a token for the registry is passed in.
2527
- */
2528
- const TOKEN_VARIABLES = ["NODE_AUTH_TOKEN", "NPM_TOKEN"];
2529
2538
  const PROVENANCE_FLAG = /--provenance(?:=true)?(?:\s|$)/u;
2530
2539
  /**
2531
2540
  * Whether the `package.json` in the working directory asks for provenance with `publishConfig.provenance: true`, which applies to every publish of that package.
@@ -2549,7 +2558,7 @@ function attestationViolations(workflow, job) {
2549
2558
  * 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.
2550
2559
  */
2551
2560
  function passesToken(workflow, job) {
2552
- return [void 0, ...job.steps].some((step) => TOKEN_VARIABLES.some((variable) => (effectiveEnv(workflow, job, step)[variable] ?? "") !== ""));
2561
+ return [void 0, ...job.steps].some((step) => REGISTRY_TOKEN_VARIABLES.some((variable) => (effectiveEnv(workflow, job, step)[variable] ?? "") !== ""));
2553
2562
  }
2554
2563
  function tokenlessPublishViolations(workflow, job, provenanceInManifest) {
2555
2564
  if (accessOf(effectivePermissions(workflow, job), "id-token") !== "write" || passesToken(workflow, job)) return [];
@@ -2557,9 +2566,9 @@ function tokenlessPublishViolations(workflow, job, provenanceInManifest) {
2557
2566
  const publishes = scriptCommands(step.run).filter((line) => PUBLISH_COMMAND.test(line));
2558
2567
  if (publishes.length === 0) return [];
2559
2568
  const env = effectiveEnv(workflow, job, step);
2560
- const blanked = TOKEN_VARIABLES.every((variable) => env[variable] === "");
2569
+ const blanked = REGISTRY_TOKEN_VARIABLES.every((variable) => env[variable] === "");
2561
2570
  const provenance = provenanceInManifest || publishes.some((line) => PROVENANCE_FLAG.test(line)) || env["NPM_CONFIG_PROVENANCE"] === "true";
2562
- 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)];
2571
+ 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)];
2563
2572
  });
2564
2573
  }
2565
2574
  /**
@@ -2577,7 +2586,6 @@ const workflowCredentials = async ({ cwd, options }) => {
2577
2586
  };
2578
2587
  //#endregion
2579
2588
  //#region src/checks/workflow-job-ordering.ts
2580
- const DEFAULT_RELEASE_JOBS = ["release"];
2581
2589
  const DEFAULT_DEPLOY_JOBS = ["deploy"];
2582
2590
  const DEFAULT_DOCS_DEPLOY_JOBS = ["docs-deploy"];
2583
2591
  const DEFAULT_BRANCH = "main";
@@ -2725,6 +2733,71 @@ const workflowMergeGroup = async ({ cwd, options }) => (await loadWorkflows(cwd,
2725
2733
  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)];
2726
2734
  });
2727
2735
  //#endregion
2736
+ //#region src/checks/workflow-release-job.ts
2737
+ const CHECKOUT_ACTION$1 = "actions/checkout";
2738
+ /**
2739
+ * A value that reads the `secrets` context inside an expression.
2740
+ */
2741
+ const FROM_SECRET = /\$\{\{[^}]*\bsecrets\./u;
2742
+ /**
2743
+ * The release jobs named for a message: `release job 'a'` or `release jobs 'a' and 'b'`.
2744
+ */
2745
+ function releaseJobsText(releases) {
2746
+ const ids = releases.map((job) => `'${job.id}'`);
2747
+ return `release job${ids.length === 1 ? "" : "s"} ${ids.join(" and ")}`;
2748
+ }
2749
+ function grantsIdToken(permissions) {
2750
+ return accessOf(permissions, "id-token") === "write";
2751
+ }
2752
+ function idTokenViolations(workflow, releases) {
2753
+ const releaseIds = new Set(releases.map((job) => job.id));
2754
+ const named = releaseJobsText(releases);
2755
+ 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)] : [];
2756
+ 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));
2757
+ return [...workflowLevel, ...jobLevel];
2758
+ }
2759
+ /**
2760
+ * What a checkout that does not set `persist-credentials: false` leaves in git config.
2761
+ */
2762
+ function persistedCredential(step) {
2763
+ if (step.with["ssh-key"] !== void 0) return "the SSH key";
2764
+ return step.with["token"] === void 0 ? "the job token" : "the token it was given";
2765
+ }
2766
+ function releaseJobViolations(workflow, job) {
2767
+ if (job.uses !== void 0) return [];
2768
+ 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)] : [];
2769
+ 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));
2770
+ return [...environment, ...checkouts];
2771
+ }
2772
+ /**
2773
+ * The token variables an environment sets from a secret.
2774
+ */
2775
+ function secretTokens(env) {
2776
+ return REGISTRY_TOKEN_VARIABLES.filter((variable) => FROM_SECRET.test(env[variable] ?? ""));
2777
+ }
2778
+ function tokenBesideOidcViolations(workflow, job) {
2779
+ if (!grantsIdToken(effectivePermissions(workflow, job))) return [];
2780
+ 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)];
2781
+ return [...report(secretTokens(effectiveEnv(workflow, job, void 0)), job.location), ...job.steps.flatMap((step) => report(secretTokens(step.env), step.location))];
2782
+ }
2783
+ /**
2784
+ * 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.
2785
+ *
2786
+ * 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.
2787
+ *
2788
+ * 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`.
2789
+ *
2790
+ * 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.
2791
+ */
2792
+ const workflowReleaseJob = async ({ cwd, options }) => {
2793
+ return (await loadWorkflows(cwd, options)).flatMap((workflow) => {
2794
+ const releases = jobsNamed(workflow, options.releaseJobs ?? DEFAULT_RELEASE_JOBS);
2795
+ const releaseFindings = releases.length === 0 ? [] : idTokenViolations(workflow, releases);
2796
+ const jobFindings = workflow.jobs.flatMap((job) => [...releases.includes(job) ? releaseJobViolations(workflow, job) : [], ...tokenBesideOidcViolations(workflow, job)]);
2797
+ return [...releaseFindings, ...jobFindings];
2798
+ });
2799
+ };
2800
+ //#endregion
2728
2801
  //#region src/checks/workflow-repository-dispatch.ts
2729
2802
  const DEFAULT_BRANCHES = ["main", "master"];
2730
2803
  const DEFAULT_BRANCH_EXPRESSION = /\$\{\{\s*github\.event\.repository\.default_branch\s*\}\}/u;
@@ -3301,6 +3374,10 @@ const checks = z.strictObject({
3301
3374
  }))),
3302
3375
  "workflow-version-single-source": z.exactOptional(enabled(z.strictObject(selection))),
3303
3376
  "workflow-credentials": z.exactOptional(enabled(z.strictObject(selection))),
3377
+ "workflow-release-job": z.exactOptional(enabled(z.strictObject({
3378
+ ...selection,
3379
+ releaseJobs: z.exactOptional(names)
3380
+ }))),
3304
3381
  "workflow-repository-dispatch": z.exactOptional(enabled(z.strictObject({
3305
3382
  ...selection,
3306
3383
  defaultBranches: z.exactOptional(names),
@@ -3594,6 +3671,12 @@ const registry = {
3594
3671
  select: (checks) => enabledOptions(checks["workflow-credentials"]),
3595
3672
  check: workflowCredentials
3596
3673
  }),
3674
+ "workflow-release-job": plain({
3675
+ name: "workflow-release-job",
3676
+ 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",
3677
+ select: (checks) => enabledOptions(checks["workflow-release-job"]),
3678
+ check: workflowReleaseJob
3679
+ }),
3597
3680
  "workflow-repository-dispatch": plain({
3598
3681
  name: "workflow-repository-dispatch",
3599
3682
  description: "A repository_dispatch handler does not push to the default branch, auto-merge unguarded, or rely on the default token",
@@ -3845,4 +3928,4 @@ function proveExhaustive(input) {
3845
3928
  }) };
3846
3929
  }
3847
3930
  //#endregion
3848
- export { ConformanceError, DEFAULT_DERIVED_TYPE_INFERENCES, DEFAULT_INFERENCES, DEFAULT_WORKFLOWS, EXIT_CODES, ExhaustivenessError, PRESET_COMMIT_TYPES, accessOf, aggregateMappers, checkNames, classifyPackages, codecPairs, commandTypes, commitTypes, conformanceSchema, conformanceSection, createGitHubClient, crossSliceRules, cycleRule, deployTools, derivedTypes, dockerfilePackageManager, effectiveEnv, effectivePermissions, eslint, generators, handlerCases, importCrossSlice, importCycles, importIsolatedGroups, importRankSkip, importUphill, instructionSymlinks, isCheckName, isolatedGroupRules, loadWorkflows, migrationsDirectory, packagesPattern, parseUses, parseWorkflow, proveExhaustive, rankSkipRules, registry, removeCase, runChecks, settingsMergeMethods, settingsRequiredChecks, settingsReviewThreadResolution, singleStorybook, transitiveNeeds, typeErrors, uphillRules, workflowActionPinning, workflowCredentials, workflowJobOrdering, workflowMergeGroup, workflowRepositoryDispatch, workflowRunnerResolution, workflowSkippableJobs, workflowUpdateBotCooldown, workflowVersionSingleSource };
3931
+ export { ConformanceError, DEFAULT_DERIVED_TYPE_INFERENCES, DEFAULT_INFERENCES, DEFAULT_WORKFLOWS, EXIT_CODES, ExhaustivenessError, PRESET_COMMIT_TYPES, accessOf, aggregateMappers, checkNames, classifyPackages, codecPairs, commandTypes, commitTypes, conformanceSchema, conformanceSection, createGitHubClient, crossSliceRules, cycleRule, deployTools, derivedTypes, dockerfilePackageManager, effectiveEnv, effectivePermissions, eslint, generators, handlerCases, importCrossSlice, importCycles, importIsolatedGroups, importRankSkip, importUphill, instructionSymlinks, isCheckName, isolatedGroupRules, loadWorkflows, migrationsDirectory, packagesPattern, parseUses, parseWorkflow, proveExhaustive, rankSkipRules, registry, removeCase, runChecks, settingsMergeMethods, settingsRequiredChecks, settingsReviewThreadResolution, singleStorybook, transitiveNeeds, typeErrors, uphillRules, workflowActionPinning, workflowCredentials, workflowJobOrdering, workflowMergeGroup, workflowReleaseJob, workflowRepositoryDispatch, workflowRunnerResolution, workflowSkippableJobs, workflowUpdateBotCooldown, workflowVersionSingleSource };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "workspace-conformance",
3
3
  "description": "Conformance checks for a pnpm workspace that ESLint cannot express: type-graph, import-graph and cross-file checks driven by the shared layout section of @exadev/config",
4
- "version": "1.5.0",
4
+ "version": "1.6.0",
5
5
  "bin": {
6
6
  "workspace-conformance": "./dist/cli.js"
7
7
  },