diff --git a/.github/workflows/release-vale.yml b/.github/workflows/release-vale.yml index 8f16317d..a516adac 100644 --- a/.github/workflows/release-vale.yml +++ b/.github/workflows/release-vale.yml @@ -58,13 +58,34 @@ # tarball through the artifact keeps 0755 intact end to end. # # PUBLISHING IDENTITY is inherited, not reinvented: npm trusted publishing, a -# short-lived OIDC-minted token bound to the `npm-production` environment, with -# no stored NPM_TOKEN anywhere. That binding is registered PER PACKAGE on -# npmjs.com and there is nothing to bind until the package name exists, so the -# FIRST publish of each of the six names is a deliberate one-time manual step by -# a maintainer, who then registers the trusted publisher. This workflow assumes -# that has already happened for every name in the manifest, and there is no -# fallback token path here on purpose. +# short-lived OIDC-minted token, with no stored NPM_TOKEN anywhere. That binding +# is registered PER PACKAGE on npmjs.com, and it names this workflow's FILENAME +# and its environment — so renaming this file or changing `environment:` below +# breaks every publish until the six bindings are re-registered to match. There +# is nothing to bind until the package name exists, so the FIRST publish of each +# of the six names is a deliberate one-time manual step by a maintainer, who +# then registers the trusted publisher. This workflow assumes that has already +# happened for every name in the manifest, and there is no fallback token path +# here on purpose. +# +# APPROVAL POLICY: the environment is `npm-autopublish`, which has NO required +# reviewer — unlike `npm-production`, where @taskless/cli still waits for a +# click. That is not a relaxation, because the review already happened +# somewhere better: the manifest-update pull request IS the gate. A human reads +# the upstream Vale version and all six SHA256 digests there, and only merging +# it can start a publish at all. An environment approval would be a second copy +# of that same gate, asked at a point where nothing is left to decide — the +# bytes were fixed when the digests were reviewed, and a reviewer standing at +# the publish step has no new information to act on. Approvals that decide +# nothing get clicked without being read, which weakens the one that matters. +# +# What still bounds an unattended publish: the deployment branch policy on +# `npm-autopublish` restricts it to `main`, so no branch can reach the credential +# by adding a job that names the environment; and publishing a platform package +# changes no consumer, because the CLI pins each one exactly and a new version +# reaches a user only when someone reviews a bump to that pin. Do not "simplify" +# this back onto `npm-production` to make the two release workflows match — they +# differ on purpose, and the reason is the pin, not the package. # # BOOTSTRAPPING A NEW PACKAGE NAME, once: # @@ -269,10 +290,39 @@ jobs: publish: name: Publish to npm needs: prepare + # DELIBERATE DUPLICATE of the `npm-autopublish` deployment branch policy. + # The duplication IS the point: two independent controls, one of which lives + # in a settings page and one of which lives here, in a file that cannot + # change without code review. Do not delete this as redundant with the + # environment — the environment is exactly what it is defending against. + # + # This became load-bearing when the required reviewer went away. Under + # `npm-production` an off-`main` publish had to get past the branch policy + # AND a human clicking approve; with the reviewer gone the branch policy is + # the only thing left, and a settings edit by someone who does not know it + # is load-bearing silently enables publishing from any branch. That failure + # is an ABSENCE, so nothing goes red when it happens. + # + # One expression covers both ways this job can be reached. The `push` path + # is already `branches: [main]`, so the guard is a no-op there and costs + # nothing. The `workflow_dispatch` path is the real case: a dispatch runs + # from whatever ref it was launched on, and `github.ref` is that ref. + # + # Only `publish` is guarded, on purpose. `gate` and `prepare` hold no + # environment, no id-token, and only `contents: read`, so running them off + # `main` publishes nothing — it is a useful dry run of the digest + # verification and the pack. Guarding them instead would protect this job + # only by inference through `needs:`, which is one control with a spare + # rather than two controls. + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest # The scoping and audit boundary for the release, and where the npm trusted - # publisher for each @taskless/vale-* package is bound. - environment: npm-production + # publisher for each @taskless/vale-* package is bound. Reviewer-free by + # design (see APPROVAL POLICY in the header): the manifest pull request is + # the gate, and this environment's branch policy still confines the + # credential to `main`. Changing this name requires re-registering all six + # bindings on npmjs.com first — the environment is part of the binding. + environment: npm-autopublish permissions: contents: read id-token: write # OIDC → short-lived npm auth + build provenance diff --git a/openspec/specs/vale-binary-packages/spec.md b/openspec/specs/vale-binary-packages/spec.md index fb0966f4..bb4fe4a0 100644 --- a/openspec/specs/vale-binary-packages/spec.md +++ b/openspec/specs/vale-binary-packages/spec.md @@ -91,6 +91,8 @@ Platform packages SHALL be versioned and published by a workflow dedicated to th A published-version check cannot bound these runs — every run stamps a previously unused timestamp — so the upstream comparison SHALL be what prevents redundant publishing. +That workflow SHALL publish without a human approval step, using the reviewer-free publishing environment. Its review gate is the manifest-update pull request, where a human reviews the upstream version and every checksum before anything may be published; an environment approval would be a second copy of a gate that already exists, at a point where nothing is being decided. The publish remains bounded by the branch policy on that environment and by the requirement below that a published platform package changes no consumer. Because removing the approval leaves the branch restriction as the only control over where a publish may run from, that restriction SHALL be enforced twice — once by the environment's configuration and once by the workflow itself, so that neither can be relaxed without the other noticing. + #### Scenario: Upstream unchanged publishes nothing - **WHEN** the workflow runs and the latest upstream Vale release is already published as a platform package @@ -106,6 +108,17 @@ A published-version check cannot bound these runs — every run stamps a previou - **WHEN** that pull request is merged - **THEN** every supported platform package is stamped with the same version and published together, verified against the checksums that were just reviewed +#### Scenario: Publishing needs no approval click + +- **WHEN** the publish phase runs after a merged manifest update +- **THEN** it SHALL proceed without waiting for an environment approval +- **AND** it SHALL run in the reviewer-free publishing environment, restricted to the default branch + +#### Scenario: A publish requested from another branch does nothing + +- **WHEN** a publish is requested manually from a ref other than the default branch +- **THEN** no package is published, and this SHALL hold independently of the publishing environment's configuration + #### Scenario: Ordinary pushes do not publish platform packages - **WHEN** a commit is pushed to the default branch