From b40cc077865e7b0f3759d66d247b03302cb7034f Mon Sep 17 00:00:00 2001 From: Leonid Svyatov Date: Fri, 7 Aug 2026 16:04:32 +0300 Subject: [PATCH] feat(publish)!: select one publication gate by default Release flows can combine forge approval with registry approval even when one enforced gate covers publication. This makes maintainers confirm the same release several times. The selected gate must cover the complete release flow. Existing extra gates remain until the maintainer accepts a migration. BREAKING CHANGE: R-PUB-02 now requires Maven Central credentials to become available only after a native forge publication gate. --- CHANGELOG.md | 11 +++++ skills/oss-audit/STANDARD.md | 4 +- skills/oss-audit/rule-sources.json | 17 +++++-- skills/oss-publish/SKILL.md | 14 +++++- .../references/ecosystems/containers.md | 2 +- .../references/ecosystems/crates.md | 2 +- .../oss-publish/references/ecosystems/hex.md | 4 +- .../references/ecosystems/maven-central.md | 25 +++++----- .../oss-publish/references/ecosystems/npm.md | 30 ++++++------ .../references/ecosystems/nuget.md | 2 +- .../references/ecosystems/pubdev.md | 4 +- .../oss-publish/references/ecosystems/pypi.md | 4 +- .../references/ecosystems/rubygems.md | 2 +- skills/oss-publish/sources.json | 3 +- tests/publishing-gates.test.ts | 46 +++++++++++++++++++ 15 files changed, 123 insertions(+), 47 deletions(-) create mode 100644 tests/publishing-gates.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 968b32a..886b3b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ The format is based on [Keep a Changelog 2.0.0](https://keepachangelog.com/en/2. ## [Unreleased] +### Changed + +- **Breaking:** R-PUB-02 requires Maven Central credentials to become available only after a native forge publication gate. A USER_MANAGED Portal stop alone no longer passes because the same user token can publish through the Portal API. +- `oss-publish` configures one publication gate by default. It uses npm staged approval for one package, a forge gate for packages released together, and a forge gate with automatic Maven Central publication. It preserves existing extra gates until the maintainer accepts a migration. +- GitLab release examples use one protected blocking manual job by default. Deployment approval rules remain an optional extra gate because GitLab also requires a manual job start after approval. + +### Fixed + +- npm staged publication examples use `./package/*.tgz`, so npm reads the values as local tarball paths. +- PyPI and pub.dev examples use the `release` environment name consistently. + ## [0.12.0] - 2026-08-04 ### Changed diff --git a/skills/oss-audit/STANDARD.md b/skills/oss-audit/STANDARD.md index 79c825f..044c67a 100644 --- a/skills/oss-audit/STANDARD.md +++ b/skills/oss-audit/STANDARD.md @@ -409,7 +409,7 @@ Forges: both A long-lived registry token in CI secrets is the single credential that turns any workflow compromise into a supply-chain compromise. Trusted publishing exchanges a short-lived OIDC token per run, so there is nothing to steal between releases. -Check: the publish job requests `id-token: write` and publishes through the registry's OIDC flow (npm trusted publishing, PyPI trusted publishers, RubyGems OIDC, crates.io trusted publishing). Where the registry's own publishing documentation names no OIDC flow at all, which is the case for Hex, a token scoped to the single package being published is below the bar and permitted, and the registry limitation is reported beside it. That is not the same as a registry documenting a flow this standard has not enumerated, which is unverified rather than absent: read the registry's documentation and report the rule unknown until the flow is checked, because the scoped-token fallback would otherwise pass a repository that could have used OIDC. Where the registry documents no credential narrower than the publishing account, which is the case for Maven Central, an account-scoped token is below the bar and permitted, reported beside the compensating controls that keep it below the bar rather than at it: an expiry set when the token is generated, revocation and replacement on compromise, namespace ownership verified against the account, and a publishing type that holds a validated deployment until a person releases it. Sonatype has announced namespace-scoped and artifact-scoped tokens without shipping them, so re-read the registry's token documentation before accepting this fallback rather than treating it as settled. A repository that publishes to no registry, shipping only built assets on a forge release, falls outside this rule rather than failing it. +Check: the publish job requests `id-token: write` and publishes through the registry's OIDC flow (npm trusted publishing, PyPI trusted publishers, RubyGems OIDC, crates.io trusted publishing). Where the registry's own publishing documentation names no OIDC flow at all, which is the case for Hex, a token scoped to the single package being published is below the bar and permitted, and the registry limitation is reported beside it. That is not the same as a registry documenting a flow this standard has not enumerated, which is unverified rather than absent: read the registry's documentation and report the rule unknown until the flow is checked, because the scoped-token fallback would otherwise pass a repository that could have used OIDC. Where the registry documents no credential narrower than the publishing account, which is the case for Maven Central, an account-scoped token is below the bar and permitted. Report the compensating controls that keep it below the bar rather than at it: an expiry set when the token is generated, revocation and replacement on compromise, namespace ownership verified against the account, and credentials available only to the forge job after its native publication gate passes. Sonatype has announced namespace-scoped and artifact-scoped tokens without shipping them, so re-read the registry's token documentation before accepting this fallback rather than treating it as settled. A repository that publishes to no registry, shipping only built assets on a forge release, falls outside this rule rather than failing it. Fixed by: oss-publish Forges: both @@ -427,7 +427,7 @@ Forges: both A registry publish cannot be undone. An approval gate is the last point where a compromised tag, a wrong version, or a bad artifact can be stopped. -Check: before public availability, a person other than an automation account must approve through a GitHub environment with required reviewers, a GitLab protected environment with a manual job and approval rules, or a registry proof-of-presence gate such as npm staged publishing with 2FA approval. Verify the configured gate through the forge or registry API. If the repository visibility or forge plan does not provide a native gate and the registry has no equivalent, report the rule as unmet rather than substituting an unverified approval action. +Check: before public availability, a person other than an automation account must approve through a GitHub environment with required reviewers, a GitLab protected environment with a blocking manual job or deployment approval rules, or a registry proof-of-presence gate such as npm staged publishing with 2FA approval. Verify the configured gate through the forge or registry API. If the repository visibility or forge plan does not provide a native gate and the registry has no equivalent, report the rule as unmet rather than substituting an unverified approval action. Fixed by: oss-publish Forges: both diff --git a/skills/oss-audit/rule-sources.json b/skills/oss-audit/rule-sources.json index 455865a..ca7a9f0 100644 --- a/skills/oss-audit/rule-sources.json +++ b/skills/oss-audit/rule-sources.json @@ -297,14 +297,18 @@ "https://github.com/hexpm/hexpm/blob/main/lib/hexpm_web/controllers/api/release_controller.ex", "https://central.sonatype.org/publish/generate-portal-token/", "https://central.sonatype.org/publish/publish-portal-api/", + "https://central.sonatype.org/publish/publish-portal-maven/", "https://central.sonatype.org/register/namespace/", "https://central.sonatype.org/news/20250326_ossrh_sunset/", + "https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments", + "https://docs.gitlab.com/ci/environments/protected_environments/", + "https://docs.gitlab.com/ci/jobs/job_control/", "https://github.com/hexpm/hex/blob/main/lib/mix/tasks/hex.user.ex", "https://github.com/hexpm/hex/blob/main/lib/hex/state.ex", "https://github.com/hexpm/hexpm/blob/main/lib/hexpm_web/controllers/dashboard/key_controller.ex" ], - "verified": "2026-07-31", - "note": "All four registries confirm id-token: write and short-lived tokens replacing long-lived ones; the RubyGems guide is explicit that the permission belongs at job level, workflow level discouraged. crates.io is client-rendered and returns only a shell to a fetcher, so it has to be read in a browser, not by fetching; read that way it is complete and shipped. It documents both GitHub and GitLab, GitLab still in public beta, tokens expiring after 30 minutes, and an initial publish that still requires an API token because the crate must already exist. The GitHub path is the rust-lang/crates-io-auth-action@v1 action; the GitLab path is a hand-rolled curl exchange against /api/v1/trusted_publishing/tokens with an id_tokens block, which is worth knowing before a check assumes an action exists on both. Hex is the checked negative, and it is why the Check now separates a registry that offers no flow from one this project has not verified. Its publish page names exactly one CI path, a long-lived HEX_API_KEY in an environment variable, and the hex client changelog through v2.5.2-dev mentions no OIDC, trusted publishing, or attestation, though it does add an OAuth device flow in v2.4.0. The scoped-token fallback is reachable there, but through the hex.pm dashboard rather than through the client: the hexpm release controller still authorizes :publish on domains [{\"api\", \"write\"}, \"package\"] gated by package_owner, so a key scoped package: publishes that one package and nothing else, and the Packages checkboxes on the /dashboard/keys create form are what issues one. Do not repeat the mix hex.user key generate command the hex.pm publish page gives: Hex 2.4.0 replaced password authentication with an OAuth device flow and removed that subcommand, lib/mix/tasks/hex.user.ex now declares @switches [] and dispatches whoami, auth, and deauth alone, and the publish page was not updated. Reported as hexpm/hexpm#1787 with the fix in #1788, and the correction was read from the client source on 2026-07-31 rather than from that page. HEX_API_KEY itself is unchanged and still read by the client as api_key in lib/hex/state.ex. hexpm/hexpm#1785, feat: trusted publisher, is open and would retire this whole clause for Hex. The three Hex sources were read on 2026-07-31; the four registry sources above were last read on the verified date. The account-scoped clause was added on 2026-07-31 for Maven Central, which is the second checked negative and a different one from Hex: Hex documents no OIDC flow but does scope a key to one package, while Sonatype documents neither. Its Central Portal authenticates with a user token carrying a display name and an expiry and nothing else, and the token-generation, API, namespace, and credential-management pages are all silent on scope, which is what makes the absence a reading rather than an inference. The compensating controls the clause names are Sonatype's own: an expiry set at generation, revocation and replacement documented as the compromise path, namespace ownership verified by DNS record or a forge-hosted repository, and USER_MANAGED as the default publishingType, which holds a validated deployment until a person releases it. The clause is written to expire: the OSSRH sunset roadmap of 2025-03-26 lists tokens 'scoped to specific namespaces, groupIds, or groupId and artifactId combinations' as planned, and two of that entry's three bullets have since shipped while the scope bullet has not. Re-read the token page before accepting the fallback, because a web search will report scoped tokens as available by restating that roadmap in the present tense." + "verified": "2026-08-07", + "note": "npm, PyPI, RubyGems, and crates.io document OIDC publishing with short-lived credentials. Hex documents no OIDC flow. Its dashboard can issue a key for one package, as confirmed in the hexpm release controller and key controller. Hex 2.4.0 removed mix hex.user key generate, although the publish page still names it. hexpm/hexpm#1785 would retire the Hex fallback if trusted publishing ships. Maven Central also documents no OIDC flow and no artifact-scoped or namespace-scoped token. Sonatype documents token expiry, revocation, namespace ownership, and autoPublish for CI. The Portal API uses the same user token for upload and for the publish endpoint. A USER_MANAGED deployment therefore provides an optional review stop, but it does not protect publication from CI that still holds that token. The compensating control requires the Central token and signing secret to become available only after a native forge gate. GitHub environment secrets become available after environment approval. GitLab protected environments can limit deployment variables and blocking manual jobs to authorized users. Sonatype's 2025 roadmap lists scoped tokens as planned. Re-read the token page before accepting this fallback." }, "R-PUB-03": { "sources": [ @@ -320,10 +324,13 @@ "R-PUB-04": { "sources": [ "https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments", - "https://docs.gitlab.com/ci/environments/deployment_approvals/" + "https://docs.gitlab.com/ci/environments/deployment_approvals/", + "https://docs.gitlab.com/ci/environments/protected_environments/", + "https://docs.gitlab.com/ci/jobs/job_control/", + "https://docs.npmjs.com/staged-publishing/" ], - "verified": "2026-07-28", - "note": "GitHub confirms required reviewers gate the job and that 'Prevent self-review' is a separate opt-in setting, which is why the rule says a person other than the triggering automation. GitLab's deployment approvals invert that default in the project's favour: 'All jobs deploying to the environment are blocked and wait for approvals before running', and the person who started the pipeline cannot approve their own deployment unless an administrator enables 'Allow pipeline triggerer to approve deployment'. npm staged publishing has no source named." + "verified": "2026-08-07", + "note": "GitHub confirms that required reviewers gate a job and that Prevent self-review is a separate setting. GitLab supports two native gates. A protected environment can restrict who runs a blocking manual deployment job. Deployment approval rules block the deployment, but an approved job still needs a separate manual start. npm staged publishing keeps a package unavailable until a person approves its stage with 2FA. Each stage has its own stage ID and approval command, so a release flow with several packages needs several npm approvals. R-PUB-04 requires at least one verified gate. Extra gates remain valid." }, "R-PUB-05": { "sources": [ diff --git a/skills/oss-publish/SKILL.md b/skills/oss-publish/SKILL.md index 31a089e..ddbfc6e 100644 --- a/skills/oss-publish/SKILL.md +++ b/skills/oss-publish/SKILL.md @@ -22,7 +22,9 @@ A registry's documentation is not proof that a command still exists. Before hand When a registry's documentation and its client disagree, that is a defect in the registry, not a puzzle to work around. Name which two sources disagree and what you ran to establish it. Do not improvise a substitute credential path, do not extract an undocumented on-disk token, and do not pin around the version where it last worked. Offer to report it upstream, and write the issue with `oss-writing`. -Present policy choices instead of guessing at them, even when the repository already contains a release workflow or a stored token. Which cooldown to use, whether to keep an existing publish step, and how much of an existing workflow to carry over are decisions the user makes. +Configure one publication gate by default. Additional gates remain valid, but they add another human action to every release. In an established release flow, preserve every configured gate, report the human actions it requires, and recommend the default path. Change those gates only after the maintainer accepts the migration. + +Present other policy choices instead of guessing at them, even when the repository already contains a release workflow or a stored token. Which cooldown to use, whether to keep an existing publish step, and how much of an existing workflow to carry over are decisions the user makes. ## The shape of a release workflow @@ -56,6 +58,8 @@ Detect what the repository ships, not what it merely contains. A manifest presen Collect, from the manifest and the repository, before changing anything: the package, gem, or crate name and version; the owner and repository, from the manifest's own repository or source metadata first, falling back to the git remote; repository visibility and forge plan, since approval protection is not available on every plan; whether the owner is an organization or a personal account; whether the package is already published, since an unpublished package may need a different first-release path; the existing tag format from `git tag --sort=-creatordate | head`, keeping whatever format is already in use; and any existing release workflow or pipeline, especially one referencing a stored registry token, which this skill's changes should remove. +Identify each release flow. One tag or release event can publish several packages when they version and ship together. Independently tagged packages are separate flows. Inventory every forge and registry approval each flow already requires before proposing a change. + Detect the forge the same way `oss-ci` does: look for `.github/workflows/` or `.gitlab-ci.yml`, check the git remote host, or ask directly if neither signal is present. If the user states the forge explicitly, trust that over any signal found in the repository. Route to the matching reference file now, using the table at the end of this file. If what the repository ships matches none of the eleven rows there, say so plainly: name the ecosystem found and state that this skill has no reference file for it, rather than improvising a publishing flow for a registry nobody has read the documentation of. @@ -74,7 +78,13 @@ Trigger the workflow on a version tag, matching the format found in Step 1. Befo ### Step 4: Gate on manual approval with two-factor authentication -Pin the publish job to a GitHub environment with required reviewers, or a GitLab protected environment with a manual job and approval rules, naming at least one approver who is not an automation account. Check availability before writing the workflow; the reference file for this registry names the plan each gate needs. Do not simulate a missing native gate with an unverified third-party approval action. Where the registry offers its own proof-of-presence gate, such as npm staged publishing with two-factor approval, use it as an additional gate and as the documented fallback when the forge plan has no native approval gate. For another registry on a plan without a native gate, report that R-PUB-04 cannot be satisfied until the repository visibility or plan changes. +Choose the last enforced publication gate that covers the complete release flow. A registry gate qualifies only when the CI identity cannot approve its own staged artifact. It must also approve every package in the flow with one human action. npm staged publishing meets both conditions for a flow containing one package. + +Where a registry gate qualifies, use it as the default. Keep any forge environment binding that forms part of the publisher identity, but configure no required reviewer there. Where no registry gate qualifies, pin the publish job to a GitHub environment with required reviewers. On GitLab, use a protected environment with one blocking manual job. Set `when: manual` and `allow_failure: false`, and restrict deployment permission to at least one person who is not an automation account. + +Check feature availability before writing the workflow. The ecosystem reference names the plan each gate needs. Do not simulate a missing native gate with an unverified third-party approval action. If the forge plan provides no native gate and the registry has no qualifying gate, report R-PUB-04 as unmet. + +Additional gates still satisfy R-PUB-04. If a release flow already has more than one, report each human action and suggest the one-gate default. Do not remove or weaken an established gate until the maintainer accepts that change. ### Step 5: Verify provenance after the first release diff --git a/skills/oss-publish/references/ecosystems/containers.md b/skills/oss-publish/references/ecosystems/containers.md index 245d768..af81b1e 100644 --- a/skills/oss-publish/references/ecosystems/containers.md +++ b/skills/oss-publish/references/ecosystems/containers.md @@ -146,7 +146,7 @@ A mutable rolling tag pushed on merge, `latest` or a branch name or a commit SHA A project whose only published image is a mutable rolling tag has no release to gate. Say that to the maintainer plainly: R-PUB-04 has nothing to attach to, and the honest report is not applicable rather than passed or failed. What such a project is missing is not an approval gate but a release, and that is a versioning question `oss-changelog` owns rather than a publishing one. -Where there is a versioned push, pin its job to `environment: release` as above, and create that environment at `https://github.com///settings/environments/new` with required reviewers naming at least one person other than an automation account. Required reviewers work for public repositories on current GitHub plans; private or internal repositories need GitHub Enterprise Cloud. On GitLab Premium or Ultimate, use a protected environment with approval rules. No container registry offers a proof-of-presence gate of its own, so report R-PUB-04 as unmet when the forge plan provides no native gate. +Where there is a versioned push, pin its job to `environment: release` as above, and create that environment at `https://github.com///settings/environments/new` with required reviewers naming at least one person other than an automation account. Required reviewers work for public repositories on current GitHub plans; private or internal repositories need GitHub Enterprise Cloud. On GitLab Premium or Ultimate, use one blocking manual job in a protected environment. No container registry offers a proof-of-presence gate of its own, so report R-PUB-04 as unmet when the forge plan provides no native gate. Create it with the API rather than the form. Reviewers and the tag policy are both settable, so nothing here needs a browser. diff --git a/skills/oss-publish/references/ecosystems/crates.md b/skills/oss-publish/references/ecosystems/crates.md index dd91aea..d9e3e04 100644 --- a/skills/oss-publish/references/ecosystems/crates.md +++ b/skills/oss-publish/references/ecosystems/crates.md @@ -144,7 +144,7 @@ If an existing workflow reads a `CARGO_REGISTRY_TOKEN` from repository secrets, ## Gate on manual approval (Step 4) -Pin the publish job to `environment: release` as above, and create that environment at `https://github.com///settings/environments/new` with required reviewers, or, on GitLab Premium or Ultimate, as a protected environment with approval rules. GitHub required reviewers work for public repositories on current plans; private or internal repositories need GitHub Enterprise Cloud. crates.io has no registry-side approval fallback, so report R-PUB-04 as unmet when the forge plan provides no native gate. +Pin the publish job to `environment: release` as above, and create that environment at `https://github.com///settings/environments/new` with required reviewers. On GitLab Premium or Ultimate, use one blocking manual job in a protected environment. GitHub required reviewers work for public repositories on current plans; private or internal repositories need GitHub Enterprise Cloud. crates.io has no registry-side approval fallback, so report R-PUB-04 as unmet when the forge plan provides no native gate. Create it with the API rather than the form. Reviewers and the tag policy are both settable, so nothing here needs a browser. diff --git a/skills/oss-publish/references/ecosystems/hex.md b/skills/oss-publish/references/ecosystems/hex.md index 4cdbb4b..0821d6a 100644 --- a/skills/oss-publish/references/ecosystems/hex.md +++ b/skills/oss-publish/references/ecosystems/hex.md @@ -116,13 +116,13 @@ The version check is a file test rather than a property query, because `mix hex. `mix hex.publish` also builds and publishes documentation by running `mix docs`, so the credentialed job runs the documentation toolchain too unless the project splits the two. `mix hex.publish package` publishes the package alone and `mix hex.publish docs` the documentation alone; splitting them lets the docs build run in an uncredentialed job and keeps the credentialed one to the package upload. Offer that split rather than assuming it, because it costs a second key or a second approval. -`oss-harden` pins every `uses:` line above to a commit SHA and sets the test job's minimal permissions. Cache nothing in either job: a restored build cache in a job that compiles dependencies and holds a publish key is the worst version of the exposure this section is about. On GitLab CI/CD the same two jobs apply, with `HEX_API_KEY` as a masked and protected variable and the publish job behind a protected environment with approval rules. +`oss-harden` pins every `uses:` line above to a commit SHA and sets the test job's minimal permissions. Cache nothing in either job: a restored build cache in a job that compiles dependencies and holds a publish key is the worst version of the exposure this section is about. On GitLab CI/CD the same two jobs apply, with `HEX_API_KEY` as a masked and protected variable and the publish job blocking and manual in a protected environment. If an existing workflow reads an account-wide `HEX_API_KEY` from repository secrets, replace it with the scoped key from Step 2 and tell the user to revoke the old one from the account's key list on hex.pm once the new flow is verified. ## Gate on manual approval (Step 4) -Pin the publish job to `environment: release` as above, and create that environment at `https://github.com///settings/environments/new` with required reviewers naming at least one person other than an automation account, and with `HEX_API_KEY` as an environment secret rather than a repository secret. Required reviewers work for public repositories on current GitHub plans; private or internal repositories need GitHub Enterprise Cloud. On GitLab Premium or Ultimate, use a protected environment with approval rules. +Pin the publish job to `environment: release` as above, and create that environment at `https://github.com///settings/environments/new` with required reviewers naming at least one person other than an automation account, and with `HEX_API_KEY` as an environment secret rather than a repository secret. Required reviewers work for public repositories on current GitHub plans; private or internal repositories need GitHub Enterprise Cloud. On GitLab Premium or Ultimate, use one blocking manual job in a protected environment. Create it with the API rather than the form. Reviewers and the tag policy are both settable, so nothing here needs a browser. diff --git a/skills/oss-publish/references/ecosystems/maven-central.md b/skills/oss-publish/references/ecosystems/maven-central.md index 54f7255..379c9e2 100644 --- a/skills/oss-publish/references/ecosystems/maven-central.md +++ b/skills/oss-publish/references/ecosystems/maven-central.md @@ -1,6 +1,6 @@ # Maven Central -Concrete flow for the decisions `SKILL.md` makes, for a component published to Maven Central through the Sonatype Central Portal. This is the one registry on the roster with no OIDC flow of any kind, so Step 2 below is a gap rather than a configuration, and R-PUB-02's account-scoped clause is what admits the fallback it gives instead. Everything else follows the usual shape, and the approval gate in Step 4 is unusually strong here because the Portal has a registry-side one of its own. +Concrete flow for the decisions `SKILL.md` makes, for a component published to Maven Central through the Sonatype Central Portal. This is the one registry on the roster with no OIDC flow of any kind, so Step 2 below is a gap rather than a configuration, and R-PUB-02's account-scoped clause is what admits the fallback it gives instead. Step 4 protects that credential with the forge publication gate. Two things about Maven Central shape every decision below. A release is immutable, and Sonatype's FAQ answers the question of whether a published component can be changed, modified, deleted, removed, or updated with a plain no. And publishing rights are namespace rights, verified against the account, so the credential's blast radius is the namespace rather than a single artifact. @@ -53,7 +53,7 @@ R-PUB-02's own clause admits an account-scoped token where the registry document - An expiry set when the token is generated, and a rotation date the project keeps. - Revocation and replacement on compromise, from the same user token page, which invalidates the old pair. - Namespace ownership verified against the account, so what the token can reach is bounded by what the account owns. -- A publishing type that holds a validated deployment until a person releases it, which is Step 4 below. +- The token and signing secrets available only to the publish job after the forge publication gate passes, which is Step 4 below. This sits below R-PUB-02's bar and stays there while Sonatype documents nothing narrower than the publishing account. It retires the day scoped tokens ship, or the day the Portal documents an OIDC flow. @@ -73,12 +73,13 @@ Central's file-level requirements decide what the build has to produce: a `.asc` true central - false + true + published ``` -Leave `autoPublish` at `false`. The Portal API's equivalent setting is `publishingType`, whose default `USER_MANAGED` holds a validated deployment at the `VALIDATED` state until a person publishes it, where `AUTOMATIC` proceeds to Maven Central on its own. Confirm the plugin's mapping against the plugin documentation before relying on it as the approval gate, because the gate is the whole reason for the setting. +Set `autoPublish` to `true` and `waitUntil` to `published`. Sonatype documents automatic publishing as the usual CI path, and the wait makes the job report the final registry result. The Portal API equivalent is `publishingType=AUTOMATIC`. ```yaml name: Release @@ -126,19 +127,19 @@ jobs: MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} ``` -`setup-java` writes the `settings.xml` server entry from the two environment variable names, so the credential never appears in a file the repository tracks. `server-id` must match `publishingServerId` in the plugin configuration exactly, and the pair the two secrets hold is the token username and password from Step 2, not a Sonatype account password. +`setup-java` writes the `settings.xml` server entry from the two environment variable names, so the credential never appears in a file the repository tracks. `server-id` must match `publishingServerId` in the plugin configuration exactly, and the pair the two secrets hold is the token username and password from Step 2, not a Sonatype account password. Store all three Central secrets and the GPG key as environment secrets on GitHub. `-DskipTests` in the publish job is deliberate: the tests ran in the uncredentialed job, and re-running them in the job holding the token and the signing key puts the project's whole test dependency tree inside the credentialed blast radius. `mvn deploy` still compiles and packages there, which is the residual this reference cannot remove, because the Maven plugin builds the bundle from a build in that same reactor. -`oss-harden` pins every `uses:` line above to a commit SHA and sets the test job's minimal permissions. On GitLab CI/CD the same `mvn deploy` runs unchanged: the credential path is identical because there is no OIDC on either forge. What differs is only where the two secrets live, which is a masked and protected CI/CD variable, and how the gate is configured, which is a protected environment with approval rules. +`oss-harden` pins every `uses:` line above to a commit SHA and sets the test job's minimal permissions. On GitLab CI/CD the same `mvn deploy` runs unchanged: the credential path is identical because there is no OIDC on either forge. Store each secret as a masked, protected variable scoped to `release`. Make the publish job blocking and manual, and restrict it through the protected environment. Neither `setup-java` block above sets `cache`, so nothing is restored. `oss-harden` owns dependency caching under R-CI-04, and a restored cache in a job that can sign and publish is the same cache-poisoning exposure every other reference in this directory refuses; leave the input off rather than setting it to a value. ## Gate on manual approval (Step 4) -Two gates apply together here, and Maven Central is unusual in having a real registry-side one. +Use the forge publication gate. The Central user token can upload a `USER_MANAGED` deployment and publish it through the Portal API. The registry stop does not block a compromised job that already holds that token. Sonatype also documents `autoPublish=true` as the usual CI configuration. -The workflow gate is `environment: release` on the publish job above, with required reviewers configured at `https://github.com///settings/environments/new`, or a GitLab protected environment with approval rules. Required reviewers work for public repositories on current GitHub plans; private or internal repositories need GitHub Enterprise Cloud. +On GitHub, keep `environment: release` on the publish job and configure required reviewers at `https://github.com///settings/environments/new`. Required reviewers work for public repositories on current GitHub plans; private or internal repositories need GitHub Enterprise Cloud. The Central token and signing secrets become available only after approval. Create it with the API rather than the form. Reviewers and the tag policy are both settable, so nothing here needs a browser. @@ -159,9 +160,11 @@ Three details decide whether that runs. `gh api` substitutes `{owner}` and `{rep `reviewers[][id]` takes a numeric user or team id rather than a login. A team needs `type=Team` and that team's id. -The registry gate is the `USER_MANAGED` publishing type. A deployment uploaded that way runs through validation, stops at the `VALIDATED` state, and waits for a person to publish it from the Portal interface or through a second authenticated call to `/api/v1/publisher/deployment/`. That is a genuine proof-of-presence gate in the sense R-PUB-04 asks for, and it survives a compromised workflow, because the workflow cannot approve itself without the token being used a second time by whoever is watching the Portal. +On GitLab Premium or Ultimate, protect `release` and make the publish job blocking and manual. Do not add deployment approval rules by default, because GitLab requires a separate manual job start after approval. Add them only when the maintainer requests a second human action. -Keep both. Where the forge plan provides no native gate, the Portal gate alone can satisfy R-PUB-04, which puts Maven Central in a better position than most of this directory. Say so plainly rather than reporting the rule unmet. What a maintainer must not do is set `autoPublish` to `true` to make the pipeline finish green, because that removes the only gate a compromised release workflow cannot walk through. +`USER_MANAGED` remains useful when the maintainer wants to inspect or test the validated deployment. Treat it as an optional second stop. It does not satisfy R-PUB-04 by itself because the CI token can call `/api/v1/publisher/deployment/`. + +If the forge plan provides no native gate, report R-PUB-02 and R-PUB-04 as unmet. The account-wide token would be available to a run no person approved. ## Verify provenance (Step 5) @@ -238,4 +241,4 @@ Do the namespace work before writing any of the workflow above, because a deploy Step 7 is in `SKILL.md`: read each R-PUB rule's `Check:` line against what this file produced, and fix what fails before reporting done. -Verified 2026-07-31 against [Central Portal, Publishing by using the Portal Publisher API](https://central.sonatype.org/publish/publish-portal-api/), [Central Portal, Publishing by using the Maven plugin](https://central.sonatype.org/publish/publish-portal-maven/), [Central Portal, Generate a portal token](https://central.sonatype.org/publish/generate-portal-token/), and [Central Portal, Requirements](https://central.sonatype.org/publish/requirements/). The OSSRH sunset and Sigstore announcements linked above were read the same day. +Verified 2026-08-07 against [Central Portal, Publishing by using the Portal Publisher API](https://central.sonatype.org/publish/publish-portal-api/), [Central Portal, Publishing by using the Maven plugin](https://central.sonatype.org/publish/publish-portal-maven/), [Central Portal, Generate a portal token](https://central.sonatype.org/publish/generate-portal-token/), and [Central Portal, Requirements](https://central.sonatype.org/publish/requirements/). The OSSRH sunset and Sigstore announcements linked above were read the same day. diff --git a/skills/oss-publish/references/ecosystems/npm.md b/skills/oss-publish/references/ecosystems/npm.md index e5e241e..63ede41 100644 --- a/skills/oss-publish/references/ecosystems/npm.md +++ b/skills/oss-publish/references/ecosystems/npm.md @@ -36,7 +36,7 @@ Enter: - Repository: the repository name from Step 1 - Workflow filename: `release.yml`, the filename only, not the full path - Environment: the approval environment name from `SKILL.md` Step 4, for example `release` -- Allowed actions: enable `npm stage publish` and leave `npm publish` disabled, so a compromised or bypassed CI run still cannot ship a version without the 2FA approval in Step 4 below +- Allowed actions: for one package in the release flow, enable `npm stage publish` and leave `npm publish` disabled. For several packages released together, enable `npm publish` and leave staged publishing disabled. Step 4 explains why the gate moves. In the workflow, the publish job needs: @@ -62,7 +62,7 @@ Enter, at `https://www.npmjs.com/package//access`: - Project name: the project name - Top-level CI file path: the path to the pipeline file, for example `.gitlab-ci.yml` - Environment name: the approval environment name from Step 4, for example `release` -- Allowed actions: enable `npm stage publish` only, matching GitHub Actions above +- Allowed actions: select staged or direct publishing from the release-flow rule above In the pipeline, the publish job needs: @@ -133,44 +133,42 @@ jobs: node-version: '24.18.0' registry-url: 'https://registry.npmjs.org' package-manager-cache: false - - run: npm stage publish package/*.tgz --ignore-scripts + - run: npm stage publish ./package/*.tgz --ignore-scripts ``` The version comparison assumes tags such as `v1.2.3`; derive the comparison from the repository's actual tag format. `npm pack` creates the exact tarball handed to `npm stage publish`, so the credentialed job does not check out source, install dependencies, or rebuild. `package-manager-cache: false` prevents `setup-node` from automatically restoring a package-manager cache in the credentialed job. -`oss-harden` pins every `uses:` line above to a commit SHA and sets the test and build jobs' minimal permissions, including the `contents: read` this skill left off them; do not pin them or narrow them here. This skill writes only the grants a job needs to authenticate, to publish, and to attest: the publish job's two above, and the github-release job's four in Step 6. On GitLab CI/CD, use separate test and build jobs, pass the resulting `.tgz` as an artifact, and make the publish job run only `npm stage publish package/*.tgz --ignore-scripts`. Give that job `environment: name: release` with `when: manual`, plus the `id_tokens` block from Step 2. +`oss-harden` pins every `uses:` line above to a commit SHA and sets the test and build jobs' minimal permissions, including the `contents: read` this skill left off them; do not pin them or narrow them here. This skill writes only the grants a job needs to authenticate, to publish, and to attest: the publish job's two above, and the github-release job's four in Step 6. On GitLab CI/CD, use separate test and build jobs, pass the resulting `.tgz` as an artifact, and make the publish job run only `npm stage publish ./package/*.tgz --ignore-scripts`. Give that job `environment: name: release` plus the `id_tokens` block from Step 2. Add `when: manual` only when Step 4 selects the forge gate. If an existing workflow uses `secrets.NPM_TOKEN`, remove it from the YAML now and tell the user to delete the corresponding secret and revoke the token once the new flow is verified. -Where a release tool already owns the release, keep it and fit the publish job inside it rather than adding a second release path. release-please, semantic-release, and changesets all work the same way: the workflow triggers on a merge to the default branch, the tool decides whether that merge is a release, and where it is, the tool creates the tag and the forge release and the publish runs behind it in the same job graph. That satisfies R-PUB-01 through the clause covering a run that creates the release tag itself, so do not rewrite it into a `push: tags` trigger to make the rule read more literally. Three things do change. The tool's own job outputs whether a release happened, and every job after it needs the condition, or the workflow publishes on every merge; the version check against the tag comes from the tool's manifest rather than from `GITHUB_REF_NAME`; and the environment gate belongs on the publish job exactly as above, because the tag is created by then and the approval has to sit between the tag and the registry rather than before the tag. +Where a release tool already owns the release, keep it and fit the publish job inside it rather than adding a second release path. release-please, semantic-release, and changesets all work the same way: the workflow triggers on a merge to the default branch, the tool decides whether that merge is a release, and where it is, the tool creates the tag and the forge release and the publish runs behind it in the same job graph. That satisfies R-PUB-01 through the clause covering a run that creates the release tag itself, so do not rewrite it into a `push: tags` trigger to make the rule read more literally. Three things do change. The tool's own job outputs whether a release happened, and every job after it needs the condition, or the workflow publishes on every merge; the version check against the tag comes from the tool's manifest rather than from `GITHUB_REF_NAME`; and any selected forge gate belongs on the publish job, after the tool creates the tag. ## Gate on manual approval (Step 4) -Two gates apply together, not as alternatives: +For one package, use npm staged publishing as the publication gate. Keep `environment: release` as part of the trusted-publisher identity and restrict it to version tags. Configure no required reviewer on the environment. -The workflow-level gate is the `environment: release` on the publish job above, with required reviewers configured at `https://github.com///settings/environments`, or, on GitLab Premium or Ultimate, a protected environment with approval rules at the project's Settings > CI/CD > Protected environments. GitHub required reviewers work for public repositories on current plans; private or internal repositories need GitHub Enterprise Cloud. If the forge plan lacks this gate, keep the environment binding because it is part of the npm trusted-publisher identity and rely on the registry gate below for R-PUB-04. - -Create it with the API rather than the form. Reviewers and the tag policy are both settable, so nothing here needs a browser. +Create a new environment and its tag policy with the API: ```sh ENV=release -GHUID=$(gh api user --jq .id) gh api -X PUT "repos/{owner}/{repo}/environments/$ENV" \ -F wait_timer=0 \ -F prevent_self_review=false \ - -f 'reviewers[][type]=User' -F "reviewers[][id]=$GHUID" \ -F 'deployment_branch_policy[protected_branches]=false' \ -F 'deployment_branch_policy[custom_branch_policies]=true' gh api -X POST "repos/{owner}/{repo}/environments/$ENV/deployment-branch-policies" \ -f 'name=v*' -f type=tag ``` -Three details decide whether that runs. `gh api` substitutes `{owner}` and `{repo}` from the checkout it runs in. Use `-F` for the booleans and the reviewer id, because `-f` sends every value as a string and the endpoint rejects a quoted boolean. Do not name the shell variable `UID`: zsh marks it read only, so the assignment fails before `gh` runs. - -`reviewers[][id]` takes a numeric user or team id rather than a login. A team needs `type=Team` and that team's id. +Two details decide whether that runs. `gh api` substitutes `{owner}` and `{repo}` from the checkout it runs in. Use `-F` for booleans because `-f` sends every value as a string and the endpoint rejects a quoted boolean. The registry-level gate is npm's staged publishing: the workflow runs `npm stage publish`, which uploads the package to a staging area without requiring 2FA, and a maintainer then runs `npm stage approve ` from the CLI or approves it on npmjs.com, which does require 2FA. Because the trusted publisher above only allows `npm stage publish` and not `npm publish`, no run of this workflow, compromised or not, can ship a version without that 2FA step. Also set publishing access at `https://www.npmjs.com/package//access` to "Require two-factor authentication and disallow tokens", which revokes any existing publish token; warn the user first if another automation still uses one. +For several packages released by one event, npm creates one stage and one approval per package. Use one forge gate for the complete flow. Configure every trusted publisher for `npm publish`, run one `npm publish ./package/.tgz --ignore-scripts` command per package, and disable staged publishing. On GitHub, add a required reviewer to `release`. On GitLab Premium or Ultimate, protect `release` and make the publish job blocking and manual. This keeps the release at one human action. + +If an established flow has both forge review and staged approval, preserve both until the maintainer accepts a migration. Report how many actions the flow requires and recommend the branch above that matches its package count. + Source: [npm Docs, Staged publishing for npm packages](https://docs.npmjs.com/staged-publishing/). ## Verify provenance (Step 5) @@ -249,7 +247,7 @@ A trusted publisher is configured on the package's npm settings page, which does ## Monorepo packages -Every public workspace package needs its own trusted publisher entry pointing at the same repository and workflow filename; a package left out stays unprotected. `npm stage` is unaware of workspaces. Pack each publishable workspace in the build job, then stage each resulting tarball explicitly, preferably in one approval-gated publish job per independently versioned package. Match each job's tag trigger and version check to that package. `npm sbom --workspace ` scopes Step 6's bill of materials to one of them. +Every public workspace package needs its own trusted publisher entry pointing at the same repository and workflow filename; a package left out stays unprotected. `npm stage` is unaware of workspaces. Independently tagged packages are separate release flows, so each can use staged publishing and one npm approval. Packages released together use direct publishing behind one forge gate, because staging requires one approval per package. Pack every package in the build job and publish each tarball explicitly. Match each independently versioned job's tag trigger and version check to that package. `npm sbom --workspace ` scopes Step 6's bill of materials to one of them. ## Why this file never reaches for a token @@ -261,4 +259,4 @@ Source: [npm classic token creation disabled](https://github.blog/changelog/2025 Step 7 is in `SKILL.md`: read each R-PUB rule's `Check:` line against what this file produced, and fix what fails before reporting done. -Verified 2026-07-31 against [npm Docs, Trusted publishing for npm packages](https://docs.npmjs.com/trusted-publishers/) and [npm Docs, Staged publishing for npm packages](https://docs.npmjs.com/staged-publishing/). The npm 12 floor, the malware-scan delay, and the token section were added on 2026-08-03 against [npm CLI v12.0.0](https://github.com/npm/cli/releases/tag/v12.0.0) and the three GitHub changelog entries cited beside them. +Verified 2026-08-07 against [npm Docs, Trusted publishing for npm packages](https://docs.npmjs.com/trusted-publishers/) and [npm Docs, Staged publishing for npm packages](https://docs.npmjs.com/staged-publishing/). The npm 12 floor, the malware-scan delay, and the token section were added on 2026-08-03 against [npm CLI v12.0.0](https://github.com/npm/cli/releases/tag/v12.0.0) and the three GitHub changelog entries cited beside them. diff --git a/skills/oss-publish/references/ecosystems/nuget.md b/skills/oss-publish/references/ecosystems/nuget.md index 231a29e..67d5ef7 100644 --- a/skills/oss-publish/references/ecosystems/nuget.md +++ b/skills/oss-publish/references/ecosystems/nuget.md @@ -72,7 +72,7 @@ Source: [Microsoft Learn, Trusted Publishing on nuget.org](https://learn.microso Where the documentation names no GitLab provider, this is the strongest alternative: -Create a scoped API key on nuget.org, from the API Keys page under the username menu. Select the narrowest scope that still publishes, which is push only new package versions where the package already exists, and set the glob pattern to the exact package ID rather than to `*`. Every key carries an expiry; set the shortest one that fits the release cadence, and note that the scope cannot be edited afterwards while the package list can. Store the key as a GitLab CI/CD variable that is both masked and protected, so it is redacted from job logs and reachable only from a protected branch or tag. Put the publish job behind a GitLab protected environment with approval rules, the same gate Step 4 uses elsewhere, so the key's presence in the pipeline is not on its own enough to publish. Register a code signing certificate and sign the package, so the uploaded bytes carry an identity the key alone does not give them. +Create a scoped API key on nuget.org, from the API Keys page under the username menu. Select the narrowest scope that still publishes, which is push only new package versions where the package already exists, and set the glob pattern to the exact package ID rather than to `*`. Every key carries an expiry; set the shortest one that fits the release cadence, and note that the scope cannot be edited afterwards while the package list can. Store the key as a GitLab CI/CD variable that is both masked and protected, so it is redacted from job logs and reachable only from a protected branch or tag. Make the publish job blocking and manual in a protected environment, so the key's presence in the pipeline is not on its own enough to publish. Register a code signing certificate and sign the package, so the uploaded bytes carry an identity the key alone does not give them. Source for the key scopes and the glob pattern: [Microsoft Learn, Scoped API keys](https://learn.microsoft.com/en-us/nuget/nuget-org/scoped-api-keys). diff --git a/skills/oss-publish/references/ecosystems/pubdev.md b/skills/oss-publish/references/ecosystems/pubdev.md index cdf3927..e578a18 100644 --- a/skills/oss-publish/references/ecosystems/pubdev.md +++ b/skills/oss-publish/references/ecosystems/pubdev.md @@ -65,7 +65,7 @@ gcloud auth print-identity-token --audiences=https://pub.dev | dart pub token ad dart pub publish --force ``` -The exported key is a long-lived credential and Dart's documentation says to treat it like a password, because anyone who reads it can publish the package. Store it as a GitLab CI/CD variable that is masked, protected, and scoped to a protected environment with approval rules, so the key is unreadable outside an approved release job. +The exported key is a long-lived credential and Dart's documentation says to treat it like a password, because anyone who reads it can publish the package. Store it as a GitLab CI/CD variable that is masked, protected, and scoped to a protected environment. Make the publish job blocking and manual, so the key is unreadable outside an approved release job. This is well below the bar R-PUB-02 sets: an exported service account key is a stored credential in exactly the sense the rule exists to eliminate, and it is account-shaped rather than package-shaped. It is also worse than the scoped-token fallbacks elsewhere in this directory, so say so plainly. Take it only while the page names no GitLab provider, and re-read that page before each release process is written. @@ -119,7 +119,7 @@ On the forge, create the environment at `https://github.com///setti Create it with the API rather than the form. Reviewers and the tag policy are both settable, so nothing here needs a browser. ```sh -ENV=pub.dev +ENV=release GHUID=$(gh api user --jq .id) gh api -X PUT "repos/{owner}/{repo}/environments/$ENV" \ -F wait_timer=0 \ diff --git a/skills/oss-publish/references/ecosystems/pypi.md b/skills/oss-publish/references/ecosystems/pypi.md index d5011c7..60808a2 100644 --- a/skills/oss-publish/references/ecosystems/pypi.md +++ b/skills/oss-publish/references/ecosystems/pypi.md @@ -136,14 +136,14 @@ If an existing workflow uses `secrets.PYPI_API_TOKEN`, remove it from the YAML n ## Gate on manual approval (Step 4) -Pin the publish job to `environment: release` as above, and create that environment at `https://github.com///settings/environments/new` with required reviewers, or, on GitLab Premium or Ultimate, as a protected environment with approval rules. GitHub required reviewers work for public repositories on current plans; private or internal repositories need GitHub Enterprise Cloud. If the repository's visibility or plan provides no native approval gate, report R-PUB-04 as unmet. PyPI has no registry-side approval fallback. +Pin the publish job to `environment: release` as above, and create that environment at `https://github.com///settings/environments/new` with required reviewers. On GitLab Premium or Ultimate, use one blocking manual job in a protected environment. GitHub required reviewers work for public repositories on current plans; private or internal repositories need GitHub Enterprise Cloud. If the repository's visibility or plan provides no native approval gate, report R-PUB-04 as unmet. PyPI has no registry-side approval fallback. PyPI does not require any particular environment name, and `pypi` is what its own examples use. This skill writes `release` for every ecosystem, so the same word means the same thing in every repository it touches. What matters is that the form's Environment name field and the job's `environment:` are the same string; where a repository already has a working publisher entry naming something else, keep it rather than renaming both to match this file. Create it with the API rather than the form. Reviewers and the tag policy are both settable, so nothing here needs a browser. ```sh -ENV=pypi +ENV=release GHUID=$(gh api user --jq .id) gh api -X PUT "repos/{owner}/{repo}/environments/$ENV" \ -F wait_timer=0 \ diff --git a/skills/oss-publish/references/ecosystems/rubygems.md b/skills/oss-publish/references/ecosystems/rubygems.md index 4fdba47..20b51d2 100644 --- a/skills/oss-publish/references/ecosystems/rubygems.md +++ b/skills/oss-publish/references/ecosystems/rubygems.md @@ -54,7 +54,7 @@ Source: [rubygems/rubygems.org discussion #4845, "trusted publishing with gitlab Where the guide names no GitLab provider, this is the strongest alternative: -Create a scoped API key at `https://rubygems.org/profile/api_keys/new`, restricted to the `Push rubygem` scope for this one gem only. The expiry field is a free datetime picker with a minimum of five minutes from the current time, not a list of preset durations; set it to the shortest value that still fits the release cadence. rubygems.org does not let the expiry be edited after creation, so plan to rotate it on that schedule. Store it as a GitLab CI/CD variable that is both masked and protected, so it is redacted from job logs and only available to pipelines running on a protected branch or tag. Put the publish job behind a GitLab protected environment with required approvers, the same gate Step 4 uses elsewhere, so the key's mere presence in the pipeline is not enough to publish. Sign the built gem: `gem push --attestation` needs the same OIDC token trusted publishing supplies, which is unavailable here, so use the older certificate-based signing instead, with `gem cert --build ` to create a signing key and `spec.signing_key` and `spec.cert_chain` in the gemspec to sign every build. +Create a scoped API key at `https://rubygems.org/profile/api_keys/new`, restricted to the `Push rubygem` scope for this one gem only. The expiry field is a free datetime picker with a minimum of five minutes from the current time, not a list of preset durations; set it to the shortest value that still fits the release cadence. rubygems.org does not let the expiry be edited after creation, so plan to rotate it on that schedule. Store it as a GitLab CI/CD variable that is both masked and protected, so it is redacted from job logs and only available to pipelines running on a protected branch or tag. Make the publish job blocking and manual in a protected environment, so the key's mere presence in the pipeline is not enough to publish. Sign the built gem: `gem push --attestation` needs the same OIDC token trusted publishing supplies, which is unavailable here, so use the older certificate-based signing instead, with `gem cert --build ` to create a signing key and `spec.signing_key` and `spec.cert_chain` in the gemspec to sign every build. This is below the bar R-PUB-02 sets, because a scoped, expiring key is still a credential that can leak, unlike a trusted publishing flow where nothing is ever stored; take it only while the guide names no GitLab provider, and re-read the guide before each release process is written. diff --git a/skills/oss-publish/sources.json b/skills/oss-publish/sources.json index eda4771..d323f3f 100644 --- a/skills/oss-publish/sources.json +++ b/skills/oss-publish/sources.json @@ -39,7 +39,8 @@ "The PyPI and crates.io GitLab publish jobs bind environment: name: beside their id_tokens: block, and say what that binding is and is not. Both registries compare only the environment claim's string, crates.io ignores the environment_protected claim GitLab also emits, and neither form binds a ref, so an unprotected environment leaves any pushable branch satisfying every claim the registry checks. Protected environments are GitLab Premium and Ultimate. The pypi.md and crates.md tag-to-version guards are now exact commands rather than angle-bracket placeholders, each carrying what to derive from the repository: PyPI's reads a static project.version and says where to compare instead when the version is dynamic, and crates.io's names the crate for workspaces and uses jq -e so an unmatched name fails the step. Recorded for the crates.io GitLab exchange script: sourcing it leaves set -eu applied to the rest of the script: block, errexit is kept deliberately and nounset is turned off at the end rather than in a subshell the token could not escape, and --fail-with-body needs curl 7.76 or later. references/rubygems.md names the separated build-and-publish workflow as the default and keeps rubygems/release-gem as the escape hatch, with the exposure that comes from running the project's full bundle in the credentialed job stated where it is offered. No claim in the four references carries a timestamp any more; each names the documentation to re-read instead, because a date in a file a reader copies from is a fact that expires without telling anybody. Each tool a reader has to supply is named where its command first appears: pypi-attestations with the install command its own README publishes, gh installed and authenticated for gh attestation verify, and the npm CLI on the maintainer's own machine for the provenance check. The plan limits on native approval gates live in the four references only, because a run reads one of them. All four references carry a Step 6 section, so every step of the process has reference support; upstream stops at publishing. Each puts the release assets in a third job that needs: [publish], carrying contents: write, id-token: write, attestations: write, and artifact-metadata: write, because the build job runs the project's own build command and the publish job holds the registry credential, so neither can hold release-asset writes without breaking the split the earlier entries describe. Read from actions/attest and the npm CLI documentation on 2026-07-30: actions/attest@v4 is what upstream tells new implementations to use, since actions/attest-build-provenance v4 is a wrapper over it; subject-checksums makes every file listed in a sha256sum manifest a subject in its own right, so a consumer verifies an asset rather than the manifest beside it; sbom-path produces an SBOM attestation a consumer reads with --predicate-type https://cyclonedx.org/bom; npm sbom omits development dependencies only when NODE_ENV is production, so --omit=dev is written explicitly; and gh release upload fails when no release exists for the tag. Only npm generates its own bill of materials, so no other reference names a third-party SBOM generator, leaving that choice to the maintainer.", "The reference files now live in references/ecosystems/, one per entry in skills/oss-audit/ecosystems.json, and there are eleven of them rather than four: npm, PyPI, RubyGems, crates.io, Go modules, Packagist, NuGet, Maven Central, Hex, pub.dev, and container images. Upstream covered npm alone. Every file carries the same six-step template and every step, so a step an ecosystem has no answer for states the gap and why rather than being left out: what was read and where it is silent, a source link to that reading, the strongest documented fallback, and what would retire the gap. Each file's last line records the date its claims were checked against the sources it names. Three of the eleven differ from the credential-and-upload shape upstream assumed. Go modules and Packagist run on the tag-published track, where pushing a git tag is the publish and no publishing credential exists at all, so their credential-shaped steps are gaps with the track as the reason and R-SEC-13's restricted tag creation named as what stands in for the approval gate. Maven Central has no OIDC flow of any kind, so its Step 2 carries the account-scoped Sonatype user token as the below-the-bar fallback with the four compensating controls reported beside it, and its Step 5 carries Sigstore as announced future blocking rather than current behaviour. Hex has no OIDC either and its publish command rebuilds, so build, attestation, and publish collapse into the one job holding the key; that file names the residual this creates, that mix runs dependency code at compile time through macros, and the four controls that bound it. Container images carry two positions the other ten do not need: a mutable rolling tag is a build artifact rather than a release, so the approval gate covers the version-tagged digest-identified push and a project publishing only a rolling tag has no release to gate; and R-SEC-13 binds git tags only, so a registry tag stays mutable whatever the forge is configured to do and a released image's immutable identity is its digest. Containers are also the only entry needing no third-party SBOM generator, because BuildKit attaches an SPDX bill of materials to the image itself.", "Every ecosystem reference that needs a GitHub deployment environment now gives the gh api calls beside the settings URL: the environments PUT carrying reviewers, and the deployment-branch-policies POST. Upstream and the earlier form of these files named the browser form alone, which left the whole approval gate as manual work. Three details are recorded because a run hit all three: gh api substitutes {owner} and {repo} from the checkout, the booleans and the reviewer id need -F because -f sends every value as a string, and naming the shell variable UID fails under zsh, which marks it read only. go-modules and packagist carry nothing, because the tag-published track has no publish credential and no environment. Five correctness defects found by running the kit against three repositories were closed on 2026-07-31, and each is a place a template contradicted a rule the kit itself owns. The rubygems, hex, crates, and pubdev release templates now use their package manager's frozen install mode, because a bare bundle install, mix deps.get, cargo build, or dart pub get in a workflow this skill writes fails R-SEC-08 and an audit run afterwards scores it. The eight references that name no SBOM generator now answer R-PUB-05 with the forge's own SPDX export at GET /repos/{owner}/{repo}/dependency-graph/sbom, written beside the assets so it is attested with them, with its two limits stated: GitHub only, and the repository's declared graph rather than the asset's linked contents. Each of those files also says outright that a hash manifest answers R-PUB-06 and not R-PUB-05, which is the conflation that made one run report the rule as passing when it failed. hex.md's Step 2 no longer gives mix hex.user key generate: Hex 2.4.0 removed the subcommand with the OAuth device flow that replaced password authentication, hex.pm's publishing guide still documents it, and the corrected flow is the /dashboard/keys form with a package-scoped permission, verified from the client and hexpm source rather than from that page. rubygems.md drops the claim that release-gem pins a sigstore-cli version, which it does not, and adds the registry propagation wait before verifying an install. SKILL.md's Step 2 hands the trusted publisher form over at the start with every field filled from the reference rather than blocking Step 3 on values Steps 3 and 4 produce, and moves the blocking confirmation to the first tag push. Its Principles carry the rule the hex bug is the case for, that a registry's documentation is not proof a command still exists and the flags are confirmed against the client, and what to do when the two disagree. Its Scope now states the reciprocal of the pinning hand-off: where oss-harden has already run, this skill pins what it adds rather than handing off to a skill with no turn left. SKILL.md states one release workflow shape for all eleven ecosystems, which upstream had no reason to and which the four-reference form did not need: the file is release.yml, the workflow is Release, the trigger is a v* tag, the environment is release, and the jobs come from test, build, publish, and github-release. The eleven references conform to it and stop restating it. That closed four divergences among them: nuget's pack job is build, pypi's pypi and pubdev's pub.dev environments are release, pubdev's three-part tag pattern is v*, which no longer drops a prerelease tag pub.dev itself accepts, and the release job is github-release everywhere, so release names the file, the trigger, and the environment and not also a job. It is a convention rather than a rule, and STANDARD.md scores no job name, because failing a repository over a word buys nothing. Three departures are recorded where they apply rather than left to read as drift: the tag-published track keeps its job in the CI workflow because pushing the tag is the publish, Hex has no build job because mix hex.publish accepts no prebuilt tarball path, and containers has no test job because an image packages something the repository already tests. The rubygems release job calls oss-changelog's release-notes.mjs, copied to .github/scripts/, rather than the awk expression that stood there while the script did not exist.", - "npm.md tracks npm 12, which upstream has no reason to and which the registry itself now forces. It states the Node range npm 12 requires so a job that pins an older patch inside a supported line is caught before it runs, it says a newly published version is not installable while the publish-time malware scan runs and that a release verifying its own package has to tolerate that delay, and it carries a section arguing that npm has no token path left rather than a slower one: classic tokens were revoked in December 2025, granular tokens expire within 90 days, and a 2FA-bypass token loses direct publish around January 2027. That last part exists because R-PUB-02 permits a scoped token where a registry documents no OIDC flow, and a reader holding a working NPM_TOKEN would otherwise read that clause as covering them." + "npm.md tracks npm 12, which upstream has no reason to and which the registry itself now forces. It states the Node range npm 12 requires so a job that pins an older patch inside a supported line is caught before it runs, it says a newly published version is not installable while the publish-time malware scan runs and that a release verifying its own package has to tolerate that delay, and it carries a section arguing that npm has no token path left rather than a slower one: classic tokens were revoked in December 2025, granular tokens expire within 90 days, and a 2FA-bypass token loses direct publish around January 2027. That last part exists because R-PUB-02 permits a scoped token where a registry documents no OIDC flow, and a reader holding a working NPM_TOKEN would otherwise read that clause as covering them.", + "The release process configures one human publication gate by default and preserves existing extra gates until the maintainer accepts a migration. One npm package uses staged publishing as the last gate. Several npm packages released together use one forge gate and direct publishing because npm approves each staged package separately. npm tarball paths start with ./ so the CLI reads them as files. Maven Central uses a forge gate and autoPublish because the same Central token can upload and publish a USER_MANAGED deployment through the Portal API. GitLab uses a protected blocking manual job by default because deployment approval rules add a second manual start." ], "license": "MIT", "copyright": [ diff --git a/tests/publishing-gates.test.ts b/tests/publishing-gates.test.ts new file mode 100644 index 0000000..edd2e7c --- /dev/null +++ b/tests/publishing-gates.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "bun:test" +import { readFileSync } from "node:fs" + +const read = (path: string) => readFileSync(path, "utf8") + +test("oss-publish defines one default publication gate", () => { + const skill = read("skills/oss-publish/SKILL.md") + + expect(skill).toContain("Configure one publication gate by default") + expect(skill).toContain("preserve every configured gate") + expect(skill).toContain("`allow_failure: false`") + expect(skill).not.toContain("use it as an additional gate") +}) + +test("npm publish commands use explicit artifact paths", () => { + const reference = read("skills/oss-publish/references/ecosystems/npm.md") + const commands = reference.split("\n").filter((line) => line.includes("npm stage publish") && line.includes(".tgz")) + + expect(commands.length).toBeGreaterThan(0) + for (const command of commands) expect(command, command).toContain("./package/") +}) + +test("npm and Maven Central do not require duplicate gates", () => { + const npm = read("skills/oss-publish/references/ecosystems/npm.md") + const maven = read("skills/oss-publish/references/ecosystems/maven-central.md") + + expect(npm).not.toContain("Two gates apply together") + expect(maven).not.toContain("Two gates apply together") + expect(maven).toContain("true") + expect(maven).not.toContain("false") +}) + +test("publishing examples use the release environment name", () => { + const pypi = read("skills/oss-publish/references/ecosystems/pypi.md") + const pubdev = read("skills/oss-publish/references/ecosystems/pubdev.md") + + expect(pypi).not.toContain("ENV=pypi") + expect(pubdev).not.toContain("ENV=pub.dev") +}) + +test("R-PUB-04 accepts one protected GitLab manual job", () => { + const standard = read("skills/oss-audit/STANDARD.md") + + expect(standard).toContain("a GitLab protected environment with a blocking manual job") + expect(standard).not.toContain("a GitLab protected environment with a manual job and approval rules") +})