diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml index 82e44c3437..f98c6954b1 100644 --- a/.github/workflows/release-doctor.yml +++ b/.github/workflows/release-doctor.yml @@ -6,6 +6,7 @@ on: workflow_dispatch: permissions: + actions: read contents: read jobs: @@ -17,9 +18,22 @@ jobs: with: { persist-credentials: false, fetch-depth: 0 } - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: { node-version: '24.12.0', package-manager-cache: false } + - name: Mint an all-installation release App audit token + id: release-app-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + with: + app-id: ${{ vars.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + permission-checks: read + permission-contents: write + permission-pull-requests: write - env: GH_TOKEN: ${{ github.token }} NPM_STAGED_PACKAGES_URL: ${{ vars.NPM_STAGED_PACKAGES_URL }} + RELEASE_APP_ID: ${{ vars.RELEASE_APP_ID }} + RELEASE_APP_PRIVATE_KEY_PRESENT: ${{ secrets.RELEASE_APP_PRIVATE_KEY != '' }} + RELEASE_APP_TOKEN: ${{ steps.release-app-token.outputs.token }} RELEASE_REQUIRED_CHECKS: ${{ vars.RELEASE_REQUIRED_CHECKS }} RELEASE_BRANCH_RULESET_ID: ${{ vars.RELEASE_BRANCH_RULESET_ID }} RELEASE_TAG_RULESET_ID: ${{ vars.RELEASE_TAG_RULESET_ID }} diff --git a/.github/workflows/release-qualify.yml b/.github/workflows/release-qualify.yml index d554b89a9f..26e5bf24b6 100644 --- a/.github/workflows/release-qualify.yml +++ b/.github/workflows/release-qualify.yml @@ -106,6 +106,10 @@ jobs: - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 with: { name: '${{ env.CORE_ARTIFACT }}', path: .release/candidate } - run: node scripts/release/release-candidate.mjs verify .release/candidate && node scripts/release/release-candidate.mjs hydrate .release/candidate + - name: Dry-run npm pack, publish, and staged publish over the exact tarballs + run: | + npm_bin=$(node scripts/release/install-pinned-npm.mjs "$RUNNER_TEMP/npm-cli-dry-run") + NPM_DRY_RUN_BIN="$npm_bin" node scripts/release/verify-npm-dry-runs.mjs .release/candidate - name: Run package source and lifecycle properties run: | pnpm --filter @rxjs/observable-polyfill --filter @rxjs/test --filter @rxjs/migrate run test diff --git a/.github/workflows/release-stage.yml b/.github/workflows/release-stage.yml index 88d6cc76f8..258bbcfd01 100644 --- a/.github/workflows/release-stage.yml +++ b/.github/workflows/release-stage.yml @@ -78,12 +78,23 @@ jobs: permissions: actions: read attestations: read - contents: write + contents: read id-token: write - pull-requests: write + pull-requests: read steps: + - name: Mint guarded release App token for tag, release, and PR writes + id: release-app-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + with: + app-id: ${{ vars.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: { ref: '${{ needs.authorize.outputs.source-commit }}', fetch-depth: 0, persist-credentials: true } + with: + ref: '${{ needs.authorize.outputs.source-commit }}' + fetch-depth: 0 + token: ${{ steps.release-app-token.outputs.token }} - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: { node-version: '24.12.0', package-manager-cache: false } - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 @@ -119,7 +130,7 @@ jobs: for tarball in .release/candidate/*.tgz; do gh attestation verify "$tarball" --repo "$GITHUB_REPOSITORY"; done - name: Create protected candidate tag and draft evidence release env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.release-app-token.outputs.token }} VERSION: ${{ inputs.version }} run: | git config user.name "github-actions[bot]" @@ -132,7 +143,7 @@ jobs: - name: Comment WebAuthn approval links, hashes, stage IDs, order, and CLI fallback if: always() env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.release-app-token.outputs.token }} NPM_STAGED_PACKAGES_URL: ${{ vars.NPM_STAGED_PACKAGES_URL }} run: | test -f .release/staged-release.json diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index e1bfce5202..5ab8825d6d 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -36,6 +36,8 @@ Self-merging the generated PR starts **Qualify RxJS 9 release**. Two separate fr The canonical first build then passes every blocking Node, browser, Safari, Deno, Bun, Webpack, performance, package, and pinned Observable WPT gate. A scripts-disabled local installation of the exact tarballs produces a CycloneDX SBOM and release-only lockfile. A SHA-pinned OSV scan uses no monorepo exceptions. GitHub attests the exact tarballs. +The checked npm 11.18.0 CLI also runs `npm pack --dry-run`, `npm publish --dry-run`, and `npm stage publish --dry-run` over every exact tarball. It previews each trusted publisher with `npm trust github --allow-stage-publish --dry-run`, bound to `ReactiveX/rxjs`, `release-stage.yml`, and `npm-stage`, without granting direct-publish authority. These commands prove packaging, lifecycle, and trusted-configuration inputs without changing the registry. They do not prove npm OIDC or trusted-publisher authorization because dry-run does not submit a stage. The private staging of the first real beta is the live authorization proof; no public rehearsal package is created. + The retained 30-day artifact contains: - `release-manifest.json`; @@ -69,14 +71,14 @@ The finalizer has no npm credentials or publishing authority. It waits for all f ## One-time setup before beta 1. Protect `master`: require pull requests with zero approvals, require CI, CodeQL, dependency review, OSV, workflow validation, release coherence, WPT, and release readiness; require verified squash commits; prevent force-push and deletion. -2. Configure the release App with only checks read plus contents and pull-request write access. Store `RELEASE_APP_ID`, `RELEASE_APP_PRIVATE_KEY`, and the exact `RELEASE_REQUIRED_CHECKS` list. +2. Configure the release App with only checks read plus contents and pull-request write access. Store `RELEASE_APP_ID`, `RELEASE_APP_PRIVATE_KEY`, and `RELEASE_REQUIRED_CHECKS` as the repository-defined JSON array of exact master check names. Pull-request-only dependency review and Conventional Commit checks belong in branch protection, not this master wait list. 3. Restrict `release/rxjs-9` updates to the release App's guarded force-with-lease refresh. 4. Restrict the `npm-stage` environment to protected `master` with no reviewer and no secret. 5. Configure all four npm trusted publishers for the stage workflow and environment. Require WebAuthn and disallow publish-capable tokens; delete any reusable publication credential. 6. Verify the authenticated npm Staged Packages URL and store it as `NPM_STAGED_PACKAGES_URL`. 7. Protect `refs/tags/9.*` from update, deletion, and force-push; allow only the staging workflow to create a tag. Enable GitHub Release immutability. -8. Run the release doctor and rehearse qualification, typed digest authorization, stage inspection, rejection, restaging, WebAuthn approval, provenance, attestations, and finalization using existing disposable packages controlled only by Ben. +8. Run the release doctor and the complete local/CI dry-run ladder. Use private staging of `9.0.0-beta.0` as the first live OIDC proof, download and compare every stage, and pause before WebAuthn approval. A partial or mismatched stage is rejected in full and requires a freshly qualified version. -The repository cannot configure GitHub/npm account WebAuthn, rulesets, environments, trusted publishers, or perform the disposable npm rehearsal from source code. P6.10 remains active until that external evidence exists. +The repository cannot configure GitHub/npm account WebAuthn, rulesets, environments, or trusted publishers from source code. P6.10 remains active until those controls and the first real private stage are verified. Nothing becomes publicly installable until Ben separately approves the matching stages with WebAuthn. Last reviewed: 2026-08-02. diff --git a/docs/rxjs-next/ARCHITECTURE.md b/docs/rxjs-next/ARCHITECTURE.md index d5c9c73f89..2c0365cc62 100644 --- a/docs/rxjs-next/ARCHITECTURE.md +++ b/docs/rxjs-next/ARCHITECTURE.md @@ -76,6 +76,11 @@ inventories, contents, and SHA-512 values are byte-identical. All package, runtime, browser, Safari, alternate-runtime, Webpack, performance, WPT, SBOM, OSV, and attestation evidence is bound to the canonical tarballs. The workflow then stops and exposes its run ID, version, source commit, and manifest SHA-512. +The checked npm 11.18.0 CLI runs pack, publish, and staged-publish dry runs over +those exact tarballs. Dry-run does not submit to the registry and therefore +does not prove OIDC authorization. Private staging of the first real beta is +the live trusted-publisher proof; RxJS does not create a public rehearsal +package. A separate manual dispatch by `benlesh` must reproduce the run ID, version, and digest. It revalidates the protected branch/current commit, generated release diff --git a/docs/rxjs-next/DECISIONS.md b/docs/rxjs-next/DECISIONS.md index 52e5001333..3f22db3b35 100644 --- a/docs/rxjs-next/DECISIONS.md +++ b/docs/rxjs-next/DECISIONS.md @@ -1485,9 +1485,18 @@ Status meanings: bundle. Finalization requires registry integrity, `npm audit signatures`, and GitHub attestation verification. OpenSSF remains secondary; Code-Review `0` is accepted rather than manufacturing approvals. +- **Dry-run and first live proof:** The checked npm CLI runs pack, publish, and + staged-publish dry runs over the exact candidate tarballs. It also previews + stage-only GitHub trust configurations with the exact repository, workflow, + and environment inputs. Those commands do not contact the registry or prove + trusted-publisher authorization. Rather + than create a public rehearsal package, private staging of the first real + beta supplies the live OIDC proof; publication still requires Ben's separate + WebAuthn approval after the staged bytes are downloaded and matched. - **Consequence:** Private Nx release imports, the token-based publisher, reviewer requests, release-team ownership, and succession-role assumptions are removed. Repository-owned policy, reproducibility, evidence, staging, doctor, and finalizer scripts implement the accepted flow. GitHub/npm - WebAuthn, ruleset/environment/trusted-publisher setup, and the sole-account - disposable-package rehearsal remain external gates before publication. + WebAuthn and ruleset/environment/trusted-publisher setup remain external + gates before publication. P6.10 closes only after the first real beta is + privately staged, approved, and verified publicly. diff --git a/docs/rxjs-next/PROJECT_PLAN.md b/docs/rxjs-next/PROJECT_PLAN.md index 2929692b64..292cab01e2 100644 --- a/docs/rxjs-next/PROJECT_PLAN.md +++ b/docs/rxjs-next/PROJECT_PLAN.md @@ -1557,18 +1557,18 @@ names. ### Phase 6 — Release readiness -| Status | ID | Outcome | -| --------- | ----- | ------------------------------------------------------------------------------------- | -| `DONE` | P6.1 | Finalize version naming, supported environments, support policy, and release channels | -| `DONE` | P6.2 | Complete package, type, bundle, performance, and conformance gates | -| `DONE` | P6.3 | Publish package-local API, migration, and contributor documentation | -| `DONE` | P6.4 | Run pre-release adoption, resolve blockers, and approve the major release | -| `DONE` | P6.5 | Complete the terminal plan, verification, and documentation-site exclusion audit | -| `DONE` | P6.6 | Centralize eligible source subscriptions and record bundle-size evidence | -| `DONE` | P6.7 | Use direct `[create]` construction and record bundle-size evidence | -| `DONE` | P6.8 | Complete RxJS 9 CI coverage and validate the resulting pull-request workflow matrix | -| `BLOCKED` | P6.9 | Validate the first live dependency-review and Scorecard runs on GitHub | -| `NEXT` | P6.10 | Implement the secure release-PR and npm staged-approval process | +| Status | ID | Outcome | +| ------ | ----- | ------------------------------------------------------------------------------------- | +| `DONE` | P6.1 | Finalize version naming, supported environments, support policy, and release channels | +| `DONE` | P6.2 | Complete package, type, bundle, performance, and conformance gates | +| `DONE` | P6.3 | Publish package-local API, migration, and contributor documentation | +| `DONE` | P6.4 | Run pre-release adoption, resolve blockers, and approve the major release | +| `DONE` | P6.5 | Complete the terminal plan, verification, and documentation-site exclusion audit | +| `DONE` | P6.6 | Centralize eligible source subscriptions and record bundle-size evidence | +| `DONE` | P6.7 | Use direct `[create]` construction and record bundle-size evidence | +| `DONE` | P6.8 | Complete RxJS 9 CI coverage and validate the resulting pull-request workflow matrix | +| `DONE` | P6.9 | Validate the first live dependency-review and Scorecard runs on GitHub | +| `NEXT` | P6.10 | Implement the secure release-PR and npm staged-approval process | #### P6.9 completion bar @@ -1583,8 +1583,8 @@ names. review blocks new moderate-or-higher runtime and development vulnerabilities. - Active-workflow formatting, release coherence, documentation links, badge targets, and the no-runtime/no-`rxjs.dev` scope boundary are verified. The - first default-branch Scorecard publication and required-check repository rule - remain explicit post-merge administrative follow-ups. + first default-branch Scorecard publication, code-scanning result, and + dependency-review required-check rule are verified live. #### P6.9 implementation evidence @@ -1601,10 +1601,11 @@ names. Local YAML parsing, active-workflow formatting, documentation links, all 24 release-check tests, release coherence, and diff checks pass; `apps/rxjs.dev` and runtime/package source are unchanged. -- P6.9 is `BLOCKED` until dependency review runs on the implementation PR. - After merge, the first `master` Scorecard publication, code-scanning result, - live badge population, and required dependency-review repository rule remain - administrative verification steps. +- Dependency review passed on merged PR #7613. After merge, `master` run + 30919216705 published the first successful Scorecard result, CodeQL passed, + and the repository's protected-branch status list included `Dependency +review`. This completes P6.9; the later P6.10 ruleset migration must preserve + that required check rather than moving it into the master-only wait list. #### P6.10 completion bar @@ -1627,9 +1628,11 @@ names. exceptions remain, while the isolated release train has no exceptions. Bounded and scheduled properties cover release parsing/authorization and the Observable lifecycle state machine. -- GitHub/npm WebAuthn, protected-branch/tag/environment/trusted-publisher setup, - and the sole-account disposable-package rehearsal remain explicit external - pre-publication gates. P6.10 remains active until they are verified. +- GitHub/npm WebAuthn and protected-branch/tag/environment/trusted-publisher + setup remain explicit external pre-publication gates. Exact npm pack, + publish, and staged-publish dry runs run before registry access; private + staging of the first real beta is the live OIDC proof. P6.10 remains active + through WebAuthn approval and public verification. #### P6.10 implementation evidence @@ -1650,8 +1653,8 @@ names. every release-PR, qualification, authorization, and staging job individually, with regression tests for both security-sensitive cases. - Local verification is recorded in the P6.10 session entry. Live App, - ruleset, trusted-publisher, disposable-package, TFA, rejection, staged digest, - tag, and immutable-release evidence remain required before `DONE`. + ruleset, trusted-publisher, WebAuthn, staged-digest, tag, provenance, and + immutable-release evidence remain required before `DONE`. #### P6.1 completion bar @@ -3636,3 +3639,25 @@ conformance implementation depends on a runnable harness. - Passed all 53 release/security tests, 177 OSV exception validations, release coherence and doctor checks, workflow formatting, and diff hygiene. P6.10 remains the sole `NEXT` item pending its external setup and rehearsal gates. + +### 2026-08-04 — P6.10 first-beta staging policy and dry-run evidence + +- Replaced the public disposable-package rehearsal with exact-tarball + `npm pack --dry-run`, `npm publish --dry-run`, and + `npm stage publish --dry-run` checks plus stage-only GitHub trusted-publisher + previews. All sixteen dry runs passed over a local four-package + `9.0.0-beta.0` candidate with the pinned npm 11.18.0 CLI and no + registry credentials. The local manifest SHA-512 was + `d8977e21abb704df0c72ed2ba5663e282f81b6e4be24d3ff6a87f9922eadfc22c09de1b04adf998e4c8817f37ce15e669cb714b82c2f983400d9417430526585`; + it is diagnostic evidence, not the future canonical Ubuntu qualification + digest. +- Split the exact release policy into blocking `master` checks, + pull-request-only branch-protection checks, and advisory checks. Added + regression coverage that rejects PR-only or advisory checks in + `RELEASE_REQUIRED_CHECKS`, malformed or duplicate configuration, weak branch + protection, and drift from the exact check set. +- Verified PR #7613's successful Dependency review and the successful + post-merge Scorecard run 30919216705, completing P6.9. P6.10 remains the sole + `NEXT` item: GitHub App/ruleset/environment administration, npm trusted + publishing, canonical Ubuntu qualification, private staging, Ben's WebAuthn + approvals, and public registry verification still have to succeed. diff --git a/package.json b/package.json index 026077d2fc..53d7344ebe 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "analyze:bundles": "node scripts/analyze-bundles.mjs", "prepare-packages": "pnpm nx run-many -t build,lint,test:circular,dtslint,copy_common_package_files --exclude rxjs.dev", "release": "node scripts/release/release-doctor.mjs", - "release:check": "node --test scripts/analyze-bundles.test.mjs scripts/check-package-docs.test.mjs scripts/check-release-coherence.test.mjs scripts/finalize-esm-package.test.mjs scripts/prerelease-adoption-lib.test.mjs scripts/release/authorize-release-commit.test.mjs scripts/release/authorize-stage.test.mjs scripts/release/install-pinned-npm.test.mjs scripts/release/release-config.test.mjs scripts/release/release-policy.test.mjs scripts/release/release-doctor-policy.test.mjs scripts/release/release-candidate.test.mjs scripts/release/stage-release.test.mjs scripts/security/check-osv-exceptions.test.mjs && node scripts/security/check-osv-exceptions.mjs && node scripts/check-package-docs.mjs && node scripts/check-release-coherence.mjs && node scripts/release/release-doctor.mjs", + "release:check": "node --test scripts/analyze-bundles.test.mjs scripts/check-package-docs.test.mjs scripts/check-release-coherence.test.mjs scripts/finalize-esm-package.test.mjs scripts/prerelease-adoption-lib.test.mjs scripts/release/authorize-release-commit.test.mjs scripts/release/authorize-stage.test.mjs scripts/release/install-pinned-npm.test.mjs scripts/release/release-config.test.mjs scripts/release/release-policy.test.mjs scripts/release/release-doctor-policy.test.mjs scripts/release/release-candidate.test.mjs scripts/release/stage-release.test.mjs scripts/release/verify-npm-dry-runs.test.mjs scripts/security/check-osv-exceptions.test.mjs && node scripts/security/check-osv-exceptions.mjs && node scripts/check-package-docs.mjs && node scripts/check-release-coherence.mjs && node scripts/release/release-doctor.mjs", "test:bundle-analysis": "node --test scripts/analyze-bundles.test.mjs", "test:workflows": "prettier --check .github/workflows/*.yml .github/actions/install-dependencies/action.yml .github/dependabot.yml", "test:kernel": "pnpm --filter rxjs run test:kernel", diff --git a/packages/rxjs/docs/SECURITY_ASSURANCE.md b/packages/rxjs/docs/SECURITY_ASSURANCE.md index c9df51f055..ac17b9605f 100644 --- a/packages/rxjs/docs/SECURITY_ASSURANCE.md +++ b/packages/rxjs/docs/SECURITY_ASSURANCE.md @@ -7,6 +7,7 @@ RxJS 9 releases are designed to be hardened, transparent, and independently veri - The `rxjs` runtime depends only on the RxJS-owned `@rxjs/observable-polyfill`; that package has no runtime dependencies. The core runtime chain therefore contains no third-party package. - A candidate is built twice in separate fresh Ubuntu 24.04 jobs with Node 24.12.0 and pnpm 10.34.5, frozen installs, and no restored caches. Filenames, inventories, contents, and SHA-512 values must match. - The exact tarballs that pass package, runtime, browser, Safari, Web Platform Test, bundler, and performance gates are the files sent to npm staging. +- Before registry access, the checked npm CLI runs pack, publish, and staged-publish dry runs over every exact tarball. Dry-run proves packaging behavior, not OIDC authorization; private staging of the first real beta supplies that live proof without creating a public test package. - Every release includes `release-manifest.json`, a CycloneDX SBOM, an OSV report for the isolated release train, a GitHub attestation bundle, and the exact npm tarballs. - npm staging uses trusted publishing bound to `ReactiveX/rxjs`, `.github/workflows/release-stage.yml`, the protected `master` branch, and the `npm-stage` environment. CI has no reusable npm publication token and cannot call direct `npm publish`. - Staged packages require a separate npm WebAuthn approval. `rxjs` is approved last. diff --git a/packages/rxjs/test/release/safari-driver.mjs b/packages/rxjs/test/release/safari-driver.mjs index 53503ce080..7b97ec70b3 100644 --- a/packages/rxjs/test/release/safari-driver.mjs +++ b/packages/rxjs/test/release/safari-driver.mjs @@ -29,7 +29,7 @@ export async function withSafariDriver(run, { port = 4444 } = {}) { try { const baseUrl = `http://127.0.0.1:${port}`; - await waitForDriver(baseUrl, diagnostics); + await waitForDriver(baseUrl, { getDiagnostics: () => diagnostics }); return await run(baseUrl); } finally { driver.kill('SIGTERM'); @@ -76,15 +76,19 @@ export async function deleteSafariSession(baseUrl, sessionId) { await request(baseUrl, `/session/${sessionId}`, { method: 'DELETE' }); } -async function waitForDriver(baseUrl, diagnostics) { - for (let attempt = 0; attempt < 100; attempt++) { +export async function waitForDriver( + baseUrl, + { attempts = 240, retryDelayMs = 250, requestStatus = fetch, wait = delay, getDiagnostics = () => '' } = {} +) { + for (let attempt = 0; attempt < attempts; attempt++) { try { - const response = await fetch(`${baseUrl}/status`); + const response = await requestStatus(`${baseUrl}/status`); if (response.ok) return; } catch {} - await delay(100); + if (attempt + 1 < attempts) await wait(retryDelayMs); } - throw new Error(`SafariDriver did not become ready.\n${diagnostics}`); + const diagnostics = getDiagnostics().trim(); + throw new Error(`SafariDriver did not become ready.${diagnostics ? `\n${diagnostics}` : ''}`); } async function request(baseUrl, pathname, init) { diff --git a/packages/rxjs/test/release/safari-driver.test.mjs b/packages/rxjs/test/release/safari-driver.test.mjs index a1a55d9994..0e628fcab9 100644 --- a/packages/rxjs/test/release/safari-driver.test.mjs +++ b/packages/rxjs/test/release/safari-driver.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { retrySafariSession, safariCapabilities } from './safari-driver.mjs'; +import { retrySafariSession, safariCapabilities, waitForDriver } from './safari-driver.mjs'; test('desktop Safari capabilities request the native desktop browser', () => { assert.deepEqual(safariCapabilities('desktop'), { alwaysMatch: { browserName: 'Safari' } }); @@ -36,3 +36,29 @@ test('Mobile Safari session creation retries one transient launch timeout', asyn assert.equal(result, 'connected'); assert.equal(attempts, 2); }); + +test('SafariDriver readiness tolerates a bounded slow startup', async () => { + let attempts = 0; + await waitForDriver('http://127.0.0.1:4444', { + attempts: 3, + requestStatus: async () => ({ ok: ++attempts === 3 }), + wait: async () => undefined, + }); + assert.equal(attempts, 3); +}); + +test('SafariDriver readiness reports diagnostics collected during startup', async () => { + let diagnostics = ''; + await assert.rejects( + waitForDriver('http://127.0.0.1:4444', { + attempts: 2, + requestStatus: async () => { + diagnostics = 'late safaridriver startup failure'; + throw new Error('not ready'); + }, + wait: async () => undefined, + getDiagnostics: () => diagnostics, + }), + /late safaridriver startup failure/ + ); +}); diff --git a/scripts/check-release-coherence.mjs b/scripts/check-release-coherence.mjs index fdafc61d2e..128ca04359 100644 --- a/scripts/check-release-coherence.mjs +++ b/scripts/check-release-coherence.mjs @@ -139,6 +139,7 @@ function auditReleaseMatrix(input, errors) { 'run test:imports', 'release-candidate.mjs verify', 'release-candidate.mjs hydrate', + 'verify-npm-dry-runs.mjs', 'authorize-stage.mjs', 'stage-release.mjs publish', 'id-token: write', diff --git a/scripts/check-release-coherence.test.mjs b/scripts/check-release-coherence.test.mjs index 43171710ca..3b471e5094 100644 --- a/scripts/check-release-coherence.test.mjs +++ b/scripts/check-release-coherence.test.mjs @@ -66,6 +66,7 @@ function validInput() { 'run test:imports', 'release-candidate.mjs verify', 'release-candidate.mjs hydrate', + 'verify-npm-dry-runs.mjs', 'authorize-stage.mjs', 'stage-release.mjs publish', 'id-token: write', diff --git a/scripts/release/release-config.mjs b/scripts/release/release-config.mjs index 930c367392..5a99b055d3 100644 --- a/scripts/release/release-config.mjs +++ b/scripts/release/release-config.mjs @@ -11,6 +11,26 @@ export const releaseOperatorLogin = 'benlesh'; export const firstReleaseVersion = '9.0.0-beta.0'; export const stagedPackagesVariable = 'NPM_STAGED_PACKAGES_URL'; export const npmWebOrigin = 'https://www.npmjs.com'; +export const releaseRequiredMasterChecks = Object.freeze([ + 'RxJS 9 migration evidence and repository checks (Node 24)', + 'Node 22.13.0 package gates', + 'Node 24 package gates', + 'ts@latest (24)', + 'No unreviewed release-reachable vulnerabilities', + 'CodeQL JavaScript and TypeScript', + 'Pinned Chrome 150 Observable WPT (Node 24)', + 'Chrome, Firefox, WebKit, Webpack, and performance', + 'Deno 2.8.0', + 'Bun 1.3.14', + 'Desktop Safari', + 'Mobile Safari (iOS simulator)', +]); +export const releaseRequiredPullRequestChecks = Object.freeze(['Conventional Commit title', 'Dependency review']); +export const releaseAdvisoryChecks = Object.freeze([ + 'Node 26 package gates (advisory)', + 'Latest stable Chrome Observable WPT (Node 24, advisory)', + 'Scorecard analysis', +]); export const releaseToolchain = Object.freeze({ runner: 'ubuntu-24.04', node: '24.12.0', diff --git a/scripts/release/release-doctor-policy.mjs b/scripts/release/release-doctor-policy.mjs index 0965aaade3..06f33244e7 100644 --- a/scripts/release/release-doctor-policy.mjs +++ b/scripts/release/release-doctor-policy.mjs @@ -1,3 +1,133 @@ +import { releaseAdvisoryChecks, releaseRequiredMasterChecks, releaseRequiredPullRequestChecks } from './release-config.mjs'; + +export function parseConfiguredChecks(value) { + let checks; + try { + checks = JSON.parse(value ?? ''); + } catch { + throw new Error('RELEASE_REQUIRED_CHECKS must be a JSON array of exact check names.'); + } + if (!Array.isArray(checks) || checks.some((check) => typeof check !== 'string' || check.trim() !== check || check.length === 0)) { + throw new Error('RELEASE_REQUIRED_CHECKS must be a JSON array of non-empty, trimmed check names.'); + } + return checks; +} + +export function auditConfiguredMasterChecks(value) { + const errors = []; + let checks; + try { + checks = parseConfiguredChecks(value); + } catch (error) { + return [error.message]; + } + if (checks.length === 0) return ['RELEASE_REQUIRED_CHECKS must list the protected master checks.']; + if (new Set(checks).size !== checks.length) errors.push('RELEASE_REQUIRED_CHECKS contains duplicate check names.'); + + const configured = new Set(checks); + const missing = releaseRequiredMasterChecks.filter((check) => !configured.has(check)); + const unexpected = checks.filter((check) => !releaseRequiredMasterChecks.includes(check)); + if (missing.length > 0) errors.push(`RELEASE_REQUIRED_CHECKS is missing master checks: ${missing.join(', ')}.`); + if (unexpected.length > 0) errors.push(`RELEASE_REQUIRED_CHECKS contains non-master checks: ${unexpected.join(', ')}.`); + + const pullRequestOnly = releaseRequiredPullRequestChecks.filter((check) => configured.has(check)); + if (pullRequestOnly.length > 0) { + errors.push(`Pull-request-only checks belong in branch protection, not the master wait list: ${pullRequestOnly.join(', ')}.`); + } + const advisory = releaseAdvisoryChecks.filter((check) => configured.has(check)); + if (advisory.length > 0) errors.push(`Advisory checks must not block release generation: ${advisory.join(', ')}.`); + return errors; +} + +export function auditBranchRuleset(ruleset) { + const errors = []; + if (ruleset.target !== 'branch' || ruleset.enforcement !== 'active') { + errors.push('The release branch ruleset must be an active branch ruleset.'); + } + if (JSON.stringify(ruleset.conditions?.ref_name) !== JSON.stringify({ include: ['refs/heads/master'], exclude: [] })) { + errors.push('The release branch ruleset must target only refs/heads/master.'); + } + if ((ruleset.bypass_actors ?? []).length !== 0) { + errors.push('The release branch ruleset must not allow bypass actors.'); + } + + const rules = new Map((ruleset.rules ?? []).map((rule) => [rule.type, rule])); + for (const requiredRule of ['deletion', 'non_fast_forward', 'pull_request', 'required_status_checks', 'required_signatures']) { + if (!rules.has(requiredRule)) errors.push(`The release branch ruleset is missing the ${requiredRule} rule.`); + } + + const pullRequest = rules.get('pull_request')?.parameters ?? {}; + if (pullRequest.required_approving_review_count !== 0) { + errors.push('The release branch ruleset must require zero approving reviews.'); + } + if (JSON.stringify(pullRequest.allowed_merge_methods) !== JSON.stringify(['squash'])) { + errors.push('The release branch ruleset must allow only squash merges.'); + } + + const statusParameters = rules.get('required_status_checks')?.parameters ?? {}; + if (statusParameters.strict_required_status_checks_policy !== true) { + errors.push('The release branch ruleset must require branches to be up to date before merging.'); + } + const configuredChecks = (statusParameters.required_status_checks ?? []).map(({ context }) => context); + if (new Set(configuredChecks).size !== configuredChecks.length) { + errors.push('The release branch ruleset contains duplicate required check names.'); + } + const expectedChecks = [...releaseRequiredMasterChecks, ...releaseRequiredPullRequestChecks]; + const configured = new Set(configuredChecks); + const missing = expectedChecks.filter((check) => !configured.has(check)); + const unexpected = configuredChecks.filter((check) => !expectedChecks.includes(check)); + if (missing.length > 0) errors.push(`The release branch ruleset is missing required checks: ${missing.join(', ')}.`); + if (unexpected.length > 0) errors.push(`The release branch ruleset contains unexpected blocking checks: ${unexpected.join(', ')}.`); + return errors; +} + +export function auditTagRuleset(ruleset, releaseAppId) { + const errors = []; + if (ruleset.target !== 'tag' || ruleset.enforcement !== 'active') { + errors.push('The release tag ruleset must be an active tag ruleset.'); + } + if (JSON.stringify(ruleset.conditions?.ref_name) !== JSON.stringify({ include: ['refs/tags/9.*'], exclude: [] })) { + errors.push('The release tag ruleset must target only refs/tags/9.*.'); + } + + const configuredRules = (ruleset.rules ?? []).map(({ type }) => type).sort(); + const requiredRules = ['creation', 'deletion', 'non_fast_forward', 'update']; + if (JSON.stringify(configuredRules) !== JSON.stringify(requiredRules)) { + errors.push(`The release tag ruleset must contain exactly: ${requiredRules.join(', ')}.`); + } + + const expectedBypass = [{ actor_id: Number(releaseAppId), actor_type: 'Integration', bypass_mode: 'always' }]; + if (!Array.isArray(ruleset.bypass_actors)) { + errors.push('The release tag ruleset audit token cannot inspect bypass actors.'); + } else if (JSON.stringify(ruleset.bypass_actors) !== JSON.stringify(expectedBypass)) { + errors.push('The release tag ruleset must allow only the release App integration to create protected tags.'); + } + return errors; +} + +export function auditStageEnvironment(environment) { + const errors = []; + if (environment.name !== 'npm-stage') errors.push('The protected release environment must be named npm-stage.'); + const protectionTypes = (environment.protection_rules ?? []).map(({ type }) => type); + if (protectionTypes.some((type) => type !== 'branch_policy')) { + errors.push('The npm-stage environment must not require reviewers, wait timers, or custom protection gates.'); + } + if ( + JSON.stringify(environment.deployment_branch_policy) !== JSON.stringify({ protected_branches: true, custom_branch_policies: false }) + ) { + errors.push('The npm-stage environment must allow only protected branches.'); + } + return errors; +} + +export function auditReleaseAppRepositories(payload) { + const repositories = (payload.repositories ?? []).map(({ full_name }) => full_name); + if (payload.total_count !== 1 || repositories.length !== 1 || repositories[0] !== 'ReactiveX/rxjs') { + return ['The release App installation must have access only to ReactiveX/rxjs.']; + } + return []; +} + export function requireWorkflowJobRunners(source, workflowName, expectedRunners) { const lines = source.split(/\r?\n/); const errors = []; diff --git a/scripts/release/release-doctor-policy.test.mjs b/scripts/release/release-doctor-policy.test.mjs index d7198a7ac3..847a294b7e 100644 --- a/scripts/release/release-doctor-policy.test.mjs +++ b/scripts/release/release-doctor-policy.test.mjs @@ -1,9 +1,168 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { requireWorkflowJobRunners } from './release-doctor-policy.mjs'; +import { releaseAdvisoryChecks, releaseRequiredMasterChecks, releaseRequiredPullRequestChecks } from './release-config.mjs'; +import { + auditBranchRuleset, + auditConfiguredMasterChecks, + auditReleaseAppRepositories, + auditStageEnvironment, + auditTagRuleset, + parseConfiguredChecks, + requireWorkflowJobRunners, +} from './release-doctor-policy.mjs'; const privilegedRunners = { authorize: 'ubuntu-24.04', stage: 'ubuntu-24.04' }; +function validBranchRuleset() { + return { + target: 'branch', + enforcement: 'active', + bypass_actors: [], + conditions: { ref_name: { include: ['refs/heads/master'], exclude: [] } }, + rules: [ + { type: 'deletion' }, + { type: 'non_fast_forward' }, + { type: 'required_signatures' }, + { type: 'pull_request', parameters: { required_approving_review_count: 0, allowed_merge_methods: ['squash'] } }, + { + type: 'required_status_checks', + parameters: { + strict_required_status_checks_policy: true, + required_status_checks: [...releaseRequiredMasterChecks, ...releaseRequiredPullRequestChecks].map((context) => ({ + context, + integration_id: 15368, + })), + }, + }, + ], + }; +} + +function validTagRuleset() { + return { + target: 'tag', + enforcement: 'active', + bypass_actors: [{ actor_id: 1234, actor_type: 'Integration', bypass_mode: 'always' }], + conditions: { ref_name: { include: ['refs/tags/9.*'], exclude: [] } }, + rules: [{ type: 'creation' }, { type: 'update' }, { type: 'deletion' }, { type: 'non_fast_forward' }], + }; +} + +test('parses and accepts only the exact master check list', () => { + const configured = JSON.stringify(releaseRequiredMasterChecks); + assert.deepEqual(parseConfiguredChecks(configured), releaseRequiredMasterChecks); + assert.deepEqual(auditConfiguredMasterChecks(configured), []); +}); + +test('rejects missing, duplicate, pull-request-only, and advisory master checks', () => { + const configured = [ + ...releaseRequiredMasterChecks.slice(1), + releaseRequiredMasterChecks[1], + releaseRequiredPullRequestChecks[0], + releaseAdvisoryChecks[0], + ]; + const serialized = JSON.stringify(configured); + const errors = auditConfiguredMasterChecks(serialized).join('\n'); + assert.match(errors, /duplicate check names/); + assert.match(errors, /missing master checks/); + assert.match(errors, /non-master checks/); + assert.match(errors, /Pull-request-only checks/); + assert.match(errors, /Advisory checks/); +}); + +test('rejects lossy comma-separated and malformed JSON check lists', () => { + assert.match(auditConfiguredMasterChecks(releaseRequiredMasterChecks.join(',')).join('\n'), /JSON array/); + assert.match(auditConfiguredMasterChecks(JSON.stringify([' Dependency review'])).join('\n'), /non-empty, trimmed/); +}); + +test('accepts the exact protected branch contract', () => { + assert.deepEqual(auditBranchRuleset(validBranchRuleset()), []); +}); + +test('rejects missing pull-request checks and unexpected advisory blockers', () => { + const ruleset = validBranchRuleset(); + const statusChecks = ruleset.rules.find(({ type }) => type === 'required_status_checks').parameters.required_status_checks; + statusChecks.splice( + statusChecks.findIndex(({ context }) => context === 'Dependency review'), + 1 + ); + statusChecks.push({ context: releaseAdvisoryChecks[0], integration_id: 15368 }); + const errors = auditBranchRuleset(ruleset).join('\n'); + assert.match(errors, /missing required checks: Dependency review/); + assert.match(errors, /unexpected blocking checks: Node 26 package gates/); +}); + +test('rejects a non-strict or weak branch ruleset', () => { + const ruleset = validBranchRuleset(); + ruleset.rules.find(({ type }) => type === 'required_status_checks').parameters.strict_required_status_checks_policy = false; + ruleset.rules.find(({ type }) => type === 'pull_request').parameters.required_approving_review_count = 1; + const errors = auditBranchRuleset(ruleset).join('\n'); + assert.match(errors, /zero approving reviews/); + assert.match(errors, /up to date before merging/); +}); + +test('rejects branch ruleset scope or bypass drift', () => { + const ruleset = validBranchRuleset(); + ruleset.conditions.ref_name.include.push('refs/heads/release/*'); + ruleset.bypass_actors.push({ actor_id: 1, actor_type: 'RepositoryRole', bypass_mode: 'always' }); + const errors = auditBranchRuleset(ruleset).join('\n'); + assert.match(errors, /target only refs\/heads\/master/); + assert.match(errors, /must not allow bypass actors/); +}); + +test('accepts only release App bypass for the exact RxJS 9 tag contract', () => { + assert.deepEqual(auditTagRuleset(validTagRuleset(), '1234'), []); +}); + +test('rejects uninspectable or broadened tag protection', () => { + const ruleset = validTagRuleset(); + ruleset.conditions.ref_name.include.push('refs/tags/10.*'); + ruleset.rules.push({ type: 'required_signatures' }); + delete ruleset.bypass_actors; + const errors = auditTagRuleset(ruleset, '1234').join('\n'); + assert.match(errors, /target only refs\/tags\/9/); + assert.match(errors, /contain exactly/); + assert.match(errors, /cannot inspect bypass actors/); +}); + +test('rejects a tag bypass actor other than the release App', () => { + const ruleset = validTagRuleset(); + ruleset.bypass_actors[0].actor_id = 5678; + assert.match(auditTagRuleset(ruleset, '1234').join('\n'), /allow only the release App integration/); +}); + +test('accepts the protected npm-stage environment without reviewers', () => { + assert.deepEqual( + auditStageEnvironment({ + name: 'npm-stage', + protection_rules: [{ type: 'branch_policy' }], + deployment_branch_policy: { protected_branches: true, custom_branch_policies: false }, + }), + [] + ); +}); + +test('rejects npm-stage reviewers and unprotected branches', () => { + const errors = auditStageEnvironment({ + name: 'npm-stage', + protection_rules: [{ type: 'required_reviewers' }], + deployment_branch_policy: { protected_branches: false, custom_branch_policies: true }, + }).join('\n'); + assert.match(errors, /must not require reviewers/); + assert.match(errors, /only protected branches/); +}); + +test('accepts a release App installation limited to ReactiveX/rxjs', () => { + assert.deepEqual(auditReleaseAppRepositories({ total_count: 1, repositories: [{ full_name: 'ReactiveX/rxjs' }] }), []); + assert.match( + auditReleaseAppRepositories({ + total_count: 2, + repositories: [{ full_name: 'ReactiveX/rxjs' }, { full_name: 'ReactiveX/other' }], + }).join('\n'), + /only to ReactiveX\/rxjs/ + ); +}); + test('accepts the required runner on each privileged release job', () => { const workflow = `jobs: authorize: diff --git a/scripts/release/release-doctor.mjs b/scripts/release/release-doctor.mjs index d0cc62fa13..3b5d06f5a7 100644 --- a/scripts/release/release-doctor.mjs +++ b/scripts/release/release-doctor.mjs @@ -4,7 +4,14 @@ import { readFile, readdir } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { assertNpmWebUrl, releaseOperatorLogin, releasePackages, releaseToolchain, stagedPackagesVariable } from './release-config.mjs'; -import { requireWorkflowJobRunners } from './release-doctor-policy.mjs'; +import { + auditBranchRuleset, + auditConfiguredMasterChecks, + auditReleaseAppRepositories, + auditStageEnvironment, + auditTagRuleset, + requireWorkflowJobRunners, +} from './release-doctor-policy.mjs'; const root = fileURLToPath(new URL('../..', import.meta.url)); const strict = process.argv.includes('--strict'); @@ -53,9 +60,19 @@ for (const requirement of [ 'authorize-release-commit.mjs', 'stage-release.mjs publish', 'release-candidate.mjs verify', + 'environment: npm-stage', + 'id: release-app-token', + 'permission-contents: write', + 'permission-pull-requests: write', + 'token: ${{ steps.release-app-token.outputs.token }}', ]) { if (!stageWorkflow.includes(requirement)) errors.push(`release-stage.yml is missing ${requirement}.`); } +for (const match of stageWorkflow.matchAll(/secrets\.([A-Z0-9_]+)/g)) { + if (match[1] !== 'RELEASE_APP_PRIVATE_KEY') { + errors.push(`release-stage.yml must not consume the ${match[1]} secret; npm-stage has no environment secrets.`); + } +} errors.push( ...requireWorkflowJobRunners(stageWorkflow, 'release-stage.yml', { authorize: 'ubuntu-24.04', @@ -66,6 +83,7 @@ for (const requirement of [ 'matrix: { build: [a, b] }', 'compare-release-candidates.mjs', 'Exact tarballs / package, type, import, and migration gates', + 'verify-npm-dry-runs.mjs', "node-version: '24.12.0'", 'generate-release-evidence.mjs', 'osv-scanner-action@', @@ -105,6 +123,21 @@ const stageScript = await readFile(path.join(root, 'scripts/release/stage-releas if (!stageScript.includes("['stage', 'download', stageId]")) { errors.push('stage-release.mjs must download each private npm stage before approval.'); } +const npmDryRunScript = await readFile(path.join(root, 'scripts/release/verify-npm-dry-runs.mjs'), 'utf8').catch(() => ''); +for (const trustInput of [ + "'trust'", + "'github'", + "'release-stage.yml'", + "'ReactiveX/rxjs'", + "'npm-stage'", + "'--allow-stage-publish'", + "'--dry-run'", +]) { + if (!npmDryRunScript.includes(trustInput)) errors.push(`verify-npm-dry-runs.mjs is missing trusted-publisher input ${trustInput}.`); +} +if (/['"]--allow-publish['"]/.test(npmDryRunScript)) { + errors.push('The trusted-publisher preview must not grant direct npm publish authority.'); +} const releasePullRequestWorkflow = await readFile(path.join(root, '.github/workflows/release-pr.yml'), 'utf8').catch(() => ''); errors.push(...requireWorkflowJobRunners(releasePullRequestWorkflow, 'release-pr.yml', { 'release-pr': 'ubuntu-24.04' })); @@ -180,17 +213,13 @@ if (strict || process.env[stagedPackagesVariable]) { } if (strict) { - const requiredChecks = (process.env.RELEASE_REQUIRED_CHECKS ?? '') - .split(',') - .map((value) => value.trim()) - .filter(Boolean); - if (requiredChecks.length === 0) errors.push('RELEASE_REQUIRED_CHECKS must list the protected master checks.'); - if (new Set(requiredChecks).size !== requiredChecks.length) errors.push('RELEASE_REQUIRED_CHECKS contains duplicate check names.'); - for (const requiredSignal of ['codeql', 'dependency', 'osv', 'migration evidence', 'package gates', 'wpt', 'release readiness']) { - if (!requiredChecks.some((check) => check.toLowerCase().includes(requiredSignal))) { - errors.push(`RELEASE_REQUIRED_CHECKS does not include the ${requiredSignal} gate.`); - } + if (!/^\d+$/.test(process.env.RELEASE_APP_ID ?? '')) errors.push('RELEASE_APP_ID must be configured as a numeric GitHub App ID.'); + if (process.env.RELEASE_APP_PRIVATE_KEY_PRESENT !== 'true') { + errors.push('RELEASE_APP_PRIVATE_KEY must be configured without exposing its value to the release doctor.'); } + errors.push(...auditConfiguredMasterChecks(process.env.RELEASE_REQUIRED_CHECKS)); + await validateReleaseAppInstallation(); + await validateStageEnvironment(); await validateBranchRuleset(); await validateTagRuleset(); } @@ -220,21 +249,43 @@ async function validateTagRuleset() { }); if (!response.ok) throw new Error(`GitHub returned HTTP ${response.status}.`); const ruleset = await response.json(); - if (ruleset.target !== 'tag' || ruleset.enforcement !== 'active') { - errors.push(`Ruleset ${rulesetId} must be an active tag ruleset.`); - } - if (!ruleset.conditions?.ref_name?.include?.includes('refs/tags/9.*')) { - errors.push(`Ruleset ${rulesetId} must include refs/tags/9.*.`); - } - const rules = new Set((ruleset.rules ?? []).map(({ type }) => type)); - for (const requiredRule of ['creation', 'update', 'deletion', 'non_fast_forward']) { - if (!rules.has(requiredRule)) errors.push(`Ruleset ${rulesetId} is missing the ${requiredRule} rule.`); - } + errors.push(...auditTagRuleset(ruleset, process.env.RELEASE_APP_ID).map((error) => `Ruleset ${rulesetId}: ${error}`)); } catch (error) { errors.push(`Could not audit release tag ruleset ${rulesetId}: ${error.message}`); } } +async function validateReleaseAppInstallation() { + const token = process.env.RELEASE_APP_TOKEN; + if (!token) { + errors.push('RELEASE_APP_TOKEN is required to audit the release App installation.'); + return; + } + try { + const response = await githubFetch('https://api.github.com/installation/repositories?per_page=100', token); + if (!response.ok) throw new Error(`GitHub returned HTTP ${response.status}.`); + errors.push(...auditReleaseAppRepositories(await response.json())); + } catch (error) { + errors.push(`Could not audit the release App installation: ${error.message}`); + } +} + +async function validateStageEnvironment() { + const repository = process.env.GITHUB_REPOSITORY; + const token = process.env.GH_TOKEN; + if (!repository || !token) { + errors.push('GITHUB_REPOSITORY and GH_TOKEN are required to audit npm-stage.'); + return; + } + try { + const response = await githubFetch(`https://api.github.com/repos/${repository}/environments/${encodeURIComponent('npm-stage')}`, token); + if (!response.ok) throw new Error(`GitHub returned HTTP ${response.status}.`); + errors.push(...auditStageEnvironment(await response.json())); + } catch (error) { + errors.push(`Could not audit the npm-stage environment: ${error.message}`); + } +} + async function validateBranchRuleset() { const rulesetId = process.env.RELEASE_BRANCH_RULESET_ID; if (!rulesetId) { @@ -243,21 +294,7 @@ async function validateBranchRuleset() { } try { const ruleset = await readRuleset(rulesetId); - if (ruleset.target !== 'branch' || ruleset.enforcement !== 'active') { - errors.push(`Ruleset ${rulesetId} must be an active branch ruleset.`); - } - if (!ruleset.conditions?.ref_name?.include?.includes('refs/heads/master')) { - errors.push(`Ruleset ${rulesetId} must include refs/heads/master.`); - } - const rules = new Map((ruleset.rules ?? []).map((rule) => [rule.type, rule])); - for (const requiredRule of ['deletion', 'non_fast_forward', 'pull_request', 'required_status_checks', 'required_signatures']) { - if (!rules.has(requiredRule)) errors.push(`Ruleset ${rulesetId} is missing the ${requiredRule} rule.`); - } - const pullRequest = rules.get('pull_request')?.parameters ?? {}; - if (pullRequest.required_approving_review_count !== 0) errors.push(`Ruleset ${rulesetId} must require zero approving reviews.`); - if (JSON.stringify(pullRequest.allowed_merge_methods) !== JSON.stringify(['squash'])) { - errors.push(`Ruleset ${rulesetId} must allow only squash merges.`); - } + errors.push(...auditBranchRuleset(ruleset).map((error) => `Ruleset ${rulesetId}: ${error}`)); } catch (error) { errors.push(`Could not audit release branch ruleset ${rulesetId}: ${error.message}`); } @@ -267,13 +304,17 @@ async function readRuleset(rulesetId) { const repository = process.env.GITHUB_REPOSITORY; const token = process.env.GH_TOKEN; if (!repository || !token) throw new Error('GITHUB_REPOSITORY and GH_TOKEN are required.'); - const response = await fetch(`https://api.github.com/repos/${repository}/rulesets/${encodeURIComponent(rulesetId)}`, { + const response = await githubFetch(`https://api.github.com/repos/${repository}/rulesets/${encodeURIComponent(rulesetId)}`, token); + if (!response.ok) throw new Error(`GitHub returned HTTP ${response.status}.`); + return response.json(); +} + +function githubFetch(url, token) { + return fetch(url, { headers: { accept: 'application/vnd.github+json', authorization: `Bearer ${token}`, 'x-github-api-version': '2022-11-28', }, }); - if (!response.ok) throw new Error(`GitHub returned HTTP ${response.status}.`); - return response.json(); } diff --git a/scripts/release/verify-npm-dry-runs.mjs b/scripts/release/verify-npm-dry-runs.mjs new file mode 100644 index 0000000000..13a4257617 --- /dev/null +++ b/scripts/release/verify-npm-dry-runs.mjs @@ -0,0 +1,88 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { verifyCandidate } from './release-candidate.mjs'; + +const root = fileURLToPath(new URL('../..', import.meta.url)); + +export function buildNpmDryRunCommands(manifest, candidateRoot) { + return manifest.packages.flatMap((entry) => { + const tarball = path.join(candidateRoot, entry.filename); + return [ + { packageName: entry.name, operation: 'pack', args: ['pack', tarball, '--dry-run', '--json', '--ignore-scripts'] }, + { + packageName: entry.name, + operation: 'publish', + args: ['publish', tarball, '--dry-run', '--json', '--ignore-scripts', '--tag', manifest.channel], + }, + { + packageName: entry.name, + operation: 'stage publish', + args: ['stage', 'publish', tarball, '--dry-run', '--json', '--ignore-scripts', '--tag', manifest.channel], + }, + { + packageName: entry.name, + operation: 'trust github', + args: [ + 'trust', + 'github', + entry.name, + '--file', + 'release-stage.yml', + '--repository', + 'ReactiveX/rxjs', + '--environment', + 'npm-stage', + '--allow-stage-publish', + '--dry-run', + '--json', + '--yes', + ], + }, + ]; + }); +} + +export async function verifyNpmDryRuns(candidateRoot, { npmBin = process.env.NPM_DRY_RUN_BIN ?? 'npm', run = spawnSync } = {}) { + const manifest = await verifyCandidate(candidateRoot, { expectedSourceCommit: process.env.RELEASE_EXPECTED_SOURCE_COMMIT }); + const cache = await mkdtemp(path.join(tmpdir(), 'rxjs-release-dry-run-cache-')); + const env = { + ...process.env, + NPM_CONFIG_CACHE: cache, + NPM_CONFIG_DRY_RUN: 'true', + NPM_CONFIG_FUND: 'false', + NPM_CONFIG_PROVENANCE: 'false', + NPM_CONFIG_UPDATE_NOTIFIER: 'false', + }; + delete env.NODE_AUTH_TOKEN; + delete env.NPM_TOKEN; + try { + for (const command of buildNpmDryRunCommands(manifest, candidateRoot)) { + if (!command.args.includes('--dry-run')) throw new Error(`Refusing non-dry-run npm ${command.operation} for ${command.packageName}.`); + const result = run(npmBin, command.args, { cwd: root, encoding: 'utf8', env }); + if (result.status !== 0) { + throw new Error( + `npm ${command.operation} --dry-run failed for ${command.packageName} (${result.status}).\n${result.stdout ?? ''}${ + result.stderr ?? '' + }` + ); + } + process.stdout.write(`Verified npm ${command.operation} --dry-run for ${command.packageName}@${manifest.version}.\n`); + } + } finally { + await rm(cache, { recursive: true, force: true }); + } + return manifest; +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + const candidateRoot = path.resolve(root, process.argv[2] ?? '.release/candidate'); + verifyNpmDryRuns(candidateRoot).catch((error) => { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + }); +} diff --git a/scripts/release/verify-npm-dry-runs.test.mjs b/scripts/release/verify-npm-dry-runs.test.mjs new file mode 100644 index 0000000000..418a2be7cd --- /dev/null +++ b/scripts/release/verify-npm-dry-runs.test.mjs @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import test from 'node:test'; +import { releasePackages } from './release-config.mjs'; +import { buildNpmDryRunCommands } from './verify-npm-dry-runs.mjs'; + +test('builds only explicit dry-run commands over the exact candidate tarballs', () => { + const candidateRoot = '/candidate'; + const manifest = { + version: '9.0.0-beta.0', + channel: 'next', + packages: releasePackages.map(({ name }, index) => ({ name, filename: `package-${index}.tgz` })), + }; + const commands = buildNpmDryRunCommands(manifest, candidateRoot); + + assert.equal(commands.length, releasePackages.length * 4); + assert.deepEqual( + commands.map(({ packageName }) => packageName), + releasePackages.flatMap(({ name }) => [name, name, name, name]) + ); + for (const [index, command] of commands.entries()) { + assert.ok(command.args.includes('--dry-run')); + const packageIndex = Math.floor(index / 4); + if (command.operation === 'trust github') { + assert.ok(command.args.includes(releasePackages[packageIndex].name)); + assert.ok(command.args.includes('--allow-stage-publish')); + assert.ok(!command.args.includes('--allow-publish')); + assert.deepEqual(command.args.slice(3, 10), [ + '--file', + 'release-stage.yml', + '--repository', + 'ReactiveX/rxjs', + '--environment', + 'npm-stage', + '--allow-stage-publish', + ]); + } else { + assert.ok(command.args.includes(path.join(candidateRoot, `package-${packageIndex}.tgz`))); + if (command.operation !== 'pack') assert.deepEqual(command.args.slice(-2), ['--tag', 'next']); + } + } + assert.deepEqual( + commands.slice(0, 4).map(({ operation }) => operation), + ['pack', 'publish', 'stage publish', 'trust github'] + ); +}); diff --git a/scripts/release/wait-for-required-checks.mjs b/scripts/release/wait-for-required-checks.mjs index 7e80104b9f..bded31f9cd 100644 --- a/scripts/release/wait-for-required-checks.mjs +++ b/scripts/release/wait-for-required-checks.mjs @@ -1,14 +1,15 @@ #!/usr/bin/env node +import { auditConfiguredMasterChecks, parseConfiguredChecks } from './release-doctor-policy.mjs'; + const [repository, commit] = process.argv.slice(2); const token = process.env.GH_TOKEN; -const required = (process.env.RELEASE_REQUIRED_CHECKS ?? '') - .split(',') - .map((value) => value.trim()) - .filter(Boolean); -if (!repository || !commit || !token || required.length === 0) { - throw new Error('Repository, commit, GH_TOKEN, and comma-separated RELEASE_REQUIRED_CHECKS are required.'); +const requiredCheckErrors = auditConfiguredMasterChecks(process.env.RELEASE_REQUIRED_CHECKS); +if (!repository || !commit || !token) { + throw new Error('Repository, commit, GH_TOKEN, and JSON-array RELEASE_REQUIRED_CHECKS are required.'); } +if (requiredCheckErrors.length > 0) throw new Error(`Invalid RELEASE_REQUIRED_CHECKS:\n- ${requiredCheckErrors.join('\n- ')}`); +const required = parseConfiguredChecks(process.env.RELEASE_REQUIRED_CHECKS); const deadline = Date.now() + 30 * 60_000; while (true) {