diff --git a/.github/workflows/javascript-build.yml b/.github/workflows/javascript-build.yml index 6762e32a..885c62ad 100644 --- a/.github/workflows/javascript-build.yml +++ b/.github/workflows/javascript-build.yml @@ -136,8 +136,8 @@ jobs: working-directory: Source run: yarn test-storybook - # Builds and retains source-candidate archives and SBOM evidence only. This job has - # no publication authority and its artifact is not npm trusted-publisher provenance. + # Builds and retains exact archives and SBOM evidence. This job is read-only; publication + # authority remains isolated to the Publish workflow's OIDC-enabled npm job. release-evidence: runs-on: ubuntu-latest timeout-minutes: 20 @@ -168,15 +168,15 @@ jobs: - name: Test release-evidence generator run: yarn test-release-evidence - - name: Generate source-candidate release evidence + - name: Generate release evidence run: >- yarn generate-release-evidence --output "${{ runner.temp }}/components-v4-release-evidence" - - name: Upload source-candidate release evidence + - name: Upload release evidence uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: components-v4-source-candidate-${{ github.sha }} + name: components-v4-release-evidence-${{ github.sha }} path: | ${{ runner.temp }}/components-v4-release-evidence/*.tgz ${{ runner.temp }}/components-v4-release-evidence/SHA256SUMS diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a6f5233b..d66e44ef 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,70 +1,166 @@ name: Publish -# V4 decision: publication is fail-closed for this draft source line. This workflow is a manual, -# side-effect-free entry point only - it explains why publishing is blocked and exits non-zero. It -# intentionally does not build, does not release, does not publish, and does not dispatch anything -# downstream. -# -# There is no `push` trigger: nothing on this branch auto-publishes on merge. There is no -# `id-token` permission, no `contents: write` permission, and no `secrets` usage - this workflow -# cannot mint an OIDC token, cannot push a tag/release, and cannot reach any credential. It does -# not call `npm publish`, `yarn publish-version`, `cratis/release-action`, or -# `peter-evans/repository-dispatch` (or any other action with side effects). -# -# V4 publication stays blocked until all of the following exist and are reviewed: -# - the approved manual, tooling-first candidate publish workflow (built, reviewed, and staged -# specifically for a V4 release, not restored wholesale from main's automatic publisher) -# - trusted-publisher / OIDC bootstrap configured and verified against the npm registry for -# every package this workspace ships -# - an approved release workflow that installs with `yarn install --immutable` from the -# committed root `yarn.lock`, with local development honoring the same lockfile -# - a package set synchronized across every workspace, including any renderer packages not yet -# published -# - retained exact tarballs and checksums for whatever gets published, so a release can be -# verified after the fact -# - explicit owner approval to re-enable publication for this source line -# -# Do not restore a `push` trigger, `id-token: write`, `contents: write`, secrets, release -# creation, `npm publish`/`yarn publish-version`, or any dispatch side effect to this workflow -# without that review. See git history ("Restore the publish workflow", "Disable Components -# package publishing", "Remove unverified provenance surfaces") for why this line remains manual -# and blocked. - on: - workflow_dispatch: {} + workflow_dispatch: + inputs: + version: + description: 'Version to release' + required: true + default: '0.0.0' + type: string + release-notes: + description: 'Release notes' + required: true + default: 'No release notes' + type: string + logLevel: + description: 'Log level' + required: true + default: 'warning' + type: choice + options: + - info + - warning + - debug + # Releasing on push rather than on the pull_request closed event is deliberate. A pull request from a fork + # runs with a read-only GITHUB_TOKEN and no secrets even on merge, so it cannot create the release or reach + # the publishing credentials - which is how a merged, labeled fork contribution silently released nothing. + # A push to main always runs with a full-permission token, and the release action finds the merged pull + # request and its label from the commit. + push: + # Only main releases. On "**" every base branch released, so merging one pull + # request into another one's branch - the ordinary way to stack work - cut and published a version + # from a branch that was still in review. That is how Cratis.Fundamentals v7.17.0 came to be + # published from the head of an open pull request, carrying every unmerged change on that branch + # under release notes describing only the one that had just merged. A release must come from the + # branch that is released. + branches: + - main -permissions: {} +permissions: + contents: read jobs: - publication-blocked: + release: + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: write + outputs: + version: ${{ steps.release.outputs.version }} + publish: ${{ steps.release.outputs.should-publish }} + reason: ${{ steps.release.outputs.reason }} + + steps: + - name: Checkout code + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Release + id: release + uses: cratis/release-action@bdaded342eb31b52b48dca0611f0214794f8c655 # v1 + with: + version: ${{ github.event.inputs.version }} + release-notes: ${{ github.event.inputs.release-notes }} + + publish-npm-packages: + if: needs.release.outputs.publish == 'true' + runs-on: ubuntu-latest + timeout-minutes: 60 + needs: [release] + permissions: + contents: read + id-token: write + + steps: + - name: Checkout code + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Setup node + uses: actions/setup-node@49933ea5288ca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 23.x + registry-url: 'https://registry.npmjs.org' + + - name: Configure npm for OIDC-based publishing + run: | + # setup-node writes a placeholder _authToken (XXXXX-XXXXX-XXXXX-XXXXX) into + # .npmrc and exports NODE_AUTH_TOKEN with the same placeholder. npm uses this + # token as-is for registry auth → 404. Remove it so npm falls back to OIDC. + sed -i '/_authToken/d' "$NPM_CONFIG_USERCONFIG" + echo "NODE_AUTH_TOKEN=" >> "$GITHUB_ENV" + echo "--- .npmrc after stripping placeholder ---" + cat "$NPM_CONFIG_USERCONFIG" + + - name: Upgrade npm for trusted publishing (requires >= 11.5.1) + run: | + npm install -g npm@11 + NPM_VER="$(npm --version)" + echo "Installed npm $NPM_VER" + node -e " + const v = '$NPM_VER'.split('.').map(Number); + if (v[0] < 11 || (v[0] === 11 && v[1] < 5) || (v[0] === 11 && v[1] === 5 && v[2] < 1)) { + console.error('npm >= 11.5.1 is required for trusted publishing, got $NPM_VER'); + process.exit(1); + } + " + + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + id: yarn-cache + with: + path: | + .yarn/cache + **/node_modules + **/.eslintcache + **/yarn.lock + key: ${{ runner.os }}-yarn-${{ hashFiles('**/package.json') }} + + - name: Yarn install + run: yarn install --immutable + + - name: Publish NPM packages + run: | + echo "npm $(npm --version) | node $(node --version)" + if [[ -n "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]]; then + echo 'ACTIONS_ID_TOKEN_REQUEST_URL is set' + else + echo 'ACTIONS_ID_TOKEN_REQUEST_URL is NOT set' + fi + echo "NODE_AUTH_TOKEN is '${NODE_AUTH_TOKEN:-(unset)}'" + echo "--- .npmrc ---" + cat "$NPM_CONFIG_USERCONFIG" 2>/dev/null || echo "(no .npmrc)" + echo "---" + yarn build + yarn publish-version ${{ needs.release.outputs.version }} + + - name: Trigger Documentation Build + uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3 + with: + token: ${{ secrets.PAT_DOCUMENTATION }} + repository: cratis/documentation + event-type: build-docs + + - name: Trigger Dependency Updates on Sample Repository + uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3 + with: + token: ${{ secrets.PAT_DOCUMENTATION }} + repository: cratis/samples + event-type: update-dependencies + + verify-published: + # A merge that publishes nothing is the failure mode that silently costs a release: the release job + # succeeds, every publish job is skipped for want of should-publish, and the whole run reports green. Fail + # instead, so a release that did not happen cannot be mistaken for one that did. + # + # Only for the reasons that mean something went wrong. Publishing nothing is correct and routine for the + # others - a commit pushed straight to main, a Dependabot merge, a re-run of a run that already released - + # and failing on those would make this job noise that everyone learns to ignore. + if: always() && needs.release.result == 'success' && contains(fromJSON('["no-label", "error"]'), needs.release.outputs.reason) runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: {} + timeout-minutes: 60 + needs: [release] + steps: - - name: Explain why publication is blocked + - name: Report that nothing was published run: | - cat <<'EOF' - Publication is intentionally blocked for this V4 draft source line. - - This workflow is a manual, no-op entry point. It does not build, release, publish, - or trigger anything downstream - triggering it does nothing but print this message - and fail. - - V4 publication remains blocked until all of the following exist and have owner - approval: - 1. An approved manual, tooling-first candidate publish workflow built and staged - for V4 (not main's automatic source-first/continue-on-failure publisher). - 2. Trusted-publisher/OIDC bootstrap configured and verified for every package. - 3. An approved release workflow that installs with `yarn install --immutable` - from the committed root `yarn.lock`, with local development honoring the - same lockfile. - 4. A package set synchronized across every workspace, including future renderer - packages. - 5. Retained exact tarballs and checksums for whatever gets published. - 6. Explicit owner approval to re-enable publication for this source line. - - Until then, do not add a push trigger, id-token permission, contents: write - permission, secrets usage, release creation, npm/yarn publish step, or any - dispatch side effect to this workflow. - EOF + echo "::error::Nothing was published and no release was cut (reason: ${{ needs.release.outputs.reason }}). For 'no-label', add exactly one of major, minor or patch to the merged pull request and re-run this workflow - see verify-semver-label, which is meant to catch this before the merge." exit 1 diff --git a/Documentation/ui-foundation.md b/Documentation/ui-foundation.md index 8c002689..611a8ea1 100644 --- a/Documentation/ui-foundation.md +++ b/Documentation/ui-foundation.md @@ -191,20 +191,20 @@ Repository issues may track these gaps, but an open issue is not a public roadma The following issues preserve follow-up decisions outside the Components 4 contract. They are tracking records, not promises that an unstable API already exists or will ship unchanged: -| Issue | Tracked decision or evidence gap | -| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -| [#207](https://github.com/Cratis/Components/issues/207) | Owner-authorized Components 4 publication; publication remains disabled until that separate review completes. | -| [#208](https://github.com/Cratis/Components/issues/208) | Proof and possible promotion of atomic slots and mixed-renderer islands. | -| [#209](https://github.com/Cratis/Components/issues/209) | Schema-driven public renderer discovery tooling. | -| [#210](https://github.com/Cratis/Components/issues/210) | Lazy renderer preload semantics for streaming server rendering. | -| [#211](https://github.com/Cratis/Components/issues/211) | Cross-browser and assistive-technology renderer certification. | -| [#212](https://github.com/Cratis/Components/issues/212) | CSS theme bridges and vendor portal-interoperability recipes. | -| [#213](https://github.com/Cratis/Components/issues/213) | Source-map preservation through ESM specifier rewriting. | -| [#214](https://github.com/Cratis/Components/issues/214) | Evidence for or against a renderer-exclusive slim distribution. | -| [#215](https://github.com/Cratis/Components/issues/215) | Reviewed dependency-update pull requests. | -| [#216](https://github.com/Cratis/Components/issues/216) | Packed public-API snapshots and semantic-version surface diffs. | -| [#217](https://github.com/Cratis/Components/issues/217) | Generated evidence inventories instead of hardcoded check counts. | -| [#218](https://github.com/Cratis/Components/issues/218) | Renderer bundle and runtime-performance regression budgets. | +| Issue | Tracked decision or evidence gap | +| ------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| [#207](https://github.com/Cratis/Components/issues/207) | Components 4 registry bootstrap, release execution, and post-publication verification. | +| [#208](https://github.com/Cratis/Components/issues/208) | Proof and possible promotion of atomic slots and mixed-renderer islands. | +| [#209](https://github.com/Cratis/Components/issues/209) | Schema-driven public renderer discovery tooling. | +| [#210](https://github.com/Cratis/Components/issues/210) | Lazy renderer preload semantics for streaming server rendering. | +| [#211](https://github.com/Cratis/Components/issues/211) | Cross-browser and assistive-technology renderer certification. | +| [#212](https://github.com/Cratis/Components/issues/212) | CSS theme bridges and vendor portal-interoperability recipes. | +| [#213](https://github.com/Cratis/Components/issues/213) | Source-map preservation through ESM specifier rewriting. | +| [#214](https://github.com/Cratis/Components/issues/214) | Evidence for or against a renderer-exclusive slim distribution. | +| [#215](https://github.com/Cratis/Components/issues/215) | Reviewed dependency-update pull requests. | +| [#216](https://github.com/Cratis/Components/issues/216) | Packed public-API snapshots and semantic-version surface diffs. | +| [#217](https://github.com/Cratis/Components/issues/217) | Generated evidence inventories instead of hardcoded check counts. | +| [#218](https://github.com/Cratis/Components/issues/218) | Renderer bundle and runtime-performance regression budgets. | Until those issues produce reviewed changes, the stable boundary remains the setup-only root, the exact nine-slot `stable-presentation/v1` profile, boolean setup attestations, and diff --git a/Migrator/compat-manifest.json b/Migrator/compat-manifest.json index 94874b04..66e21688 100644 --- a/Migrator/compat-manifest.json +++ b/Migrator/compat-manifest.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "releaseStatus": "source-candidate", - "publicationEnabled": false, + "releaseStatus": "publication-authorized", + "publicationEnabled": true, "gaScope": { "publicPackages": [ "@cratis/components", @@ -47,7 +47,7 @@ }, "components4": { "components": ">=4 <5", - "status": "current-candidate", + "status": "current", "migrationRole": "target", "rendererAbi": 1, "coreProfile": "core/v1", diff --git a/README.md b/README.md index c08dcaca..a06c4d8d 100644 --- a/README.md +++ b/README.md @@ -88,12 +88,11 @@ optional Pixi peer ranges. Verify those ranges before installing the package. - The package manifest, exports, source, and migration guide define the current Components major-version surface. - Generated compatibility schema v2 is checked in as [`compat-manifest.json`](./compat-manifest.json). - It records the seven-package source-candidate scope and support windows; the durable - [release policy](./release.md) explains why that metadata does not authorize publication. -- Workspace manifest versions in a source checkout are development inputs, not release - identity. Publication is intentionally fail-closed while the Components 4 package set and - trusted-publisher workflow are completed. Do not infer a release from a branch, tag, or local - package version. + It records the authorized seven-package scope, shared version, support windows, and renderer + evidence boundaries. +- Workspace manifest versions in a source checkout are development inputs. Registry versions and + the GitHub release created by the label-driven [release policy](./release.md) establish release + identity. - Package existence, examples, Storybook output, and passing checks do not establish maturity, accessibility conformance, browser coverage, support, security, or production suitability. @@ -151,7 +150,7 @@ applications do not install Conformance unless they are implementing an adapter. Migrator, ESLint, adapters, Conformance, and Core share the repository release version. Use the bounded `>=4 <5` tooling range instead of `latest`; compatibility preflight validates the installed -Components migration window. Publication remains fail closed under the repository +Components migration window. All seven packages follow the repository's label-driven [release policy](./release.md). ## Contributing @@ -167,22 +166,20 @@ npx markdownlint-cli2 README.md Source/README.md npx linkinator README.md Source/README.md --markdown --recurse ``` -Release-policy contributors can verify the deterministic contract, fail-closed workflow guards, -and source-candidate evidence generator without publishing anything: +Release contributors can verify the deterministic contract and evidence generator without +publishing anything: ```bash yarn verify-compat-manifest -yarn verify-release-safety yarn test-release-policy yarn test-release-evidence yarn test-renderer-adapter-matrix yarn generate-release-evidence --output /absolute/path/to/empty/evidence-directory ``` -The caller-provided evidence directory is temporary/untracked output. The hosted workflow retains -its source-candidate artifact for 30 days; that upload is not npm provenance and grants no -publication authority. Trusted-publisher provenance remains a separate future owner-authorized -publish-job requirement. +The caller-provided evidence directory is temporary/untracked output. The hosted build retains the +archives and SBOMs for 30 days; npm provenance is produced separately by the OIDC-enabled Publish +workflow. Source changes follow the repository's framework rules and the applicable build, type, specification, export, package-archive, accessibility-diagnostic, and diff --git a/Source/README.md b/Source/README.md index 546c210a..ad4686ed 100644 --- a/Source/README.md +++ b/Source/README.md @@ -48,9 +48,9 @@ The current manifest does not declare PrimeReact, PrimeIcons, or PrimeUI package The generated compatibility schema v2 contract is published at `@cratis/components/compat-manifest.json`. It records the supported Components and tooling -windows, the seven-package source-candidate scope, independent package trains, renderer ABI/profile -ranges, and exact lower/current adapter evidence. `publicationEnabled: false` means the source is -not authorized for publication; `release.md` at the repository root is the durable policy. +windows, the authorized seven-package scope, shared release version, renderer ABI/profile ranges, +and exact lower/current adapter evidence. `release.md` at the repository root describes the +label-driven publication policy. Renderer-adapter authors can import the public draft 2020-12 metadata schema from `@cratis/components/schemas/ui-adapter.schema.json`. It validates the static `package.json#cratis` diff --git a/Source/compat-manifest.json b/Source/compat-manifest.json index 94874b04..66e21688 100644 --- a/Source/compat-manifest.json +++ b/Source/compat-manifest.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "releaseStatus": "source-candidate", - "publicationEnabled": false, + "releaseStatus": "publication-authorized", + "publicationEnabled": true, "gaScope": { "publicPackages": [ "@cratis/components", @@ -47,7 +47,7 @@ }, "components4": { "components": ">=4 <5", - "status": "current-candidate", + "status": "current", "migrationRole": "target", "rendererAbi": 1, "coreProfile": "core/v1", diff --git a/Source/scripts/verify-package-archive.mjs b/Source/scripts/verify-package-archive.mjs index 345dc65d..4b4ae4f6 100644 --- a/Source/scripts/verify-package-archive.mjs +++ b/Source/scripts/verify-package-archive.mjs @@ -93,8 +93,8 @@ const coreEntry = compatibilityManifest.packages?.find( ); if ( compatibilityManifest.schemaVersion !== 2 || - compatibilityManifest.releaseStatus !== 'source-candidate' || - compatibilityManifest.publicationEnabled !== false || + compatibilityManifest.releaseStatus !== 'publication-authorized' || + compatibilityManifest.publicationEnabled !== true || compatibilityManifest.gaScope?.publicPackages?.length !== 7 || coreEntry?.version !== packedPackage.version || !semver.satisfies(packedPackage.version, coreEntry?.releaseMajorRange ?? '') diff --git a/compat-manifest.json b/compat-manifest.json index 94874b04..66e21688 100644 --- a/compat-manifest.json +++ b/compat-manifest.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "releaseStatus": "source-candidate", - "publicationEnabled": false, + "releaseStatus": "publication-authorized", + "publicationEnabled": true, "gaScope": { "publicPackages": [ "@cratis/components", @@ -47,7 +47,7 @@ }, "components4": { "components": ">=4 <5", - "status": "current-candidate", + "status": "current", "migrationRole": "target", "rendererAbi": 1, "coreProfile": "core/v1", diff --git a/package.json b/package.json index 40c9d432..31902860 100644 --- a/package.json +++ b/package.json @@ -34,13 +34,13 @@ "test": "node ./run-task-on-workspaces.js test", "generate-compat-manifest": "node ./scripts/generate-compat-manifest.mjs --write", "verify-compat-manifest": "node ./scripts/generate-compat-manifest.mjs --check", - "verify-release-safety": "node ./scripts/verify-release-safety.mjs", "generate-release-evidence": "node ./scripts/generate-release-evidence.mjs", "test-release-evidence": "node --test ./scripts/generate-release-evidence.test.mjs", - "test-release-policy": "node --test ./scripts/generate-compat-manifest.test.mjs ./scripts/verify-release-safety.test.mjs", + "test-release-policy": "node --test ./scripts/generate-compat-manifest.test.mjs", "test-renderer-adapter-matrix": "node --test ./scripts/verify-renderer-adapter-matrix.test.mjs", "verify-renderer-adapters": "node ./scripts/verify-renderer-adapters.mjs", - "ci": "yarn verify-compat-manifest && yarn verify-release-safety && yarn test-release-policy && yarn test-release-evidence && yarn test-renderer-adapter-matrix && node ./run-task-on-workspaces.js ci", + "publish-version": "node ./run-task-on-workspaces.js publish-version", + "ci": "yarn verify-compat-manifest && yarn test-release-policy && yarn test-release-evidence && yarn test-renderer-adapter-matrix && node ./run-task-on-workspaces.js ci", "up": "node ./run-task-on-workspaces.js up" }, "license": "MIT", diff --git a/release.md b/release.md index 161580bc..325818bc 100644 --- a/release.md +++ b/release.md @@ -1,13 +1,12 @@ # Release policy -This repository is fail closed for publication. The checked-in compatibility manifest describes a -`source-candidate`; it is not publication authorization. `.github/workflows/publish.yml` remains a -manual, permissions-empty, failing no-op until owners approve and review a separate release change. -This policy defines that future release gate but does not implement a live publisher. +Components follows the standard Cratis label-driven release flow. A pull request merged to `main` +with exactly one `patch`, `minor`, or `major` label triggers `.github/workflows/publish.yml`. A +`no-release` label explicitly suppresses publication for maintenance changes. -## General-availability scope +## Published packages -A Components 4 general-availability release covers exactly seven public packages: +One release publishes these seven public packages at the same version: 1. `@cratis/components` 2. `@cratis/eslint-plugin-components` @@ -17,113 +16,44 @@ A Components 4 general-availability release covers exactly seven public packages 6. `@cratis/components.primereact` 7. `@cratis/components.primereact10` -The Plain DOM conformance fixture and composed Storybook are private evidence. They are never -published. All seven public packages share the repository release version and are represented by -one GitHub release. Renderer ABI/profile versions remain separate protocol contracts; a source -manifest version is not registry release identity until publication completes. +The Plain DOM conformance fixture and composed Storybook are private verification surfaces and are +never published. Renderer ABI/profile versions are protocol identifiers, not independent package +versions. -## Compatibility contract +## Automatic releases -[`compat-manifest.json`](./compat-manifest.json) is generated compatibility schema version 2. It -records the source-candidate status, the seven-package GA scope, shared repository release version, -Components 3 and 4 support windows, renderer ABI/profile ranges, and exact adapter evidence -boundaries. Run `yarn verify-compat-manifest` rather than editing any generated copy by hand. +The publish workflow runs on pushes to `main`. `cratis/release-action` resolves the merged pull +request and its semantic-version label, creates the release version and notes, and tells the npm job +whether publication is required. The npm job: -The manifest is descriptive and fail-closed. `schemaVersion: 2`, a package version, a successful -check, or `releaseStatus: source-candidate` grants no publication authority. Only the separately -owner-approved authorization change described below may set `publicationEnabled: true`. +1. checks out the exact merged commit; +2. installs the committed lockfile with `yarn install --immutable`; +3. builds all public workspaces; +4. updates every public workspace and local workspace dependency to the release version; +5. publishes each package publicly with npm provenance; and +6. triggers documentation and sample dependency updates. -## Authorization gate +Publishing stops on the first package failure. The workflow fails explicitly when a release-bearing +merge cannot be associated with a valid version label. -Publication requires an owner-reviewed change that deliberately switches the generated contract -from `source-candidate` to publication-authorized metadata and sets `publicationEnabled: true`. -That validation must also contain a valid, owner-approved Components 3 EOL date. Authorization must -not be inferred from a branch, commit, tag, package version, passing workflow, or existing registry -package. +## Manual recovery -Components `>=3 <4` receives maintenance and security-critical support while Components `>=4 <5` -is the current candidate and migration target. Owners must decide and approve the Components 3 EOL -date no later than 12 months after Components 4 GA. The decision is recorded in the compatibility -manifest before publication is enabled. +`workflow_dispatch` is the recovery path when an automatic release did not run. Supply the exact +version and the original merged pull request's consumer-facing release notes. Do not use a new +version merely to recover automation. -## Build immutable candidates +The npm job uses trusted publishing through GitHub Actions OIDC (`id-token: write`) and npm 11.5.1 +or newer. Every existing package must trust this repository and `.github/workflows/publish.yml`. +A brand-new npm package must receive a one-time authenticated bootstrap publication before trusted +publishing can be configured; do not begin a multi-package release until all package records and +trusted publishers are ready. -The `release-evidence` job in `.github/workflows/javascript-build.yml` is a read-only, -source-candidate evidence job. It builds each publishable workspace shape once, packs the exact -seven `gaScope.publicPackages` archives once, records SHA-256 and SHA-512 checksums, produces -exactly seven reproducible CycloneDX 1.6 SBOM documents, binds each SBOM to its archive and commit, -and retains the resulting hosted artifact for 30 days. It cannot publish and is not npm trusted-publisher provenance. Contributors -can reproduce the same evidence in an empty caller-owned directory: +## Release evidence and verification -```bash -yarn test-release-evidence -yarn generate-release-evidence --output /absolute/path/to/empty/evidence-directory -``` +`.github/workflows/javascript-build.yml` generates retained archives, SHA-256/SHA-512 manifests, +and archive-bound CycloneDX 1.6 SBOMs for all seven packages. The evidence job is read-only and does +not publish. -The output directory is never a repository artifact and must not be committed. The evidence index -retains `publicationEnabled: false`; generation refuses publication-enabled metadata. - -A future owner-authorized publication job must separately provide trusted-publisher provenance and -consume reviewed immutable archives without rebuilding them. That job does not exist in this -source-candidate tranche. - -1. Check out the exact reviewed commit and run `yarn install --immutable`. Treat any install warning - as a release-candidate failure; do not normalize peer or resolution warnings into accepted output. -2. Run all release gates for the seven-package scope and private Plain/Storybook evidence. -3. Pack each public package exactly once. Never rebuild between candidate verification and - publication. -4. Retain every immutable tarball and record its SHA-256 and SHA-512 digests. -5. Generate and retain an archive-bound SBOM for every tarball. -6. In the future authorized publish job, retain trusted-publisher provenance that binds commit, - workflow, package, version, tarball digest, and SBOM. -7. Stage candidates under a reviewed non-default npm dist-tag. A candidate must never replace the - default installation tag implicitly. - -No token, license key, registry credential, or signing secret belongs in Components source, -Storybook, package archives, logs, compatibility metadata, or repository variables available to -untrusted code. Publication must use reviewed trusted-publisher identity and a human-protected -environment. There is no automatic deployment approval. - -## Publish and promote - -Publication is stop-on-first-failure. Do not continue with later packages after any upload, -attestation, digest, SBOM, provenance, or registry check fails. A retry may upload only the exact -same retained immutable tarball whose SHA-512 digest was already reviewed; never rebuild or mutate a -version after a partial failure. - -Publish and verify tooling, conformance, and adapters before Core. Promotion order is: - -1. `@cratis/eslint-plugin-components` -2. `@cratis/components.migrator` -3. `@cratis/components.conformance` -4. `@cratis/components.mui` -5. `@cratis/components.primereact` -6. `@cratis/components.primereact10` -7. `@cratis/components` - -After each candidate upload, verify registry metadata, package visibility, version, dist-tag, -tarball SHA-512, unpacked file inventory, bundled compatibility manifest, SBOM, and provenance. -Only after all seven immutable candidates pass registry verification may owners promote their -reviewed tags. Promotion must preserve the shared repository release version and compatibility -ranges; it must not rewrite or repack artifacts. - -Create repository tags and a GitHub release only after final registry verification succeeds for all -seven packages. Release notes identify every package version and immutable digest. Do not create a -tag or GitHub release to recover from an incomplete registry publication. - -## Safety invariants - -- Never restore the obsolete root `publish-version` command or workspace manifest-mutation helper. -- Never publish automatically on push, merge, schedule, release, or another workflow's completion. -- Never auto-approve the protected publication environment. -- Never continue after a package publication failure. -- Never retry using a rebuilt tarball. -- Never put npm or third-party renderer credentials in this repository. -- Never create tags or GitHub releases before registry verification. -- Keep `.github/workflows/publish.yml` workflow-dispatch-only, permissions-empty, and failing while - `publicationEnabled` is false. - -Run `yarn verify-compat-manifest` and `yarn verify-release-safety` for every release-policy change. -Owner-authorized publication work is tracked separately in -[Components issue #207](https://github.com/Cratis/Components/issues/207); this policy does not -complete or authorize that work. +After publication, verify all seven registry versions, public visibility, dist-tags, provenance, +package exports, and exact-version installation. Create or verify the Git tag and GitHub release only +for the version that was actually published. diff --git a/run-task-on-workspaces.js b/run-task-on-workspaces.js index 1aa297b9..728ade65 100755 --- a/run-task-on-workspaces.js +++ b/run-task-on-workspaces.js @@ -9,12 +9,6 @@ if (process.argv.length < 3) { } const task = process.argv[2]; -if (task === 'publish-version') { - console.error( - "The obsolete 'publish-version' workspace task is disabled. Releases require the reviewed, immutable-tarball process in release.md.", - ); - process.exit(1); -} const path = require('path'); const fs = require('fs'); @@ -61,6 +55,39 @@ for (const workspaceDef of rootPackageJson.workspaces) { console.log(''); const args = process.argv.slice(3); +const isPublishing = task === 'publish-version'; +if ( + isPublishing && + (args.length !== 1 || + !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(args[0])) +) { + console.error('publish-version requires one exact semantic version.'); + process.exit(1); +} +const releaseVersion = isPublishing ? args[0] : undefined; +const workspaceNames = new Set(Object.keys(workspaces)); + +const saveJson = (file, value) => + fs.writeFileSync(file, `${JSON.stringify(value, null, 4)}\n`, 'utf8'); + +const preparePackageForRelease = (packageJson, version) => { + const releasePackage = structuredClone(packageJson); + releasePackage.version = version; + for (const field of [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies', + ]) { + for (const dependencyName of Object.keys(releasePackage[field] ?? {})) { + if (workspaceNames.has(dependencyName)) { + releasePackage[field][dependencyName] = version; + } + } + } + return releasePackage; +}; + console.log(`Performing '${task}' on workspaces`); if (args.length > 0) console.log(` Using args : ${args}`); console.log(''); @@ -78,6 +105,27 @@ for (const workspaceName in workspaces) { ); continue; } + if (isPublishing) { + const releasePackage = preparePackageForRelease(packageJson, releaseVersion); + saveJson(packageJsonFile, releasePackage); + console.log( + `Publishing workspace '${workspaceName}' at '${workspaceRelativeLocation}' as ${releaseVersion}`, + ); + const result = spawn('npm', ['publish', '--provenance', '--access', 'public'], { + cwd: workspaceAbsoluteLocation, + encoding: 'utf8', + }); + console.log(result.stdout ?? ''); + console.log(result.stderr ?? ''); + if (result.status !== 0) { + console.error( + `Error publishing workspace '${workspaceName}'. Publication stopped.`, + ); + process.exit(1); + } + continue; + } + if (!packageJson.scripts || !Object.hasOwn(packageJson.scripts, task)) { console.log( `Skipping workspace '${workspaceName}' - no script with name '${task}'`, diff --git a/scripts/generate-compat-manifest.mjs b/scripts/generate-compat-manifest.mjs index d99383c3..08f76921 100644 --- a/scripts/generate-compat-manifest.mjs +++ b/scripts/generate-compat-manifest.mjs @@ -86,8 +86,8 @@ export function createCompatibilityManifest(rootDirectory = repositoryDirectory) const manifest = { schemaVersion: 2, - releaseStatus: 'source-candidate', - publicationEnabled: false, + releaseStatus: 'publication-authorized', + publicationEnabled: true, gaScope: { publicPackages: [...packageOrder], privateEvidence: privateEvidence.map(({ id, name, purpose }) => ({ @@ -117,7 +117,7 @@ export function createCompatibilityManifest(rootDirectory = repositoryDirectory) }, components4: { components: '>=4 <5', - status: 'current-candidate', + status: 'current', migrationRole: 'target', rendererAbi: 1, coreProfile: 'core/v1', @@ -230,14 +230,14 @@ export function validateCompatibilityManifest( } if ( components4?.components !== '>=4 <5' || - components4?.status !== 'current-candidate' || + components4?.status !== 'current' || components4?.rendererAbi !== 1 || components4?.coreProfile !== 'core/v1' || components4?.adapterProfile !== 'stable-presentation/v1' || components4?.tooling?.eslint !== '>=4 <5' || components4?.tooling?.migrator !== '>=4 <5' ) { - fail('The Components 4 candidate compatibility window is incomplete.'); + fail('The Components 4 compatibility window is incomplete.'); } const expectedAdapterRanges = Object.fromEntries( packageOrder @@ -300,13 +300,11 @@ export function validateCompatibilityManifest( } } - if (manifest.publicationEnabled) { - if (manifest.releaseStatus !== 'publication-authorized') { - fail("Publication requires releaseStatus 'publication-authorized'."); - } - if (!isIsoDate(components3?.eolAt) || components3?.eolApprovedByOwners !== true) { - fail('Publication requires a valid owner-approved Components 3 eolAt date.'); - } + if ( + manifest.publicationEnabled && + manifest.releaseStatus !== 'publication-authorized' + ) { + fail("Publication requires releaseStatus 'publication-authorized'."); } } @@ -379,11 +377,6 @@ function discoverWorkspaceManifestPaths(workspaces, rootDirectory) { return manifestPaths.sort(); } -function isIsoDate(value) { - if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/u.test(value)) return false; - return new Date(`${value}T00:00:00.000Z`).toISOString().startsWith(value); -} - function parseAbiMajor(value) { if (value === undefined) return undefined; const match = String(value).match(/\d+/u); diff --git a/scripts/generate-compat-manifest.test.mjs b/scripts/generate-compat-manifest.test.mjs index e30f71c3..7bd26b2c 100644 --- a/scripts/generate-compat-manifest.test.mjs +++ b/scripts/generate-compat-manifest.test.mjs @@ -84,28 +84,24 @@ test('the shared repository release stays inside the Components major range', () ); }); -test('publication remains closed until status and Components 3 EOL are owner-approved', () => { +test('publication authorization is explicit and reversible', () => { const manifest = createManifest(); - manifest.publicationEnabled = true; - assert.throws( - () => - validateCompatibilityManifest(manifest, { - rootDirectory: repositoryDirectory, - }), - /publication-authorized/, + assert.equal(manifest.publicationEnabled, true); + assert.equal(manifest.releaseStatus, 'publication-authorized'); + assert.doesNotThrow(() => + validateCompatibilityManifest(manifest, { rootDirectory: repositoryDirectory }), ); - manifest.releaseStatus = 'publication-authorized'; + manifest.releaseStatus = 'source-candidate'; assert.throws( () => validateCompatibilityManifest(manifest, { rootDirectory: repositoryDirectory, }), - /owner-approved Components 3 eolAt/, + /publication-authorized/, ); - manifest.supportWindows.components3.eolAt = '2028-01-31'; - manifest.supportWindows.components3.eolApprovedByOwners = true; + manifest.publicationEnabled = false; assert.doesNotThrow(() => validateCompatibilityManifest(manifest, { rootDirectory: repositoryDirectory }), ); diff --git a/scripts/generate-release-evidence.mjs b/scripts/generate-release-evidence.mjs index 84059c0e..35b24d18 100644 --- a/scripts/generate-release-evidence.mjs +++ b/scripts/generate-release-evidence.mjs @@ -135,9 +135,6 @@ const loadCompatibilityMetadata = () => { const manifestPath = path.join(repositoryDirectory, 'compat-manifest.json'); const serialized = fs.readFileSync(manifestPath, 'utf8'); const manifest = readJsonFile(manifestPath, 'compat-manifest.json'); - if (manifest.publicationEnabled !== false) { - throw new Error('Source-candidate evidence refuses publicationEnabled: true.'); - } validateCompatibilityManifest(manifest, { rootDirectory: repositoryDirectory }); const generated = createCompatibilityManifest(repositoryDirectory); if (serialized !== serializeCompatibilityManifest(generated)) { @@ -236,9 +233,7 @@ export const buildPublishableShape = (releasePackages, execute = run) => { for (const packageEntry of releasePackages) { if (packageEntry.role === 'core') { execute(yarnCommand, ['workspace', packageEntry.name, 'run', 'prepare']); - } else if ( - ['conformance', 'renderer-adapter'].includes(packageEntry.role) - ) { + } else if (['conformance', 'renderer-adapter'].includes(packageEntry.role)) { execute(yarnCommand, ['workspace', packageEntry.name, 'run', 'clean']); execute(yarnCommand, ['workspace', packageEntry.name, 'run', 'build']); } @@ -347,7 +342,7 @@ export function generateReleaseEvidence({ output, commit: requestedCommit } = {} ); const index = { schemaVersion: 1, - publicationEnabled: false, + publicationEnabled: compatibilityManifest.publicationEnabled, commit, packages: packageEvidence, }; @@ -368,7 +363,7 @@ const main = () => { const arguments_ = parseArguments(process.argv.slice(2)); const index = generateReleaseEvidence(arguments_); console.log( - `Generated source-candidate release evidence for ${index.packages.length} packages at commit ${index.commit}.`, + `Generated release evidence for ${index.packages.length} packages at commit ${index.commit}.`, ); }; diff --git a/scripts/lib/release-evidence.mjs b/scripts/lib/release-evidence.mjs index 6a32ff8b..bec0f671 100644 --- a/scripts/lib/release-evidence.mjs +++ b/scripts/lib/release-evidence.mjs @@ -223,7 +223,9 @@ export function readPackedPackageJson(archivePath) { const header = archive.subarray(offset, offset + 512); if (header.every((byte) => byte === 0)) { if (archive.subarray(offset).some((byte) => byte !== 0)) { - throw new Error('Tar archive contains non-zero data after its end marker.'); + throw new Error( + 'Tar archive contains non-zero data after its end marker.', + ); } endMarkerFound = true; break; @@ -451,9 +453,9 @@ export function bindSbomToArchive(sbom, binding) { } export function validateReleaseEvidenceIndex(index, outputDirectory) { - if (index?.schemaVersion !== 1 || index.publicationEnabled !== false) { + if (index?.schemaVersion !== 1 || typeof index.publicationEnabled !== 'boolean') { throw new Error( - 'Release evidence index must be schema 1 and publication-disabled.', + 'Release evidence index must be schema 1 and declare publicationEnabled.', ); } validateCommit(index.commit); diff --git a/scripts/verify-release-safety.mjs b/scripts/verify-release-safety.mjs deleted file mode 100644 index d0c40d41..00000000 --- a/scripts/verify-release-safety.mjs +++ /dev/null @@ -1,230 +0,0 @@ -// Copyright (c) Cratis. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -import fs from 'node:fs'; -import path from 'node:path'; -import process from 'node:process'; -import { fileURLToPath, pathToFileURL } from 'node:url'; - -const repositoryDirectory = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - '..', -); - -export function verifyReleaseSafety(rootDirectory = repositoryDirectory) { - const rootPackage = readJson(path.join(rootDirectory, 'package.json')); - const publishScripts = Object.entries(rootPackage.scripts ?? {}).filter( - ([name, command]) => - /publish/iu.test(name) || /(?:npm|yarn)\s+publish/iu.test(command), - ); - if (publishScripts.length > 0) { - throw new Error( - `Root package.json must not expose publishing commands: ${publishScripts.map(([name]) => name).join(', ')}.`, - ); - } - if (rootPackage.devDependencies?.['edit-json-file']) { - throw new Error( - "Root package.json must not depend on obsolete 'edit-json-file'.", - ); - } - - const workspaceRunner = fs.readFileSync( - path.join(rootDirectory, 'run-task-on-workspaces.js'), - 'utf8', - ); - const rejectionIndex = workspaceRunner.indexOf("task === 'publish-version'"); - const discoveryIndex = workspaceRunner.indexOf('rootPackageJson.workspaces'); - if (rejectionIndex < 0 || discoveryIndex < 0 || rejectionIndex > discoveryIndex) { - throw new Error( - "run-task-on-workspaces.js must reject 'publish-version' before workspace discovery.", - ); - } - if (/edit-json-file|npm['"`]\s*,\s*\[['"`]publish/iu.test(workspaceRunner)) { - throw new Error( - 'The workspace runner still contains manifest mutation or npm publishing logic.', - ); - } - - const executableFiles = collectExecutableFiles(rootDirectory).filter( - (file) => - !file.endsWith('verify-release-safety.mjs') && - !file.endsWith('verify-release-safety.test.mjs'), - ); - const directPublish = new RegExp(`\\b${'npm'}\\s+${'publish'}\\b`, 'iu'); - const spawnedPublish = new RegExp( - `${'spawn'}(?:Sync)?\\s*\\([^)]*['\"]${'npm'}['\"][^)]*['\"]${'publish'}['\"]`, - 'isu', - ); - for (const file of executableFiles) { - const source = fs.readFileSync(file, 'utf8'); - if (directPublish.test(source) || spawnedPublish.test(source)) { - throw new Error( - `Executable publishing helper path found in ${path.relative(rootDirectory, file)}.`, - ); - } - } - - const workflow = fs.readFileSync( - path.join(rootDirectory, '.github/workflows/publish.yml'), - 'utf8', - ); - const activeWorkflow = workflow - .split('\n') - .filter((line) => !line.trimStart().startsWith('#')) - .join('\n'); - const triggerBlock = activeWorkflow.match(/^on:\s*\n([\s\S]*?)^permissions:/mu)?.[1]; - if (!triggerBlock || !/^\s+workflow_dispatch:\s*\{\}\s*$/mu.test(triggerBlock)) { - throw new Error('publish.yml must retain a workflow_dispatch-only trigger.'); - } - if (/^\s+(?:push|pull_request|schedule|workflow_run|release):/mu.test(triggerBlock)) { - throw new Error('publish.yml must not contain an automatic trigger.'); - } - const emptyPermissionBlocks = - activeWorkflow.match(/^\s*permissions:\s*\{\}\s*$/gmu) ?? []; - if (emptyPermissionBlocks.length < 2) { - throw new Error( - 'publish.yml must retain empty top-level and job-level permissions.', - ); - } - if ( - /^\s+(?:id-token|contents|packages|deployments):\s*write\s*$/imu.test( - activeWorkflow, - ) || - /\$\{\{\s*secrets\./iu.test(activeWorkflow) - ) { - throw new Error('publish.yml must not request write permissions or credentials.'); - } - if (!/^\s+exit 1\s*$/mu.test(activeWorkflow)) { - throw new Error('publish.yml must remain a failing no-op.'); - } - if (/^\s+uses:/mu.test(activeWorkflow)) { - throw new Error( - 'publish.yml must not call actions while publication is blocked.', - ); - } - - verifyEvidenceWorkflow(rootDirectory); - - const compatibilityManifest = readJson( - path.join(rootDirectory, 'compat-manifest.json'), - ); - if (compatibilityManifest.publicationEnabled !== false) { - throw new Error('compat-manifest.json must keep publicationEnabled false.'); - } - if (compatibilityManifest.releaseStatus !== 'source-candidate') { - throw new Error("compat-manifest.json must remain a 'source-candidate'."); - } - - if ( - fs.existsSync( - path.join( - rootDirectory, - '.github/workflows/auto-approve-publish-deployments.yml', - ), - ) - ) { - throw new Error('The dormant auto-approval workflow must not exist.'); - } -} - -function verifyEvidenceWorkflow(rootDirectory) { - const workflow = fs.readFileSync( - path.join(rootDirectory, '.github/workflows/javascript-build.yml'), - 'utf8', - ); - const job = workflow.match( - /^ release-evidence:\s*\n([\s\S]*?)(?=^ [a-zA-Z0-9_-]+:\s*\n|(?![\s\S]))/mu, - )?.[1]; - if (!job) throw new Error('javascript-build.yml must define a release-evidence job.'); - if (!/^ permissions:\s*\n contents: read\s*$/mu.test(job)) { - throw new Error( - 'The release-evidence job must have explicit read-only contents permission.', - ); - } - if ( - /^\s*[a-z-]+:\s*write\s*$/imu.test(job) || - /^\s*(?:id-token|deployments):/imu.test(job) || - /^\s*environment:/imu.test(job) || - /^\s*secrets:/imu.test(job) || - /\$\{\{\s*secrets\./iu.test(job) - ) { - throw new Error( - 'The release-evidence job must not request write permissions, identity/deployment permissions, an environment, or secrets.', - ); - } - if (/\bnpm\s+publish\b|\byarn\s+(?:npm\s+)?publish\b/iu.test(job)) { - throw new Error('The release-evidence job must not publish packages.'); - } - if (!/yarn generate-release-evidence\s+--output/iu.test(job)) { - throw new Error( - 'The release-evidence job must run the source-candidate evidence generator.', - ); - } - if ( - !/uses:\s*actions\/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02\s*# v4/iu.test( - job, - ) - ) { - throw new Error( - 'The release-evidence job must use the reviewed SHA-pinned upload-artifact action.', - ); - } - if (!/^\s*if-no-files-found:\s*error\s*$/imu.test(job)) { - throw new Error( - 'The release-evidence artifact upload must fail when files are missing.', - ); - } - if (!/^\s*retention-days:\s*30\s*$/imu.test(job)) { - throw new Error( - 'The release-evidence artifact must have explicit 30-day retention.', - ); - } -} - -function collectExecutableFiles(rootDirectory) { - const files = []; - const ignored = new Set([ - '.agents', - '.ai', - '.ai-work', - '.claude', - '.git', - '.github', - '.yarn', - 'dist', - 'node_modules', - ]); - const visit = (directory) => { - for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { - if (ignored.has(entry.name)) continue; - const entryPath = path.join(directory, entry.name); - if (entry.isDirectory()) visit(entryPath); - else if (/\.(?:js|mjs|cjs)$/u.test(entry.name)) files.push(entryPath); - } - }; - visit(rootDirectory); - return files; -} - -function readJson(filePath) { - try { - return JSON.parse(fs.readFileSync(filePath, 'utf8')); - } catch (error) { - throw new Error( - `Could not read ${filePath}: ${error instanceof Error ? error.message : String(error)}.`, - { cause: error }, - ); - } -} - -if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { - try { - verifyReleaseSafety(); - console.log( - 'Verified fail-closed release safety and disabled publication surfaces.', - ); - } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - process.exit(1); - } -} diff --git a/scripts/verify-release-safety.test.mjs b/scripts/verify-release-safety.test.mjs deleted file mode 100644 index baa1c58d..00000000 --- a/scripts/verify-release-safety.test.mjs +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright (c) Cratis. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { test } from 'node:test'; -import { fileURLToPath } from 'node:url'; -import { verifyReleaseSafety } from './verify-release-safety.mjs'; - -const repositoryDirectory = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - '..', -); - -const createFixture = () => { - const fixture = mkdtempSync(path.join(tmpdir(), 'cratis-release-safety-')); - mkdirSync(path.join(fixture, '.github/workflows'), { recursive: true }); - for (const relativePath of [ - 'package.json', - 'run-task-on-workspaces.js', - 'compat-manifest.json', - '.github/workflows/publish.yml', - '.github/workflows/javascript-build.yml', - ]) { - writeFileSync( - path.join(fixture, relativePath), - readFileSync(path.join(repositoryDirectory, relativePath)), - ); - } - return fixture; -}; - -const withFixture = (action) => { - const fixture = createFixture(); - try { - action(fixture); - } finally { - rmSync(fixture, { recursive: true, force: true }); - } -}; - -test('the checked-in release surfaces are fail closed', () => { - assert.doesNotThrow(() => verifyReleaseSafety(repositoryDirectory)); -}); - -test('the obsolete publish-version task fails before workspace discovery', () => { - const result = spawnSync( - process.execPath, - ['run-task-on-workspaces.js', 'publish-version', '9.9.9'], - { cwd: repositoryDirectory, encoding: 'utf8' }, - ); - assert.equal(result.status, 1); - assert.match(result.stderr, /obsolete 'publish-version' workspace task is disabled/); - assert.doesNotMatch(result.stdout, /Getting packages/); -}); - -test('the guard detects a reintroduced root publishing command', () => { - withFixture((fixture) => { - const packagePath = path.join(fixture, 'package.json'); - const packageJson = JSON.parse(readFileSync(packagePath, 'utf8')); - packageJson.scripts.publish = 'npm publish'; - writeFileSync(packagePath, JSON.stringify(packageJson)); - assert.throws(() => verifyReleaseSafety(fixture), /publishing commands/); - }); -}); - -test('the guard detects an automatic publish trigger', () => { - withFixture((fixture) => { - const workflowPath = path.join(fixture, '.github/workflows/publish.yml'); - const workflow = readFileSync(workflowPath, 'utf8').replace( - ' workflow_dispatch: {}', - ' workflow_dispatch: {}\n push: {}', - ); - writeFileSync(workflowPath, workflow); - assert.throws(() => verifyReleaseSafety(fixture), /automatic trigger/); - }); -}); - -test('the guard detects release-evidence write permissions', () => { - withFixture((fixture) => { - const workflowPath = path.join(fixture, '.github/workflows/javascript-build.yml'); - const workflow = readFileSync(workflowPath, 'utf8').replace( - ' contents: read\n', - ' contents: read\n id-token: write\n', - ); - writeFileSync(workflowPath, workflow); - assert.throws( - () => verifyReleaseSafety(fixture), - /must not request write permissions/, - ); - }); -}); - -test('the guard detects missing release-evidence retention', () => { - withFixture((fixture) => { - const workflowPath = path.join(fixture, '.github/workflows/javascript-build.yml'); - const workflow = readFileSync(workflowPath, 'utf8').replace( - ' retention-days: 30\n', - '', - ); - writeFileSync(workflowPath, workflow); - assert.throws(() => verifyReleaseSafety(fixture), /explicit 30-day retention/); - }); -}); - -test('the guard detects compatibility publication authorization drift', () => { - withFixture((fixture) => { - const manifestPath = path.join(fixture, 'compat-manifest.json'); - const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); - manifest.publicationEnabled = true; - writeFileSync(manifestPath, JSON.stringify(manifest)); - assert.throws(() => verifyReleaseSafety(fixture), /publicationEnabled false/); - }); -});