diff --git a/.github/workflows/finalize-release.yml b/.github/workflows/finalize-release.yml deleted file mode 100644 index 1c85ad3087..0000000000 --- a/.github/workflows/finalize-release.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Finalize staged release - -on: - schedule: - - cron: '17 * * * *' - workflow_dispatch: - -permissions: - contents: write - -jobs: - finalize: - name: Finalize fully approved release - runs-on: ubuntu-24.04 - permissions: - attestations: read - contents: write - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: { persist-credentials: false } - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: { node-version: '24.12.0', package-manager-cache: false } - - name: Install checked npm 11.18.0 - run: | - npm_bin=$(node scripts/release/install-pinned-npm.mjs "$RUNNER_TEMP/npm-cli") - echo "$(dirname "$npm_bin")" >> "$GITHUB_PATH" - - name: Publish immutable GitHub Releases only after registry, signatures, and attestations verify - env: - GH_TOKEN: ${{ github.token }} - run: | - gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100" --jq '.[] | select(.draft == true) | .tag_name' | while read -r version; do - candidate_dir="$RUNNER_TEMP/$version" - mkdir -p "$candidate_dir" - gh release download "$version" --dir "$candidate_dir" - for tarball in "$candidate_dir"/*.tgz; do gh attestation verify "$tarball" --repo "$GITHUB_REPOSITORY"; done - if node scripts/release/finalize-release.mjs "$candidate_dir"; then - if [[ "$version" == *-* ]]; then - gh release edit "$version" --draft=false --prerelease --latest=false - else - gh release edit "$version" --draft=false --latest - fi - fi - done diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml deleted file mode 100644 index f98c6954b1..0000000000 --- a/.github/workflows/release-doctor.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Release doctor - -on: - schedule: - - cron: '41 8 1 * *' - workflow_dispatch: - -permissions: - actions: read - contents: read - -jobs: - doctor: - name: Read-only release doctor - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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 }} - run: node scripts/release/release-doctor.mjs --strict diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml deleted file mode 100644 index 40710ebbd9..0000000000 --- a/.github/workflows/release-pr.yml +++ /dev/null @@ -1,116 +0,0 @@ -name: Generate release PR - -on: - workflow_run: - workflows: ['Release readiness'] - types: [completed] - workflow_dispatch: - inputs: - mode: - description: Release mode - required: true - default: auto - type: choice - options: [auto, promote-stable] - -permissions: - contents: read - -concurrency: - group: rxjs-9-release-pr - cancel-in-progress: true - -jobs: - release-pr: - if: >- - (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master') || - (github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' && - github.event.workflow_run.head_branch == 'master' && - github.event.workflow_run.head_repository.full_name == github.repository) - runs-on: ubuntu-24.04 - steps: - - name: Mint short-lived release App token - id: 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-checks: read - permission-contents: write - permission-pull-requests: write - - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.workflow_run.head_sha || github.sha }} - fetch-depth: 0 - token: ${{ steps.app-token.outputs.token }} - - - uses: pnpm/action-setup@9fd676a19091d4595eefd76e4bd31c97133911f1 # v4.2.0 - with: - version: 10.34.5 - run_install: false - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: '24.12.0' - package-manager-cache: false - - - name: Wait for every configured master check - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - RELEASE_REQUIRED_CHECKS: ${{ vars.RELEASE_REQUIRED_CHECKS }} - run: node scripts/release/wait-for-required-checks.mjs "$GITHUB_REPOSITORY" "${{ github.event.workflow_run.head_sha || github.sha }}" - - - name: Install from the frozen lockfile without caches - run: pnpm install --frozen-lockfile - - - name: Generate versions, changelog, and release PR body - id: plan - env: - NPM_STAGED_PACKAGES_URL: ${{ vars.NPM_STAGED_PACKAGES_URL }} - RELEASE_MODE: ${{ inputs.mode || 'auto' }} - run: | - node scripts/release/prepare-release-pr.mjs --mode "$RELEASE_MODE" --output "$RUNNER_TEMP/release-pr.md" --result "$RUNNER_TEMP/release-result.json" - status=$(node -e "console.log(JSON.parse(require('node:fs').readFileSync(process.argv[1])).status)" "$RUNNER_TEMP/release-result.json") - echo "status=$status" >> "$GITHUB_OUTPUT" - if [ "$status" = planned ]; then - version=$(node -e "console.log(JSON.parse(require('node:fs').readFileSync(process.argv[1])).version)" "$RUNNER_TEMP/release-result.json") - echo "version=$version" >> "$GITHUB_OUTPUT" - fi - - - name: Update the lockfile without lifecycle scripts - if: steps.plan.outputs.status == 'planned' - run: pnpm install --lockfile-only --ignore-scripts - - - name: Enforce the release-bot file boundary - if: steps.plan.outputs.status == 'planned' - run: node scripts/release/check-release-bot-diff.mjs HEAD - - - name: Refresh the single release PR - if: steps.plan.outputs.status == 'planned' - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - VERSION: ${{ steps.plan.outputs.version }} - run: | - git config user.name "rxjs-release-app[bot]" - git config user.email "rxjs-release-app[bot]@users.noreply.github.com" - if git ls-remote --exit-code origin refs/heads/release/rxjs-9 >/dev/null; then - git fetch origin refs/heads/release/rxjs-9:refs/remotes/origin/release/rxjs-9 - fi - git checkout -B release/rxjs-9 - git add CHANGELOG.md pnpm-lock.yaml packages/*/package.json packages/observable-polyfill/src/index.ts packages/observable-polyfill/test/import/esm.mjs packages/observable-polyfill/test/import/commonjs.cjs packages/rxjs/test/import/fixture-scenario.mjs packages/migrate/src/version.ts .agents/skills/rxjs-next-migration/.rxjs-migrate-skill.json - git commit -m "chore(release): $VERSION" - git push --force-with-lease origin release/rxjs-9 - pr_number=$(gh pr list --head release/rxjs-9 --state open --json number --jq '.[0].number') - if [ -z "$pr_number" ]; then - gh pr create --base master --head release/rxjs-9 --title "chore(release): $VERSION" --body-file "$RUNNER_TEMP/release-pr.md" - else - gh pr edit "$pr_number" --title "chore(release): $VERSION" --body-file "$RUNNER_TEMP/release-pr.md" - fi - - - name: Block the 9.x train after a stable breaking change - if: steps.plan.outputs.status == 'blocked' - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - run: | - gh pr list --head release/rxjs-9 --state open --json number --jq '.[].number' | while read -r number; do gh pr close "$number" --comment "Release blocked: SemVer requires RxJS 10. No 9.x release is available."; done diff --git a/.github/workflows/release-qualify.yml b/.github/workflows/release-qualify.yml deleted file mode 100644 index 26e5bf24b6..0000000000 --- a/.github/workflows/release-qualify.yml +++ /dev/null @@ -1,290 +0,0 @@ -name: Qualify RxJS 9 release - -on: - push: - branches: ['master'] - paths: - - 'CHANGELOG.md' - - 'packages/migrate/package.json' - - 'packages/observable-polyfill/package.json' - - 'packages/rxjs/package.json' - - 'packages/test/package.json' - -permissions: - contents: read - -concurrency: - group: rxjs-9-release-qualification - cancel-in-progress: false - -env: - BUILD_ARTIFACT_PREFIX: rxjs-release-build-${{ github.run_id }} - CORE_ARTIFACT: rxjs-release-core-${{ github.run_id }} - CANDIDATE_ARTIFACT: rxjs-release-candidate-${{ github.run_id }} - RELEASE_EXPECTED_SOURCE_COMMIT: ${{ github.sha }} - -jobs: - build: - name: Independent clean build ${{ matrix.build }} - strategy: - fail-fast: false - matrix: { build: [a, b] } - runs-on: ubuntu-24.04 - permissions: - contents: read - pull-requests: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: { fetch-depth: 0, persist-credentials: false } - - uses: pnpm/action-setup@9fd676a19091d4595eefd76e4bd31c97133911f1 # v4.2.0 - with: { version: 10.34.5, run_install: false } - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: { node-version: '24.12.0', package-manager-cache: false } - - name: Verify that the generated release PR authorized this commit - id: authorize - env: { GH_TOKEN: '${{ github.token }}' } - run: | - pr_number=$(node scripts/release/authorize-release-commit.mjs "$GITHUB_REPOSITORY" "$GITHUB_SHA") - echo "pull-request=$pr_number" >> "$GITHUB_OUTPUT" - - name: Fresh frozen dependency install with no restored cache - run: pnpm install --frozen-lockfile - - name: Verify release policy and repository configuration - run: pnpm run release:check - - name: Build release packages - run: pnpm run prepare-packages - - name: Pack, inventory, and hash this independent candidate - env: - RELEASE_AUTHORIZING_PR: ${{ steps.authorize.outputs.pull-request }} - RELEASE_BUILD_ID: ${{ matrix.build }} - RELEASE_RUNNER_IMAGE: ubuntu-24.04 - run: node scripts/release/release-candidate.mjs build .release/candidate - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: ${{ env.BUILD_ARTIFACT_PREFIX }}-${{ matrix.build }} - path: .release/candidate - if-no-files-found: error - retention-days: 7 - - compare: - name: Require byte-identical independent builds - needs: build - runs-on: ubuntu-24.04 - outputs: - version: ${{ steps.details.outputs.version }} - pull-request: ${{ steps.details.outputs.pull-request }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: { persist-credentials: false } - - 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 - with: { pattern: 'rxjs-release-build-${{ github.run_id }}-*', path: .release/builds } - - run: >- - node scripts/release/compare-release-candidates.mjs - .release/builds/rxjs-release-build-${{ github.run_id }}-a - .release/builds/rxjs-release-build-${{ github.run_id }}-b - .release/candidate - - id: details - run: | - echo "version=$(node -e 'console.log(require("./.release/candidate/release-manifest.json").version)')" >> "$GITHUB_OUTPUT" - echo "pull-request=$(node -e 'console.log(require("./.release/candidate/release-manifest.json").authorizingPullRequest)')" >> "$GITHUB_OUTPUT" - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: { name: '${{ env.CORE_ARTIFACT }}', path: .release/candidate, if-no-files-found: error, retention-days: 7 } - - package: - name: Exact tarballs / package, type, import, and migration gates - needs: compare - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: { persist-credentials: false } - - uses: pnpm/action-setup@9fd676a19091d4595eefd76e4bd31c97133911f1 # v4.2.0 - with: { version: 10.34.5, run_install: false } - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: { node-version: '24.12.0', package-manager-cache: false } - - run: pnpm install --frozen-lockfile - - 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 - pnpm --filter rxjs exec vitest --run src - - name: Type-check and exercise the hydrated canonical packages - run: | - pnpm --filter @rxjs/observable-polyfill --filter @rxjs/test --filter @rxjs/migrate --filter rxjs run test:types - pnpm --filter @rxjs/observable-polyfill --filter @rxjs/test --filter @rxjs/migrate --filter rxjs run test:imports - pnpm --filter @rxjs/migrate run test:pack - pnpm --filter rxjs run test:migration-contracts - - node: - name: Exact tarballs / Node ${{ matrix.node }}${{ matrix.advisory && ' (advisory)' || '' }} - needs: compare - runs-on: ubuntu-24.04 - continue-on-error: ${{ matrix.advisory }} - strategy: - fail-fast: false - matrix: - include: - - { node: '22.13.0', advisory: false } - - { node: '24.12.0', advisory: false } - - { node: '26', advisory: true } - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: { persist-credentials: false } - - uses: pnpm/action-setup@9fd676a19091d4595eefd76e4bd31c97133911f1 # v4.2.0 - with: { version: 10.34.5, run_install: false } - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: { node-version: '${{ matrix.node }}', package-manager-cache: false } - - run: pnpm install --frozen-lockfile - - 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 - - run: pnpm run test:release:runtime - - browser: - name: Exact tarballs / browsers, Webpack, performance - needs: compare - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: { persist-credentials: false } - - uses: pnpm/action-setup@9fd676a19091d4595eefd76e4bd31c97133911f1 # v4.2.0 - with: { version: 10.34.5, run_install: false } - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: { node-version: '24.12.0', package-manager-cache: false } - - run: pnpm install --frozen-lockfile - - 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 - - run: pnpm exec playwright install --with-deps chromium firefox webkit - - run: pnpm run test:release:browsers && pnpm run test:release:webpack && pnpm run test:release:performance - - alternate-runtime: - name: Exact tarballs / ${{ matrix.runtime }} - needs: compare - runs-on: ubuntu-24.04 - strategy: { fail-fast: false, matrix: { runtime: [deno, bun] } } - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: { persist-credentials: false } - - uses: pnpm/action-setup@9fd676a19091d4595eefd76e4bd31c97133911f1 # v4.2.0 - with: { version: 10.34.5, run_install: false } - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: { node-version: '24.12.0', package-manager-cache: false } - - run: pnpm install --frozen-lockfile - - 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 - - if: matrix.runtime == 'deno' - uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.5 - with: { deno-version: v2.8.0 } - - if: matrix.runtime == 'bun' - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: { bun-version: 1.3.14 } - - if: matrix.runtime == 'deno' - run: deno run --allow-read packages/rxjs/test/release/runtime-contract.mjs - - if: matrix.runtime == 'bun' - run: bun packages/rxjs/test/release/runtime-contract.mjs - - safari: - name: Exact tarballs / ${{ matrix.target }} Safari - needs: compare - runs-on: macos-15 - strategy: { fail-fast: false, matrix: { target: [desktop, ios] } } - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: { persist-credentials: false } - - uses: pnpm/action-setup@9fd676a19091d4595eefd76e4bd31c97133911f1 # v4.2.0 - with: { version: 10.34.5, run_install: false } - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: { node-version: '24.12.0', package-manager-cache: false } - - run: pnpm install --frozen-lockfile - - 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 - - run: sudo safaridriver --enable - - if: matrix.target == 'ios' - run: node packages/rxjs/test/release/boot-ios-simulator.mjs - - run: pnpm run test:release:safari:${{ matrix.target }} - - wpt: - name: Exact tarballs / pinned Observable WPT - needs: compare - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: { persist-credentials: false } - - uses: pnpm/action-setup@9fd676a19091d4595eefd76e4bd31c97133911f1 # v4.2.0 - with: { version: 10.34.5, run_install: false } - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: { node-version: '24.12.0', package-manager-cache: false } - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - with: { python-version: '3.11' } - - run: sudo apt-get update && sudo apt-get install --yes libatspi2.0-dev libcairo2-dev libgirepository1.0-dev pkg-config - - run: pnpm install --frozen-lockfile - - 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 - - run: pnpm run test:wpt - - evidence: - name: SBOM, clean release OSV scan, attestations, and authorization summary - needs: [compare, package, node, browser, alternate-runtime, safari, wpt] - runs-on: ubuntu-24.04 - permissions: - attestations: write - contents: read - id-token: write - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: { persist-credentials: false } - - 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 - with: { name: '${{ env.CORE_ARTIFACT }}', path: .release/candidate } - - name: Install checked npm and generate scripts-disabled local release-train SBOM - run: | - npm_bin=$(node scripts/release/install-pinned-npm.mjs "$RUNNER_TEMP/npm-cli") - echo "$(dirname "$npm_bin")" >> "$GITHUB_PATH" - export PATH="$(dirname "$npm_bin"):$PATH" - npm --version - node scripts/release/generate-release-evidence.mjs .release/candidate - - name: Scan the isolated release train with no monorepo exceptions - uses: google/osv-scanner-action/osv-scanner-action@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8 - with: - scan-args: |- - --format=json - --output-file=.release/candidate/rxjs-${{ needs.compare.outputs.version }}.osv.json - --lockfile=package-lock.json:.release/candidate/rxjs-${{ needs.compare.outputs.version }}.release-lock.json - - run: node scripts/release/release-candidate.mjs record-evidence .release/candidate rxjs-${{ needs.compare.outputs.version }}.osv.json - - name: Attest exact npm tarballs - id: attest - uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 - with: { subject-path: '.release/candidate/*.tgz' } - - name: Preserve the portable attestation bundle - run: | - cp "${{ steps.attest.outputs.bundle-path }}" ".release/candidate/rxjs-${{ needs.compare.outputs.version }}.intoto.jsonl" - node scripts/release/release-candidate.mjs record-evidence .release/candidate rxjs-${{ needs.compare.outputs.version }}.intoto.jsonl - - name: Publish typed authorization values without staging - run: | - digest=$(node scripts/release/release-candidate.mjs manifest-digest .release/candidate) - { - echo '# RxJS release qualification succeeded' - echo '' - echo 'No npm stage was created. To authorize staging, manually run **Stage qualified RxJS 9 release** with:' - echo '' - echo "- qualification run ID: \`${GITHUB_RUN_ID}\`" - echo "- version: \`${{ needs.compare.outputs.version }}\`" - echo "- release-manifest.json SHA-512: \`${digest}\`" - } >> "$GITHUB_STEP_SUMMARY" - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: ${{ env.CANDIDATE_ARTIFACT }} - path: .release/candidate - if-no-files-found: error - retention-days: 30 diff --git a/.github/workflows/release-stage.yml b/.github/workflows/release-stage.yml deleted file mode 100644 index 258bbcfd01..0000000000 --- a/.github/workflows/release-stage.yml +++ /dev/null @@ -1,160 +0,0 @@ -name: Stage qualified RxJS 9 release - -on: - workflow_dispatch: - inputs: - qualification_run_id: - description: Successful qualification run ID - required: true - type: string - version: - description: Exact qualified version - required: true - type: string - manifest_sha512: - description: Exact release-manifest.json SHA-512 - required: true - type: string - -permissions: - contents: read - -concurrency: - group: rxjs-9-npm-stage - cancel-in-progress: false - -env: - RELEASE_OPERATOR_LOGIN: benlesh - RELEASE_EXPECTED_SOURCE_COMMIT: ${{ github.sha }} - -jobs: - authorize: - name: Verify typed manual authorization without npm authority - if: github.ref == 'refs/heads/master' && github.actor == 'benlesh' - runs-on: ubuntu-24.04 - outputs: - source-commit: ${{ steps.details.outputs.source-commit }} - pull-request: ${{ steps.details.outputs.pull-request }} - permissions: - actions: read - contents: read - pull-requests: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: { ref: '${{ github.sha }}', fetch-depth: 0, persist-credentials: false } - - 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 - with: - github-token: ${{ github.token }} - run-id: ${{ inputs.qualification_run_id }} - name: rxjs-release-candidate-${{ inputs.qualification_run_id }} - path: .release/candidate - - name: Reject wrong actor, run, branch, commit, version, digest, expiry, bytes, or replay - env: - GH_TOKEN: ${{ github.token }} - RELEASE_ACTOR: ${{ github.actor }} - run: >- - node scripts/release/authorize-stage.mjs "$GITHUB_REPOSITORY" - "${{ inputs.qualification_run_id }}" "${{ inputs.version }}" - "${{ inputs.manifest_sha512 }}" .release/candidate - - name: Reconfirm the generated release PR authorized this exact commit - env: - GH_TOKEN: ${{ github.token }} - run: | - expected=$(node -e 'console.log(require("./.release/candidate/release-manifest.json").authorizingPullRequest)') - actual=$(node scripts/release/authorize-release-commit.mjs "$GITHUB_REPOSITORY" "$(git rev-parse HEAD)") - test "$actual" = "$expected" - - id: details - run: | - echo "source-commit=$(node -e 'console.log(require("./.release/candidate/release-manifest.json").sourceCommit)')" >> "$GITHUB_OUTPUT" - echo "pull-request=$(node -e 'console.log(require("./.release/candidate/release-manifest.json").authorizingPullRequest)')" >> "$GITHUB_OUTPUT" - - stage: - name: Reverify authorization and stage exact tarballs with npm OIDC - needs: authorize - runs-on: ubuntu-24.04 - environment: npm-stage - permissions: - actions: read - attestations: read - contents: read - id-token: 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 - 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 - with: - github-token: ${{ github.token }} - run-id: ${{ inputs.qualification_run_id }} - name: rxjs-release-candidate-${{ inputs.qualification_run_id }} - path: .release/candidate - - name: Recheck current head, retained bytes, typed digest, and replay immediately before staging - env: - GH_TOKEN: ${{ github.token }} - RELEASE_ACTOR: ${{ github.actor }} - run: >- - node scripts/release/authorize-stage.mjs "$GITHUB_REPOSITORY" - "${{ inputs.qualification_run_id }}" "${{ inputs.version }}" - "${{ inputs.manifest_sha512 }}" .release/candidate - - name: Reconfirm the generated release PR again in the privileged job - env: - GH_TOKEN: ${{ github.token }} - run: | - expected="${{ needs.authorize.outputs.pull-request }}" - actual=$(node scripts/release/authorize-release-commit.mjs "$GITHUB_REPOSITORY" "$(git rev-parse HEAD)") - test "$actual" = "$expected" - - name: Install the checked npm CLI only after registry SHA-512 verification - run: | - npm_bin=$(node scripts/release/install-pinned-npm.mjs "$RUNNER_TEMP/npm-cli") - echo "$(dirname "$npm_bin")" >> "$GITHUB_PATH" - - name: Verify GitHub attestations and candidate bytes again - env: - GH_TOKEN: ${{ github.token }} - run: | - node scripts/release/release-candidate.mjs verify .release/candidate - 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: ${{ steps.release-app-token.outputs.token }} - VERSION: ${{ inputs.version }} - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git tag --annotate "$VERSION" --message "RxJS $VERSION qualified release candidate" - git push origin "refs/tags/$VERSION" - gh release create "$VERSION" --draft --verify-tag --title "RxJS $VERSION" --notes-file CHANGELOG.md .release/candidate/* - - name: Stage exact attested tarballs with stage-only npm OIDC - run: node scripts/release/stage-release.mjs publish .release/candidate .release/staged-release.json - - name: Comment WebAuthn approval links, hashes, stage IDs, order, and CLI fallback - if: always() - env: - GH_TOKEN: ${{ steps.release-app-token.outputs.token }} - NPM_STAGED_PACKAGES_URL: ${{ vars.NPM_STAGED_PACKAGES_URL }} - run: | - test -f .release/staged-release.json - pr_number=$(node -e 'console.log(require("./.release/candidate/release-manifest.json").authorizingPullRequest)') - node scripts/release/stage-release.mjs comment .release/candidate .release/staged-release.json > "$RUNNER_TEMP/staging-comment.md" - gh pr comment "$pr_number" --body-file "$RUNNER_TEMP/staging-comment.md" - - name: Preserve staging receipt - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: rxjs-staging-receipt-${{ github.run_id }} - path: .release/staged-release.json - if-no-files-found: warn - retention-days: 90 diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index 5ab8825d6d..e2e9486f03 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -1,84 +1,91 @@ -# RxJS 9 secure release process +# RxJS 9 beta release process -## Basic steps +RxJS 9 beta releases are intentionally manual. Ben publishes from a clean, +up-to-date `master` checkout with npm's interactive OTP/WebAuthn authentication. +GitHub Actions qualifies changes, but no workflow, GitHub App, environment, +trusted publisher, or repository secret can publish an npm package. -1. Ben merges an ordinary pull request into `master`. -2. After required `master` checks pass, automation creates or refreshes the generated release PR. -3. Ben reviews and self-merges that release PR. No approving review is required. -4. GitHub qualifies the candidate and stops without creating an npm stage. -5. Ben copies the qualification run ID, exact version, and `release-manifest.json` SHA-512 from the successful run summary into **Stage qualified RxJS 9 release**. -6. GitHub revalidates the run, current `master` commit, release PR, retained bytes, digest, age, and replay state, then creates npm stages. -7. Ben opens npm Staged Packages and approves all four packages with WebAuthn, approving `rxjs` last. -8. GitHub verifies registry integrity, npm signatures/provenance, and GitHub attestations before publishing the immutable GitHub Release. +## Before publishing -Merging an ordinary PR automatically creates or refreshes the release PR. That automation is not a security problem: it changes only reviewable release metadata on an allowlisted branch. The later typed run/version/digest authorization exists so an accidental or compromised release-PR merge cannot immediately create publishable npm stages. +1. Confirm all required `master` checks are green for the commit being released. +2. Confirm the four package manifests, their runtime version constants, and the + migration Skill metadata already contain the exact beta version. +3. Confirm npm two-factor authentication works and recovery codes are stored + offline. Do not create an npm automation token for this process. +4. Check out `master`, fetch its remote, update it without a merge commit, and + leave the working tree completely clean. -## Security considerations +The command refuses live publication from another branch, a dirty or divergent +checkout, CI, a non-interactive terminal, or an environment containing +`NPM_TOKEN` or `NODE_AUTH_TOKEN`. -- RxJS currently has one maintainer. The process requires no second reviewer, team, CODEOWNER approval, or environment reviewer. -- Pull requests provide a visible diff and mandatory automated checks; they do not imply independent human approval. -- Two separate human decisions exist: merge the generated release PR, then manually authorize its qualified digest for npm staging. -- npm publication is irreversible for RxJS. A pre-stage failure has no npm effect. -- The files tested twice are the exact tarballs staged. Any changed, missing, or additional byte invalidates the candidate. -- CI can call only `npm stage publish` through OIDC. It has no long-lived npm publishing token and cannot call direct `npm publish`. -- GitHub and npm both use WebAuthn security keys or passkeys. Recovery codes are stored offline. -- Compromise of both Ben's GitHub and npm authentication can still compromise a release. +## Rehearse without publishing -## Automatic release PR +Run the complete local build, package gates, tarball creation, and npm dry runs: -Ordinary pull requests use Conventional Commit titles because the squash title is the version-selection input. A successful merge to `master` runs the configured checks and causes the narrowly scoped release App to create or refresh `release/rxjs-9`. The generated PR contains the proposed version, channel, changelog, synchronized package versions, affected packages, and policy diff. It requests no reviewer. +```sh +pnpm release:beta 9.0.0-beta.0 --dry-run +``` -During `9.0.0-beta.N`, fixes, features, and breaking changes increment only `beta.N`. Stable promotion is an explicit **Generate release PR** mode. After stable 9.x, fixes increment patch, features increment minor, and a breaking change blocks the 9.x train because SemVer requires 10.0.0. Documentation-only and internal chores do not produce a release. +The rehearsal may run from a clean review branch. It creates temporary +tarballs, prints npm's package inventories, and deletes the temporary files +when it finishes. It does not contact npm with a publication request. -## Read-only qualification +## Publish -Self-merging the generated PR starts **Qualify RxJS 9 release**. Two separate fresh `ubuntu-24.04` jobs use Node 24.12.0 and pnpm 10.34.5, frozen installs, and no restored caches. They independently build and pack the four packages. Qualification fails unless filenames, inventories, contents, and SHA-512 values are byte-identical. +From the clean, synchronized `master` checkout, run: -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. +```sh +pnpm release:beta 9.0.0-beta.0 +``` -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 command performs these steps in order: -The retained 30-day artifact contains: +1. validates the exact `9.0.0-beta.N` argument and synchronized package metadata; +2. runs `pnpm run release:check` and every release package's `test:package` gate; +3. packs all four packages into a temporary directory; +4. prints each tarball's byte count and SHA-512 integrity; +5. runs `npm publish --dry-run --tag next --access public` for every tarball; +6. asks Ben to type the exact version as the irreversible confirmation; +7. publishes with npm's interactive authentication in this order: + `@rxjs/observable-polyfill`, `@rxjs/test`, `@rxjs/migrate`, and `rxjs` last; +8. compares each registry integrity with the local tarball; +9. verifies every package's `next` tag and confirms `rxjs@latest` remains RxJS 7. -- `release-manifest.json`; -- `rxjs-.intoto.jsonl`; -- `rxjs-.cdx.json`; -- `rxjs-.osv.json`; -- `rxjs-.release-lock.json`; -- all exact npm tarballs. +npm may request OTP/WebAuthn once per package. That repetition is deliberate: +the four packages are independent registry publications. Nothing attempts to +bypass npm's proof-of-presence requirement. -Qualification creates no tag, GitHub Release, npm stage, or npm publication. Its summary prints the three values required for manual staging. +## Failure recovery -## Manual digest authorization and npm approval +npm versions are immutable. Never rebuild and reuse a version after npm accepts +different bytes. -Only GitHub login `benlesh` may dispatch `.github/workflows/release-stage.yml`, and it must be dispatched from protected `master`. The workflow rejects a wrong run ID, workflow, event, branch, actor, version, digest, source commit, current head, release PR, failed or older-than-30-day run, expired artifact, changed inventory/bytes, or replayed version. +The command is safe to rerun after a network failure or interrupted OTP prompt. +Before each publish it checks whether that exact package version already exists. +It skips the package only when the registry's SHA-512 integrity equals the +freshly packed tarball; a mismatch stops the release. Because `rxjs` is last, +the main consumer entry remains unpublished until the three supporting packages +are present and verified. -Those checks first run in a job with no npm environment and no OIDC permission. Only after it succeeds can the separate `npm-stage` job start; that job rechecks current head, retained bytes, digest, release PR, and replay state before staging. npm trusted publishing must be bound to `ReactiveX/rxjs`, `.github/workflows/release-stage.yml`, protected `master`, and `npm-stage`, with stage-only authority. npm 11.18.0 is downloaded only after its checked-in registry SHA-512 matches. +If a package was published correctly but a later package cannot be published, +fix only the operational problem and rerun the same command from the same clean +commit. If any source or package byte must change, bump to a fresh beta version. -Approve with WebAuthn in this order: +## After publishing -1. `@rxjs/observable-polyfill` -2. `@rxjs/test` -3. `@rxjs/migrate` -4. `rxjs` last +1. Confirm the four public package pages show the expected version under `next`. +2. Confirm `npm view rxjs@latest version` still reports the maintained RxJS 7 line. +3. Create the immutable GitHub tag and release for the verified source commit. +4. Record the release URL and four npm integrity values in the project-plan + session log. -Automation downloads every private stage and compares its bytes before showing approval instructions. If staging is partial or any value differs, approve nothing: reject every stage and qualify a fresh version. Staging attempts cannot be replayed because creation of the protected version tag and draft release precedes npm staging. +For the three new scoped packages, immediately select **Require two-factor +authentication and disallow tokens** in each npm package's publishing-access +settings after its first publication. The existing `rxjs` package should use +the same setting. No npm publishing credential belongs in GitHub Actions. -## Final verification +Stable `9.0.0` and moving RxJS 9 to npm's `latest` tag require a separate +decision and are not supported by `release:beta`. -The finalizer has no npm credentials or publishing authority. It waits for all four public packages, compares each registry integrity to the manifest, verifies `npm audit signatures`, and verifies GitHub attestations for every tarball. Only then does it publish the draft GitHub Release. - -## 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 `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 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, 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. +Last reviewed: 2026-08-04. diff --git a/docs/rxjs-next/ARCHITECTURE.md b/docs/rxjs-next/ARCHITECTURE.md index 2c0365cc62..e39bcf6dbb 100644 --- a/docs/rxjs-next/ARCHITECTURE.md +++ b/docs/rxjs-next/ARCHITECTURE.md @@ -66,31 +66,25 @@ flowchart LR RxJS 9 explicitly assumes one human author, reviewer, merger, release operator, and security responder. Pull requests expose changes and run required checks; -they are not evidence of independent approval. An ordinary successful merge to -`master` automatically creates or refreshes a generated release PR. Self-merging -that PR starts read-only qualification only. - -Qualification uses two independent fresh Ubuntu 24.04 jobs with exact Node -24.12.0 and pnpm 10.34.5. The release continues only when package filenames, -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 -PR, retained bytes, run success/age, and replay state before the `npm-stage` -environment receives OIDC authority limited to `npm stage publish`. npm WebAuthn -approval is a second account boundary. Final GitHub Release publication has no -npm authority and requires registry integrity, npm signature/provenance, and -GitHub attestation verification. This architecture reduces accidental and -single-account release compromise; it cannot eliminate compromise of both the -maintainer's GitHub and npm authentication. +they are not evidence of independent approval. + +Beta publication is a local, interactive operation from a clean `master` +checkout that exactly matches its remote. `pnpm release:beta ` validates +the synchronized four-package version, runs repository and package gates, packs +the packages, prints their SHA-512 integrities, and runs npm publication dry +runs. Ben must then type the exact version before npm's own OTP/WebAuthn flow +publishes each tarball under `next`. The supporting packages publish first and +`rxjs` publishes last. Registry integrity and dist-tags are verified before the +command reports success. + +CI has no npm publishing credential and no workflow can publish. The design +deliberately trusts Ben's local machine and npm account at the publication +boundary instead of adding a GitHub App, trusted publisher, private staging, +release environment, or automated release-PR system. This keeps the process +understandable and makes the residual risk explicit: a compromised maintainer +machine or npm authentication can still compromise a release. Required CI, +interactive WebAuthn, exact package ordering, dry runs, and registry-integrity +verification reduce mistakes without pretending to remove that trust. Useful producer-per-subscription values and Subjects remain intentional APIs inside `rxjs`; they do not form a separate compatibility layer or package. diff --git a/docs/rxjs-next/DECISIONS.md b/docs/rxjs-next/DECISIONS.md index 3f22db3b35..8fa787d248 100644 --- a/docs/rxjs-next/DECISIONS.md +++ b/docs/rxjs-next/DECISIONS.md @@ -1446,57 +1446,48 @@ Status meanings: ## D-057 — Use a single-maintainer, reproducible, manually authorized staged release +- **Status:** Superseded by D-058 +- **Decision:** The repository briefly implemented generated release PRs, + two-build qualification, stage-only npm OIDC, private npm staging, typed + digest authorization, and automated finalization. +- **Reason superseded:** The mechanism optimized for maximum assurance without + meeting the maintainer's primary usability requirement. More importantly, + npm cannot configure trusted publishing or staged publishing for a package + that does not already exist. Three first-release packages do not yet have + public registry records, so the proposed four-package private staging flow + could not perform the initial beta release it was designed for. Dry runs did + not expose that registry prerequisite. +- **Consequence:** The GitHub App was deleted before publication. No package was + staged or published by this design. Its App, OIDC, staging, qualification, + doctor, and finalizer implementation is removed rather than retained as an + inactive alternate release path. + +## D-058 — Publish betas with one local interactive command + - **Status:** Accepted -- **Decision:** Ben Lesh is the sole required author, reviewer, merger, release - operator, and security responder. RxJS 9 releases from `master` use a - generated release PR as a reviewable version/changelog/policy diff, not as - evidence of independent approval. Ordinary successful merges automatically - create or refresh it. Conventional Commit titles select the - version: beta work increments only `beta.N`; stable fixes increment patch; - stable features increment minor; and stable breaking changes block the 9.x - train. Stable promotion is an explicit mode. All four packages retain one - synchronized version. -- **Publication boundary:** Self-merging the release PR starts read-only - qualification. Two independent fresh Ubuntu 24.04 jobs use exact Node - 24.12.0 and pnpm 10.34.5, and the candidate continues only when filenames, - inventories, contents, and SHA-512 values match. Every blocking environment, - the release-only OSV scan, SBOM generation, and attestations operate on those - exact files, then stop. A later manual dispatch by `benlesh` must type the - qualification run ID, exact version, and manifest SHA-512. It rejects wrong, - failed, stale, changed, non-current, unauthorized, expired, or replayed - candidates before the same filenames reach an npm trusted publisher limited - to `npm stage publish`. CI has no long-lived npm token and cannot call direct - `npm publish`. Ben approves every stage with npm WebAuthn and approves `rxjs` - last. -- **Irreversibility:** npm publication is a pre-approval safety boundary for - RxJS. Post-publication registry checks only finalize the immutable GitHub - Release. Any changed byte invalidates a candidate, and partial staging - requires rejection plus a fresh fully qualified version. -- **Security:** `master` requires pull requests but zero approvals. Required - automation, verified squash commits, and branch/tag protections replace a - nonexistent reviewer team. Privileged workflows reject any initiating login - other than `benlesh`, run on fresh GitHub-hosted runners with frozen installs - and no restored caches, use SHA-pinned actions, and verify the checked npm - 11.18.0 registry SHA-512 before staging. GitHub and npm use WebAuthn; reusable - publish-capable npm tokens are prohibited. Compromise of both maintainer - accounts remains a documented residual risk. -- **Evidence and signal:** Stable release assets include the manifest, exact - tarballs, CycloneDX SBOM, clean isolated OSV report, and portable attestation - 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 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. +- **Decision:** Ben Lesh remains the sole required author, reviewer, merger, + release operator, and security responder. All four packages keep one exact + synchronized version. From a clean local `master` checkout that exactly + matches its remote, Ben runs `pnpm release:beta <9.0.0-beta.N>`. +- **Publication boundary:** The command validates versions and repository + state, runs release and package gates, packs all four packages, prints their + SHA-512 integrities, and runs `npm publish --dry-run`. It then requires the + exact version as confirmation and calls interactive `npm publish` under + `next`, allowing npm to request OTP/WebAuthn for each package. Supporting + packages publish first and `rxjs` publishes last. +- **Credentials:** CI has no npm publishing credential. The command refuses CI, + non-interactive terminals, and `NPM_TOKEN` or `NODE_AUTH_TOKEN` environment + credentials. Package publishing access requires two-factor authentication + and disallows automation tokens after the three new scoped packages have + been initialized. +- **Recovery and verification:** A rerun skips an already-published package only + when npm's registry integrity matches the freshly packed tarball. Any byte + mismatch stops. Success requires all four `next` tags to resolve to the exact + version while `rxjs@latest` remains on RxJS 7. +- **Tradeoff:** The process trusts the maintainer's local machine and npm + authentication and does not provide private staging or automatic npm + provenance. That explicit, understandable boundary is accepted over a more + complicated workflow whose incremental protection does not justify its + operational and bootstrap costs for the current sole-maintainer release. +- **Scope:** Stable `9.0.0`, promotion to `latest`, and future reconsideration + of registry-supported trusted publishing remain separate decisions. diff --git a/docs/rxjs-next/PROJECT_PLAN.md b/docs/rxjs-next/PROJECT_PLAN.md index 292cab01e2..cdf47fc508 100644 --- a/docs/rxjs-next/PROJECT_PLAN.md +++ b/docs/rxjs-next/PROJECT_PLAN.md @@ -79,10 +79,10 @@ bundle-size reduction. P6.7 then removed the remaining derived-construction wrappers in favor of direct D-037 `[create]` calls and recorded a further bundle-size reduction. P6.8 completed durable pull-request and `master` CI ownership for every accepted RxJS 9 test and release check. P6.9 implemented -truthful status signals and security automation; its first live GitHub results -remain external validation. The user has now prioritized P6.10: the secure -single-maintainer release-PR, reproducible qualification, and manually authorized npm staged-approval -maintenance process. +truthful status signals and security automation and validated their first live +GitHub results. The user has now prioritized P6.10: one understandable, +interactive single-maintainer beta publication command with npm two-factor +authentication and no CI publishing credential. RxJS 9 and `9.0.0-beta.0` are selected under D-007. D-053 defines runtime, browser, bundler, channel, and RxJS 7 maintenance policy. Dates and staffing @@ -1568,7 +1568,7 @@ names. | `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 | +| `NEXT` | P6.10 | Publish and verify the first beta with the interactive release command | #### P6.9 completion bar @@ -1613,48 +1613,39 @@ review`. This completes P6.9; the later P6.10 ruleset migration must preserve author, reviewer, merger, release operator, and security responder. No team, approving review, CODEOWNER, environment reviewer, or succession role is required or implied. -- A narrowly scoped release App automatically refreshes one allowlisted release - PR after an ordinary successful `master` merge. Self-merging that PR starts - read-only qualification, not npm staging. -- Two fresh exact-toolchain builds must be byte-identical. Every blocking gate, - the exception-free release OSV scan, SBOM generation, and attestations use the - canonical tarballs. Qualification publishes the run ID, version, and manifest - SHA-512 and then stops. -- Only `benlesh` can manually authorize stage-only npm OIDC by typing all three - values. Tests reject wrong, failed, stale, expired, changed, unauthorized, - non-current, or replayed candidates. npm 11.18.0 is registry-hash verified; - no direct publish command or reusable publishing token remains. +- `pnpm release:beta <9.0.0-beta.N>` is the sole publication entry. It refuses + live operation outside a clean, remote-synchronized `master`, in CI, without + an interactive terminal, or with `NPM_TOKEN`/`NODE_AUTH_TOKEN` present. +- The command requires synchronized package metadata, runs release and package + gates, packs all four packages, prints SHA-512 integrities, runs npm publish + dry runs, and requires the exact version as confirmation before publication. +- npm interactive OTP/WebAuthn authorizes each public package. The three scoped + packages publish before `rxjs`; every registry integrity and `next` tag must + match, and `rxjs@latest` must remain on RxJS 7. +- A partial retry skips an immutable package version only when the registry + integrity equals the newly packed tarball. Any byte change requires a fresh + beta version. - Root vulnerability paths are classified; only time-bounded `apps/rxjs.dev` - 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 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. + exceptions remain. Package publishing access disallows automation tokens + after the three new package records are initialized. +- P6.10 remains active through the interactive publication, registry integrity + verification, npm channel verification, and immutable GitHub Release. #### P6.10 implementation evidence -- Added repository-owned release policy, automatic release-PR generation, synchronized - version/provenance updates, changelog generation, release-bot allowlisting, - exact candidate manifests, hash verification, exact-artifact hydration, - staged comments, public-integrity finalization, and a read-only doctor. -- Replaced private Nx release imports and token publishing with GitHub-App - release PRs, two-build exact-tarball qualification, artifact attestations, - typed manual staging authorization, stage-only OIDC, WebAuthn approval order, - draft evidence releases, and no-npm-authority finalization. Added CodeQL, - action-pin enforcement, Dependabot, and OSV/property gates. -- Added the sole-maintainer public runbook and security-assurance document. npm routes - are never guessed: setup records a manually verified URL, rendering validates - its origin, and comments retain stage-ID CLI fallbacks. -- Copilot review follow-up recognizes indented multi-line `BREAKING CHANGE` - footers and makes the release doctor verify the exact GitHub-hosted runner on - 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, WebAuthn, staged-digest, tag, provenance, and - immutable-release evidence remain required before `DONE`. +- D-057 records the abandoned staged design and its unsupported new-package + bootstrap assumption; D-058 accepts the simpler local boundary. The release + App was deleted before any npm stage or publication. +- Removed the release App, automated release PR, two-build candidate, + qualification, OIDC staging, typed authorization, doctor, staged-comment, + and automated-finalizer workflows and scripts. +- Added the tested interactive beta command, exact publish order, clean-master + and no-token guards, dry runs, resumable integrity checks, channel checks, and + an operator-focused runbook. Existing CI, CodeQL, dependency review, OSV, + package gates, and release-readiness coverage remain. +- Local verification is recorded in the P6.10 session entry. Live npm + OTP/WebAuthn publication, package-access hardening, registry verification, + and the immutable GitHub Release remain required before `DONE`. #### P6.1 completion bar @@ -3661,3 +3652,31 @@ conformance implementation depends on a runnable harness. `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. + +### 2026-08-04 — P6.10 interactive release simplification + +- Rejected the staged design after confirming npm's initial-publication + prerequisite: `rxjs` has a public registry record, while + `@rxjs/observable-polyfill`, `@rxjs/test`, and `@rxjs/migrate` return 404 and + therefore cannot use npm staged publishing or trusted-publisher configuration + for their first version. The earlier dry runs did not contact the registry + and did not reveal that blocker. +- Superseded D-057 with D-058. Deleted the GitHub App before any stage or + publication, then removed its repository private-key secret and App-ID + variable plus the obsolete `NPM_TOKEN`. The unrelated Firebase and Nx Cloud + secrets remain unchanged; no ReactiveX organization setting was changed. +- Removed five privileged release workflows and the App, release-PR, + two-builder, candidate, OIDC, staging, typed-authorization, doctor, and + finalizer scripts. Retained ordinary CI, release readiness, CodeQL, + dependency review, OSV, Conventional Commit validation, and package gates. +- Added `pnpm release:beta <9.0.0-beta.N>` with clean synchronized-`master`, + interactive-terminal, and no-environment-token guards; synchronized package + checks; local package gates; exact tarball SHA-512 display; npm publish dry + runs; exact-version confirmation; supporting-package-first/`rxjs`-last + publication; resumable integrity matching; and `next`/`latest` verification. +- Passed 37 release, documentation, coherence, Conventional Commit, and OSV + tests; all four package build/type/import gates; workflow formatting; and + four exact-tarball npm publication dry runs. Nothing was published. P6.10 + remains the sole `NEXT` item until the command publishes and verifies + `9.0.0-beta.0`, package access disallows automation tokens, and the immutable + GitHub Release is recorded. diff --git a/package.json b/package.json index 53d7344ebe..fb5b8ef539 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,8 @@ "scripts": { "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/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", + "release:beta": "node scripts/release/beta.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/beta.test.mjs scripts/release/conventional-commit.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", "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", @@ -48,8 +48,7 @@ "typescript": "~5.7.3", "vite": "6.4.3", "vitest": "^4.1.10", - "webpack": "5.106.2", - "yargs": "17.7.2" + "webpack": "5.106.2" }, "husky": { "hooks": { diff --git a/packages/observable-polyfill/.tshy/esm.json b/packages/observable-polyfill/.tshy/esm.json index ae04da6fc7..dcb80e6103 100644 --- a/packages/observable-polyfill/.tshy/esm.json +++ b/packages/observable-polyfill/.tshy/esm.json @@ -1,7 +1,13 @@ { "extends": "./build.json", - "include": ["../src/**/*.ts", "../src/**/*.mts", "../src/**/*.tsx"], - "exclude": ["../src/**/*.spec.ts"], + "include": [ + "../src/**/*.ts", + "../src/**/*.mts", + "../src/**/*.tsx" + ], + "exclude": [ + "../src/**/*.spec.ts" + ], "compilerOptions": { "outDir": "../.tshy-build/esm" } diff --git a/packages/rxjs/.tshy/esm.json b/packages/rxjs/.tshy/esm.json index ae04da6fc7..dcb80e6103 100644 --- a/packages/rxjs/.tshy/esm.json +++ b/packages/rxjs/.tshy/esm.json @@ -1,7 +1,13 @@ { "extends": "./build.json", - "include": ["../src/**/*.ts", "../src/**/*.mts", "../src/**/*.tsx"], - "exclude": ["../src/**/*.spec.ts"], + "include": [ + "../src/**/*.ts", + "../src/**/*.mts", + "../src/**/*.tsx" + ], + "exclude": [ + "../src/**/*.spec.ts" + ], "compilerOptions": { "outDir": "../.tshy-build/esm" } diff --git a/packages/rxjs/docs/PRERELEASE_APPROVAL.md b/packages/rxjs/docs/PRERELEASE_APPROVAL.md index 338d178c45..8a71b07dae 100644 --- a/packages/rxjs/docs/PRERELEASE_APPROVAL.md +++ b/packages/rxjs/docs/PRERELEASE_APPROVAL.md @@ -67,9 +67,10 @@ conditions of publication, not waived evidence. ## Publication boundary -The [secure release workflow](https://github.com/ReactiveX/rxjs/blob/master/docs/RELEASE_PROCESS.md) checks version -identity, package-local documentation, ESM-only exports, runtime lanes, -adoption evidence, and the protected npm channel before packing once. The exact -attested tarballs are staged through stage-only OIDC and require a release -maintainer's npm TFA approval for each package, with `rxjs` last. No -documentation application is part of this approval or release path. +The [release process](https://github.com/ReactiveX/rxjs/blob/master/docs/RELEASE_PROCESS.md) +requires a clean, synchronized `master` checkout and one interactive +`pnpm release:beta 9.0.0-beta.0` command. It checks version identity, +package-local documentation, ESM-only exports, package gates, and npm dry runs; +prints the exact tarball integrities; and then uses npm's OTP/WebAuthn flow for +each public package. The supporting packages publish first and `rxjs` publishes +last. No CI credential or documentation application is part of publication. diff --git a/packages/rxjs/docs/RELEASE_GATES.md b/packages/rxjs/docs/RELEASE_GATES.md index 135b8872dc..74230d4dc7 100644 --- a/packages/rxjs/docs/RELEASE_GATES.md +++ b/packages/rxjs/docs/RELEASE_GATES.md @@ -4,10 +4,10 @@ This document describes the executable environment and package gates for the RxJS 9 prerelease line. It is package documentation; it does not depend on the repository documentation application. -The repository's [secure release runbook](https://github.com/ReactiveX/rxjs/blob/master/docs/RELEASE_PROCESS.md) -defines version selection, exact-tarball qualification, npm staged approval, -WebAuthn order and failure recovery. These gates qualify a candidate; -they never authorize direct publication. +The repository's [release runbook](https://github.com/ReactiveX/rxjs/blob/master/docs/RELEASE_PROCESS.md) +defines the clean-checkout requirement, interactive npm publication order, +integrity verification, and failure recovery. CI qualifies the source; only a +maintainer running the local release command can authorize publication. ## Runtime matrix diff --git a/packages/rxjs/docs/SECURITY_ASSURANCE.md b/packages/rxjs/docs/SECURITY_ASSURANCE.md index ac17b9605f..3944655fb1 100644 --- a/packages/rxjs/docs/SECURITY_ASSURANCE.md +++ b/packages/rxjs/docs/SECURITY_ASSURANCE.md @@ -1,53 +1,91 @@ # RxJS 9 security assurance -RxJS 9 releases are designed to be hardened, transparent, and independently verifiable. This is evidence about the release process, not a promise that the software contains no vulnerability. +RxJS 9 uses a deliberately small release boundary. This document describes +the evidence and trust assumptions; it does not promise that the software is +free of vulnerabilities. ## What users can rely on -- 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. -- The final GitHub Release is published only after npm registry integrity, npm signatures/provenance, and GitHub attestations verify. +- The `rxjs` runtime depends only on the RxJS-owned + `@rxjs/observable-polyfill`; that package has no runtime dependencies. +- Pull requests and every `master` push run the package, type, browser, Safari, + Web Platform Test, bundler, performance, migration, dependency-review, + CodeQL, and OSV checks described in [RELEASE_GATES.md](RELEASE_GATES.md). +- npm publication is not available to GitHub Actions. The repository stores no + npm publishing token and has no publishing workflow, trusted publisher, or + release App. +- A maintainer publishes from a clean local `master` checkout with + `pnpm release:beta `. The command builds and tests, packs the four + packages, prints SHA-512 integrities, and runs npm publication dry runs before + asking for irreversible confirmation. +- npm's interactive OTP/WebAuthn flow authorizes each package. Supporting + packages publish first and `rxjs` publishes last. +- The command verifies npm's registry integrity and `next` tag for all four + packages and confirms that `rxjs@latest` remains on RxJS 7. +- An interrupted command can skip an already-published package only when the + registry integrity equals the freshly packed tarball. ## One-maintainer reality -RxJS currently has one active maintainer. The same person authors, reviews, merges, and releases changes. Pull requests remain useful because they expose diffs and run mandatory checks, but RxJS does not claim independent human approval. OpenSSF's Code-Review score is therefore accepted rather than manipulated with ceremonial approvals. +RxJS currently has one active maintainer. The same person authors, reviews, +merges, and releases changes. Pull requests expose diffs and run mandatory +checks, but RxJS does not claim independent human approval. OpenSSF's +Code-Review score is therefore accepted rather than manipulated with +ceremonial approvals. -The primary defenses are automation, deterministic evidence, narrow short-lived authority, two separate manual decisions, and phishing-resistant authentication on both GitHub and npm. Compromise of both the maintainer's GitHub and npm authentication can still compromise a release. Recovery codes are kept offline; publish-capable reusable npm tokens are prohibited. +The publication boundary trusts that maintainer's local machine and npm +authentication. Interactive WebAuthn, required CI, dry runs, ordered +publication, and registry verification reduce common mistakes and credential +risks; they do not remove the risk of a compromised maintainer machine or npm +account. Recovery codes are kept offline. Package publishing access requires +two-factor authentication and disallows automation tokens after each new +package record exists. ## Verify a release -Download the version's assets from the [GitHub Releases page](https://github.com/ReactiveX/rxjs/releases), then run: +After installing with a lockfile, verify npm registry signatures: ```sh -# Verify GitHub's attestation for an exact downloaded tarball. -gh attestation verify ./rxjs-9.0.0-beta.0.tgz --repo ReactiveX/rxjs - -# After installing with a lockfile, verify npm registry signatures/provenance. npm audit signatures +``` -# Compare npm's registry integrity with the package entry in release-manifest.json. +Inspect the registry integrity and channels directly: + +```sh npm view rxjs@9.0.0-beta.0 dist.integrity +npm view rxjs@next version +npm view rxjs@latest version ``` -The manifest uses hexadecimal SHA-512 for files and npm-compatible base64 integrity for registry comparison. Verify the version and source commit as well as the digest; a correct hash for the wrong release is not sufficient. +The release operator records all four integrity values and the immutable GitHub +Release URL in the project-plan session log. ## Vulnerability evidence -OSV scanning runs on pull requests, every `master` push, weekly, and against each isolated release train. Anything reachable from runtime, build, test, qualification, or publication must be fixed or removed. The inherited legacy documentation-application toolchain is excluded from RxJS 9 and tracked separately; its time-bounded exceptions never apply to the release-train scan. +OSV scanning runs on pull requests, every `master` push, and weekly. Anything +reachable from runtime, build, or test tooling must be fixed or removed. The +inherited legacy documentation-application toolchain is excluded from RxJS 9 +and tracked separately; its time-bounded exceptions do not change the release +package scans. -Property-based tests exercise release version selection, manifest and staging authorization, npm URL parsing, byte integrity, and Observable subscribe/abort/terminal/teardown/ref-count/restart sequences. Failures report a replay seed. +Property-based tests exercise the Observable lifecycle state machine. Package +and release-script tests cover synchronized versions, clean-checkout guards, +interactive credential restrictions, publication order, npm channels, and +integrity comparisons. ## What this does not prove -Reproducibility shows that two controlled builds produced the same bytes; it does not prove the source is bug-free or that both builders were uncompromised. Provenance identifies the GitHub workflow and source commit; it does not certify the maintainer's intent. An SBOM and clean scanner result cover known data in the selected databases, not unknown vulnerabilities. No control removes the residual risk of a fully compromised sole-maintainer account. +Passing tests does not prove that source is bug-free. A matching registry hash +proves that npm received the local tarball; it does not prove that the local +machine or maintainer account was uncompromised. The initial manual publication +does not provide npm's CI provenance attestation. These are accepted tradeoffs +for an understandable sole-maintainer process. ## OpenSSF in context [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/ReactiveX/rxjs/badge)](https://scorecard.dev/viewer/?uri=github.com/ReactiveX/rxjs) -OpenSSF Scorecard is a useful repository-hygiene signal, but its aggregate is not an RxJS release gate and is not the project's primary security claim. In particular, Code-Review `0` truthfully reflects the lack of an independent reviewer. Users assessing a specific RxJS 9 version should prefer the release manifest, exact tarball hashes, provenance, SBOM, vulnerability report, and registry verification above. +OpenSSF Scorecard is a useful repository-hygiene signal, not a release gate or +the project's primary security claim. Users assessing a specific RxJS 9 +version should prefer required-CI history, registry signatures, exact package +integrity, and the tagged source commit. diff --git a/packages/test/.tshy/esm.json b/packages/test/.tshy/esm.json index ae04da6fc7..dcb80e6103 100644 --- a/packages/test/.tshy/esm.json +++ b/packages/test/.tshy/esm.json @@ -1,7 +1,13 @@ { "extends": "./build.json", - "include": ["../src/**/*.ts", "../src/**/*.mts", "../src/**/*.tsx"], - "exclude": ["../src/**/*.spec.ts"], + "include": [ + "../src/**/*.ts", + "../src/**/*.mts", + "../src/**/*.tsx" + ], + "exclude": [ + "../src/**/*.spec.ts" + ], "compilerOptions": { "outDir": "../.tshy-build/esm" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec1d3f9a18..595d75e46a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,9 +101,6 @@ importers: webpack: specifier: 5.106.2 version: 5.106.2(esbuild@0.25.12) - yargs: - specifier: 17.7.2 - version: 17.7.2 apps/rxjs.dev: dependencies: diff --git a/scripts/check-release-coherence.mjs b/scripts/check-release-coherence.mjs index 128ca04359..2dbfb44663 100644 --- a/scripts/check-release-coherence.mjs +++ b/scripts/check-release-coherence.mjs @@ -57,8 +57,8 @@ export function auditReleaseCoherence(input) { if (!input.preparePackagesCommand.includes('--exclude rxjs.dev')) { errors.push('Package preparation must explicitly exclude rxjs.dev.'); } - if (!input.publishSource.includes("version.includes('-') ? 'next' : 'latest'")) { - errors.push('Release policy must map prereleases to next and stable versions to latest.'); + if (input.betaCommand !== 'node scripts/release/beta.mjs') { + errors.push('The beta release command must remain node scripts/release/beta.mjs.'); } auditReleaseMatrix(input, errors); @@ -117,34 +117,24 @@ function auditReleaseMatrix(input, errors) { for (const [label, source] of [ ['package CI', input.ciWorkflowSource], ['release-readiness CI', input.readinessWorkflowSource], - ['publishing CI', input.publishWorkflowSource], + ['interactive beta release', input.betaSource], ]) { if (source.includes('rxjs.dev')) errors.push(`${label} must not build, test, publish, or otherwise reference rxjs.dev.`); } - if (!/node-version:\s*['"]24\.12\.0['"]/.test(input.publishWorkflowSource)) { - errors.push('Publishing must use exact Node 24.12.0.'); - } - if ( - !/Verify release policy and repository configuration[\s\S]*?Build release packages[\s\S]*?Pack, inventory, and hash/.test( - input.publishWorkflowSource - ) - ) { - errors.push('Qualification must verify release policy before building and packing each independent candidate.'); - } for (const requirement of [ - 'RELEASE_EXPECTED_SOURCE_COMMIT: ${{ github.sha }}', - 'compare-release-candidates.mjs', - 'Exact tarballs / package, type, import, and migration gates', - 'pnpm --filter @rxjs/observable-polyfill --filter @rxjs/test --filter @rxjs/migrate run test', - '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', + "{ name: '@rxjs/observable-polyfill', directory: 'packages/observable-polyfill' }", + "{ name: '@rxjs/test', directory: 'packages/test' }", + "{ name: '@rxjs/migrate', directory: 'packages/migrate' }", + "{ name: 'rxjs', directory: 'packages/rxjs' }", + " '--tag',\n 'next',\n '--access',\n 'public',", + "assert.equal(branch, 'master'", + 'The release checkout must be clean.', + 'Unset NPM_TOKEN and NODE_AUTH_TOKEN', + 'npm publish dry runs', + 'registry integrity did not match the local tarball', + 'rxjs@latest unexpectedly resolves', ]) { - if (!input.publishWorkflowSource.includes(requirement)) errors.push(`Publishing must retain ${requirement}.`); + if (!input.betaSource.includes(requirement)) errors.push(`Interactive beta publishing must retain ${requirement}.`); } requireMasterPush(input.ciWorkflowSource, 'Package CI', errors); @@ -219,27 +209,22 @@ export async function readReleaseCoherenceInput(root = repositoryRoot) { rootManifest, nxConfig, skillProvenance, - publishSource, + betaSource, ciWorkflowSource, tsWorkflowSource, wptWorkflowSource, readinessWorkflowSource, - publishWorkflowSource, safariDriverSource, wptRunnerSource, ] = await Promise.all([ readFile(resolve(root, 'package.json'), 'utf8').then(JSON.parse), readFile(resolve(root, 'nx.json'), 'utf8').then(JSON.parse), readFile(resolve(root, '.agents/skills/rxjs-next-migration/.rxjs-migrate-skill.json'), 'utf8').then(JSON.parse), - readFile(resolve(root, 'scripts/release/release-config.mjs'), 'utf8'), + readFile(resolve(root, 'scripts/release/beta.mjs'), 'utf8'), readFile(resolve(root, '.github/workflows/ci_main.yml'), 'utf8'), readFile(resolve(root, '.github/workflows/ci_ts_latest.yml'), 'utf8'), readFile(resolve(root, '.github/workflows/observable-wpt.yml'), 'utf8'), readFile(resolve(root, '.github/workflows/release-readiness.yml'), 'utf8'), - Promise.all([ - readFile(resolve(root, '.github/workflows/release-qualify.yml'), 'utf8'), - readFile(resolve(root, '.github/workflows/release-stage.yml'), 'utf8'), - ]).then((sources) => sources.join('\n')), readFile(resolve(root, 'packages/rxjs/test/release/safari-driver.mjs'), 'utf8'), readFile(resolve(root, 'packages/observable-polyfill/test/wpt/lib/runner.mjs'), 'utf8'), ]); @@ -266,12 +251,12 @@ export async function readReleaseCoherenceInput(root = repositoryRoot) { rootNodeEngine: rootManifest.engines?.node, nxReleaseConfigured: nxConfig.release !== undefined, preparePackagesCommand: rootManifest.scripts?.['prepare-packages'] ?? '', - publishSource, + betaCommand: rootManifest.scripts?.['release:beta'] ?? '', + betaSource, ciWorkflowSource, tsWorkflowSource, wptWorkflowSource, readinessWorkflowSource, - publishWorkflowSource, safariDriverSource, wptRunnerSource, }; diff --git a/scripts/check-release-coherence.test.mjs b/scripts/check-release-coherence.test.mjs index 3b471e5094..554a451e60 100644 --- a/scripts/check-release-coherence.test.mjs +++ b/scripts/check-release-coherence.test.mjs @@ -19,7 +19,20 @@ function validInput() { rootNodeEngine: '>=22.13.0', nxReleaseConfigured: false, preparePackagesCommand: 'pnpm nx run-many -t build --exclude rxjs.dev', - publishSource: "version.includes('-') ? 'next' : 'latest'", + betaCommand: 'node scripts/release/beta.mjs', + betaSource: [ + "{ name: '@rxjs/observable-polyfill', directory: 'packages/observable-polyfill' }", + "{ name: '@rxjs/test', directory: 'packages/test' }", + "{ name: '@rxjs/migrate', directory: 'packages/migrate' }", + "{ name: 'rxjs', directory: 'packages/rxjs' }", + " '--tag',\n 'next',\n '--access',\n 'public',", + "assert.equal(branch, 'master'", + 'The release checkout must be clean.', + 'Unset NPM_TOKEN and NODE_AUTH_TOKEN', + 'npm publish dry runs', + 'registry integrity did not match the local tarball', + 'rxjs@latest unexpectedly resolves', + ].join('\n'), ciWorkflowSource: [ " push:\n branches: ['master']", "node: '22.13.0'", @@ -54,23 +67,6 @@ function validInput() { 'pnpm --filter @rxjs/test run build', 'pnpm --filter @rxjs/migrate run build', ].join('\n'), - publishWorkflowSource: [ - "node-version: '24.12.0'", - 'Verify release policy and repository configuration', - 'Build release packages', - 'Pack, inventory, and hash', - 'RELEASE_EXPECTED_SOURCE_COMMIT: ${{ github.sha }}', - 'compare-release-candidates.mjs', - 'Exact tarballs / package, type, import, and migration gates', - 'pnpm --filter @rxjs/observable-polyfill --filter @rxjs/test --filter @rxjs/migrate run test', - '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', - ].join('\n'), safariDriverSource: "'safari:useSimulator': true\n'safari:deviceUDID'", wptRunnerSource: "'--binary-arg=--no-sandbox'", }; @@ -111,12 +107,12 @@ test('rejects manifest, dependency, runtime identity, and release-channel drift' }; input.nxReleaseConfigured = true; input.preparePackagesCommand = 'pnpm nx run-many -t build'; - input.publishSource = ''; + input.betaCommand = ''; + input.betaSource = ''; input.ciWorkflowSource = ''; input.tsWorkflowSource = ''; input.wptWorkflowSource = ''; input.readinessWorkflowSource = ''; - input.publishWorkflowSource = ''; input.safariDriverSource = ''; input.wptRunnerSource = ''; @@ -132,7 +128,7 @@ test('rejects manifest, dependency, runtime identity, and release-channel drift' assert.match(errors.join('\n'), /same dist\/esm files/); assert.match(errors.join('\n'), /Nx release configuration must remain removed/); assert.match(errors.join('\n'), /must explicitly exclude rxjs\.dev/); - assert.match(errors.join('\n'), /prereleases to next/); + assert.match(errors.join('\n'), /beta release command/); assert.match(errors.join('\n'), /Node 22\.13\.0/); assert.match(errors.join('\n'), /Node 26 lane/); assert.match(errors.join('\n'), /Mobile Safari/); @@ -146,18 +142,33 @@ test('rejects documentation-site work from release workflows', () => { const input = validInput(); input.ciWorkflowSource += '\npnpm --filter rxjs.dev run test'; input.readinessWorkflowSource += '\npnpm --filter rxjs.dev run build'; - input.publishWorkflowSource += '\npnpm --filter rxjs.dev run publish'; + input.betaSource += '\npnpm --filter rxjs.dev run publish'; assert.deepEqual( auditReleaseCoherence(input).filter((error) => error.includes('must not build, test, publish')), [ 'package CI must not build, test, publish, or otherwise reference rxjs.dev.', 'release-readiness CI must not build, test, publish, or otherwise reference rxjs.dev.', - 'publishing CI must not build, test, publish, or otherwise reference rxjs.dev.', + 'interactive beta release must not build, test, publish, or otherwise reference rxjs.dev.', ] ); }); +test('rejects removal of interactive beta release safeguards', () => { + const input = validInput(); + input.betaSource = input.betaSource + .replace("{ name: 'rxjs', directory: 'packages/rxjs' }", '') + .replace(" '--tag',\n 'next',\n '--access',\n 'public',", '') + .replace('Unset NPM_TOKEN and NODE_AUTH_TOKEN', '') + .replace('registry integrity did not match the local tarball', ''); + + const errors = auditReleaseCoherence(input).join('\n'); + assert.match(errors, /interactive beta publishing.*rxjs/is); + assert.match(errors, /interactive beta publishing.*--tag.*next/is); + assert.match(errors, /interactive beta publishing.*NPM_TOKEN/is); + assert.match(errors, /interactive beta publishing.*registry integrity/is); +}); + test('rejects removal of the clean-workspace release-package build', () => { const input = validInput(); input.ciWorkflowSource = input.ciWorkflowSource.replace( diff --git a/scripts/release/authorize-release-commit.mjs b/scripts/release/authorize-release-commit.mjs deleted file mode 100644 index 25de66de5d..0000000000 --- a/scripts/release/authorize-release-commit.mjs +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env node - -import { readFile } from 'node:fs/promises'; -import path from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import { releaseBranch, releasePullRequestBranch } from './release-config.mjs'; - -const root = fileURLToPath(new URL('../..', import.meta.url)); - -export function selectAuthorizingPullRequest(pullRequests, { commit, repository, version }) { - const matches = pullRequests.filter( - (pullRequest) => - pullRequest.merged_at && - pullRequest.merge_commit_sha === commit && - pullRequest.base?.ref === releaseBranch && - pullRequest.head?.ref === releasePullRequestBranch && - pullRequest.head?.repo?.full_name === repository && - pullRequest.title === `chore(release): ${version}` - ); - if (matches.length !== 1) { - throw new Error(`Expected exactly one merged, repository-owned ${releasePullRequestBranch} PR for ${commit}; found ${matches.length}.`); - } - return matches[0]; -} - -if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { - const [repository, commit] = process.argv.slice(2); - const token = process.env.GH_TOKEN; - if (!repository || !commit || !token) throw new Error('Repository, commit, and GH_TOKEN are required.'); - const version = JSON.parse(await readFile(path.join(root, 'packages/rxjs/package.json'), 'utf8')).version; - const response = await fetch(`https://api.github.com/repos/${repository}/commits/${commit}/pulls`, { - headers: { - accept: 'application/vnd.github+json', - authorization: `Bearer ${token}`, - 'x-github-api-version': '2022-11-28', - }, - }); - if (!response.ok) throw new Error(`GitHub pull-request lookup failed: ${response.status} ${await response.text()}`); - const pullRequest = selectAuthorizingPullRequest(await response.json(), { commit, repository, version }); - process.stdout.write(`${pullRequest.number}\n`); -} diff --git a/scripts/release/authorize-release-commit.test.mjs b/scripts/release/authorize-release-commit.test.mjs deleted file mode 100644 index a18fc71242..0000000000 --- a/scripts/release/authorize-release-commit.test.mjs +++ /dev/null @@ -1,27 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { selectAuthorizingPullRequest } from './authorize-release-commit.mjs'; - -const commit = 'a'.repeat(40); -const repository = 'ReactiveX/rxjs'; -const version = '9.0.0-beta.4'; -const valid = { - number: 123, - merged_at: '2026-08-02T00:00:00Z', - merge_commit_sha: commit, - title: `chore(release): ${version}`, - base: { ref: 'master' }, - head: { ref: 'release/rxjs-9', repo: { full_name: repository } }, -}; - -test('accepts only the exact repository-owned release PR and squash commit', () => { - assert.equal(selectAuthorizingPullRequest([valid], { commit, repository, version }).number, 123); - for (const changed of [ - { merge_commit_sha: 'b'.repeat(40) }, - { title: 'chore(release): 9.0.0-beta.5' }, - { base: { ref: '7.x' } }, - { head: { ref: 'release/rxjs-9', repo: { full_name: 'attacker/rxjs' } } }, - ]) { - assert.throws(() => selectAuthorizingPullRequest([{ ...valid, ...changed }], { commit, repository, version }), /found 0/); - } -}); diff --git a/scripts/release/authorize-stage.mjs b/scripts/release/authorize-stage.mjs deleted file mode 100644 index 143be1edc1..0000000000 --- a/scripts/release/authorize-stage.mjs +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env node - -import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import path from 'node:path'; -import { pathToFileURL } from 'node:url'; -import { releaseBranch, releaseOperatorLogin } from './release-config.mjs'; -import { manifestDigest, verifyCandidate } from './release-candidate.mjs'; - -const maximumArtifactAgeMs = 30 * 24 * 60 * 60_000; - -export function validateStageAuthorization({ - run, - artifact, - currentHead, - actor, - version, - manifest, - manifestSha512, - replayed, - now = Date.now(), -}) { - assert.equal(actor, releaseOperatorLogin, `Only ${releaseOperatorLogin} may authorize npm staging.`); - assert.equal(run.id, Number(run.id), 'Qualification run ID must be numeric.'); - assert.equal(run.name, 'Qualify RxJS 9 release', 'Referenced run is not the qualification workflow.'); - assert.equal(run.event, 'push', 'Qualification must originate from a protected master push.'); - assert.equal(run.head_branch, releaseBranch, 'Qualification run is not on master.'); - assert.equal(run.conclusion, 'success', 'Qualification run did not succeed.'); - assert.equal(run.head_sha, currentHead, 'Qualification run is not for the current master head.'); - assert.equal(manifest.sourceCommit, currentHead, 'Candidate source commit is not the current master head.'); - assert.equal(manifest.version, version, 'Typed version does not match the qualified candidate.'); - assert.match(manifestSha512, /^[0-9a-f]{128}$/, 'Manifest SHA-512 must be 128 lowercase hexadecimal characters.'); - assert.equal(manifest.reproducible, true, 'Candidate has not passed independent reproducibility.'); - assert.equal(manifest.independentBuilds?.length, 2, 'Candidate does not record two independent builds.'); - assert.equal(artifact.expired, false, 'Qualification artifact has expired.'); - assert.equal(artifact.name, `rxjs-release-candidate-${run.id}`, 'Qualification artifact name does not match the run ID.'); - assert.ok(now - Date.parse(run.created_at) <= maximumArtifactAgeMs, 'Qualification run is older than 30 days.'); - assert.equal(replayed, false, `${version} already has a tag or GitHub Release; staging replay is forbidden.`); -} - -export async function validateDownloadedCandidate(candidateRoot, expectedVersion, expectedDigest) { - const manifest = await verifyCandidate(candidateRoot); - const actualDigest = await manifestDigest(candidateRoot); - assert.equal(manifest.version, expectedVersion, 'Typed version does not match the downloaded candidate.'); - assert.equal(actualDigest, expectedDigest, 'Typed manifest SHA-512 does not match the downloaded candidate.'); - return manifest; -} - -if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { - const [repository, runIdText, version, expectedDigest, candidateDirectory] = process.argv.slice(2); - const token = process.env.GH_TOKEN; - const actor = process.env.RELEASE_ACTOR; - if (!repository || !/^\d+$/.test(runIdText ?? '') || !version || !expectedDigest || !candidateDirectory || !token || !actor) { - throw new Error( - 'Usage: authorize-stage.mjs ; GH_TOKEN and RELEASE_ACTOR are required.' - ); - } - const runId = Number(runIdText); - const [run, artifacts, reference, tag, release] = await Promise.all([ - github(`/repos/${repository}/actions/runs/${runId}`, token), - github(`/repos/${repository}/actions/runs/${runId}/artifacts`, token), - github(`/repos/${repository}/git/ref/heads/${releaseBranch}`, token), - github(`/repos/${repository}/git/ref/tags/${encodeURIComponent(version)}`, token, [404]), - github(`/repos/${repository}/releases/tags/${encodeURIComponent(version)}`, token, [404]), - ]); - const artifact = artifacts.artifacts?.find(({ name }) => name === `rxjs-release-candidate-${runId}`); - if (!artifact) throw new Error(`Qualification run ${runId} does not contain its exact candidate artifact.`); - const manifest = await validateDownloadedCandidate(path.resolve(candidateDirectory), version, expectedDigest); - validateStageAuthorization({ - run, - artifact, - currentHead: reference.object.sha, - actor, - version, - manifest, - manifestSha512: expectedDigest, - replayed: tag !== null || release !== null, - }); - process.stdout.write( - `${JSON.stringify({ runId, version, sourceCommit: manifest.sourceCommit, pullRequest: manifest.authorizingPullRequest })}\n` - ); -} - -async function github(endpoint, token, nullableStatuses = []) { - const response = await fetch(`https://api.github.com${endpoint}`, { - headers: { - accept: 'application/vnd.github+json', - authorization: `Bearer ${token}`, - 'x-github-api-version': '2022-11-28', - }, - }); - if (nullableStatuses.includes(response.status)) return null; - if (!response.ok) throw new Error(`GitHub request ${endpoint} failed: ${response.status} ${await response.text()}`); - return response.json(); -} diff --git a/scripts/release/authorize-stage.test.mjs b/scripts/release/authorize-stage.test.mjs deleted file mode 100644 index aec17880e3..0000000000 --- a/scripts/release/authorize-stage.test.mjs +++ /dev/null @@ -1,116 +0,0 @@ -import assert from 'node:assert/strict'; -import { createHash } from 'node:crypto'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import test from 'node:test'; -import fc from 'fast-check'; -import { validateDownloadedCandidate, validateStageAuthorization } from './authorize-stage.mjs'; -import { releasePackages, releaseToolchain } from './release-config.mjs'; - -const digest = 'a'.repeat(128); -const head = 'b'.repeat(40); -const now = Date.parse('2026-08-02T12:00:00Z'); - -function valid() { - return { - run: { - id: 123, - name: 'Qualify RxJS 9 release', - event: 'push', - head_branch: 'master', - head_sha: head, - conclusion: 'success', - created_at: '2026-08-02T00:00:00Z', - }, - artifact: { name: 'rxjs-release-candidate-123', expired: false }, - currentHead: head, - actor: 'benlesh', - version: '9.0.0-beta.1', - manifest: { sourceCommit: head, version: '9.0.0-beta.1', reproducible: true, independentBuilds: [{}, {}] }, - manifestSha512: digest, - replayed: false, - now, - }; -} - -test('rejects every wrong manual-stage authorization dimension', () => { - validateStageAuthorization(valid()); - for (const mutate of [ - (value) => (value.actor = 'attacker'), - (value) => (value.run.id = '123'), - (value) => (value.run.name = 'CI'), - (value) => (value.run.event = 'workflow_dispatch'), - (value) => (value.run.head_branch = 'feature'), - (value) => (value.run.conclusion = 'failure'), - (value) => (value.run.head_sha = 'c'.repeat(40)), - (value) => (value.currentHead = 'c'.repeat(40)), - (value) => (value.manifest.sourceCommit = 'c'.repeat(40)), - (value) => (value.manifest.version = '9.0.0-beta.2'), - (value) => (value.manifestSha512 = 'wrong'), - (value) => (value.artifact.expired = true), - (value) => (value.artifact.name = 'other'), - (value) => (value.manifest.reproducible = false), - (value) => (value.manifest.independentBuilds = [{}]), - (value) => (value.run.created_at = '2026-06-01T00:00:00Z'), - (value) => (value.replayed = true), - ]) { - const value = structuredClone(valid()); - mutate(value); - assert.throws(() => validateStageAuthorization(value)); - } -}); - -test('manifest authorization is exact for arbitrary typed versions and digests', async () => { - const root = await mkdtemp(path.join(tmpdir(), 'rxjs-stage-auth-')); - try { - const packages = []; - for (const [index, { name }] of releasePackages.entries()) { - const filename = `${index}.tgz`; - const bytes = Buffer.from(name); - await writeFile(path.join(root, filename), bytes); - packages.push({ - name, - version: '9.0.0-beta.1', - filename, - size: bytes.length, - sha256: createHash('sha256').update(bytes).digest('hex'), - sha512: createHash('sha512').update(bytes).digest('hex'), - integrity: `sha512-${createHash('sha512').update(bytes).digest('base64')}`, - contents: [], - }); - } - await writeFile( - path.join(root, 'release-manifest.json'), - JSON.stringify({ - schemaVersion: 2, - sourceCommit: head, - authorizingPullRequest: 123, - version: '9.0.0-beta.1', - channel: 'next', - toolchain: releaseToolchain, - build: { id: 'a', runner: 'ubuntu-24.04' }, - reproducible: true, - independentBuilds: [{}, {}], - packages, - }) - ); - const bytes = await import('node:fs/promises').then(({ readFile }) => readFile(path.join(root, 'release-manifest.json'))); - const actual = createHash('sha512').update(bytes).digest('hex'); - await validateDownloadedCandidate(root, '9.0.0-beta.1', actual); - await assert.rejects(() => validateDownloadedCandidate(root, '9.0.0-beta.1', 'b'.repeat(128)), /SHA-512/); - await fc.assert( - fc.asyncProperty( - fc.string().filter((value) => value !== '9.0.0-beta.1'), - async (value) => { - await assert.rejects(() => validateDownloadedCandidate(root, value, actual), /Typed version/); - } - ), - { numRuns: 50 } - ); - await writeFile(path.join(root, packages[0].filename), 'changed bytes'); - await assert.rejects(() => validateDownloadedCandidate(root, '9.0.0-beta.1', actual), /size changed|SHA-256 changed/); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); diff --git a/scripts/release/beta.mjs b/scripts/release/beta.mjs new file mode 100644 index 0000000000..f564256e63 --- /dev/null +++ b/scripts/release/beta.mjs @@ -0,0 +1,257 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { createInterface } from 'node:readline/promises'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const repositoryRoot = fileURLToPath(new URL('../..', import.meta.url)); + +export const releasePackages = Object.freeze([ + { name: '@rxjs/observable-polyfill', directory: 'packages/observable-polyfill' }, + { name: '@rxjs/test', directory: 'packages/test' }, + { name: '@rxjs/migrate', directory: 'packages/migrate' }, + { name: 'rxjs', directory: 'packages/rxjs' }, +]); + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + await main(process.argv.slice(2)); +} + +export async function main(argv, options = {}) { + const { version, dryRun } = parseArguments(argv); + const root = options.root ?? repositoryRoot; + const command = options.command ?? run; + const interactiveCommand = options.interactiveCommand ?? runInteractive; + const prompt = options.prompt ?? confirmVersion; + + validateBetaVersion(version); + assertSupportedNode(process.versions.node); + await assertSynchronizedVersion(root, version); + assertRepositoryReady(root, { live: !dryRun, command }); + assertInteractivePublishingEnvironment({ dryRun, env: process.env, stdin: process.stdin, stdout: process.stdout }); + + const outputRoot = await mkdtemp(path.join(tmpdir(), `rxjs-${version}-`)); + const npmCache = path.join(outputRoot, 'npm-cache'); + try { + printHeading(`Preparing ${version}`); + for (const args of localVerificationCommands()) command(args[0], args.slice(1), { cwd: root, stdio: 'inherit' }); + assertRepositoryReady(root, { live: false, command }); + + const candidates = []; + for (const releasePackage of releasePackages) { + const result = command('npm', ['pack', '--json', '--pack-destination', outputRoot, '--cache', npmCache], { + cwd: path.join(root, releasePackage.directory), + encoding: 'utf8', + }); + const [report] = JSON.parse(result.stdout); + assert.equal(report.name, releasePackage.name, `Packed the wrong package from ${releasePackage.directory}.`); + assert.equal(report.version, version, `${releasePackage.name} packed the wrong version.`); + const tarballPath = path.join(outputRoot, report.filename); + const bytes = await readFile(tarballPath); + candidates.push({ + ...releasePackage, + filename: report.filename, + integrity: `sha512-${createHash('sha512').update(bytes).digest('base64')}`, + size: bytes.byteLength, + tarballPath, + }); + } + + printCandidateSummary(candidates, version); + printHeading('npm publish dry runs'); + for (const candidate of candidates) { + command('npm', publishArguments(candidate.tarballPath, { cache: npmCache, dryRun: true }), { + cwd: root, + stdio: 'inherit', + }); + } + + if (dryRun) { + process.stdout.write(`\nDry run complete. Nothing was published.\n`); + return { candidates, published: false }; + } + + const confirmation = await prompt(version); + if (confirmation !== version) throw new Error('Release cancelled: confirmation did not exactly match the beta version.'); + + printHeading('Publishing to npm under the next tag'); + for (const candidate of candidates) { + const currentIntegrity = registryIntegrity(candidate.name, version, { cache: npmCache, command, root }); + if (currentIntegrity !== null) { + assert.equal(currentIntegrity, candidate.integrity, `${candidate.name}@${version} already exists with different bytes.`); + process.stdout.write(`Already published and verified: ${candidate.name}@${version}\n`); + continue; + } + + process.stdout.write(`\nPublishing ${candidate.name}@${version}. npm may request OTP/WebAuthn.\n`); + interactiveCommand('npm', publishArguments(candidate.tarballPath, { cache: npmCache }), { cwd: root }); + const publishedIntegrity = registryIntegrity(candidate.name, version, { cache: npmCache, command, root }); + assert.equal( + publishedIntegrity, + candidate.integrity, + `${candidate.name}@${version} registry integrity did not match the local tarball.` + ); + process.stdout.write(`Published and verified: ${candidate.name}@${version}\n`); + } + + for (const candidate of candidates) { + const nextVersion = npmView(`${candidate.name}@next`, 'version', { cache: npmCache, command, root }); + assert.equal(nextVersion, version, `${candidate.name}@next does not resolve to ${version}.`); + } + const latestVersion = npmView('rxjs@latest', 'version', { cache: npmCache, command, root }); + assert.match(latestVersion, /^7\./, `rxjs@latest unexpectedly resolves to ${latestVersion}; expected the maintained RxJS 7 line.`); + + process.stdout.write(`\n${version} is published and verified under npm's next tag. rxjs@latest remains ${latestVersion}.\n`); + return { candidates, published: true }; + } finally { + await rm(outputRoot, { recursive: true, force: true }); + } +} + +export function parseArguments(argv) { + const dryRun = argv.includes('--dry-run'); + const positional = argv.filter((value) => value !== '--dry-run'); + if (positional.length !== 1) throw new Error('Usage: pnpm release:beta <9.0.0-beta.N> [--dry-run]'); + return { dryRun, version: positional[0] }; +} + +export function validateBetaVersion(version) { + if (!/^9\.0\.0-beta\.(0|[1-9]\d*)$/.test(version)) { + throw new Error(`Expected an RxJS 9 beta version such as 9.0.0-beta.0; received ${JSON.stringify(version)}.`); + } + return version; +} + +export async function assertSynchronizedVersion(root, version) { + const manifests = new Map(); + for (const releasePackage of releasePackages) { + const manifest = JSON.parse(await readFile(path.join(root, releasePackage.directory, 'package.json'), 'utf8')); + assert.equal(manifest.name, releasePackage.name, `${releasePackage.directory} has the wrong package name.`); + assert.equal(manifest.version, version, `${releasePackage.name} must already be versioned as ${version} on master.`); + manifests.set(releasePackage.name, manifest); + } + assert.equal( + manifests.get('rxjs').dependencies?.['@rxjs/observable-polyfill'], + version, + `rxjs must depend on @rxjs/observable-polyfill@${version}.` + ); +} + +export function assertRepositoryReady(root, { live, command = run }) { + const status = command('git', ['status', '--porcelain', '--untracked-files=normal'], { cwd: root, encoding: 'utf8' }).stdout.trim(); + assert.equal(status, '', 'The release checkout must be clean. Commit or discard every change first.'); + if (!live) return; + + const branch = command('git', ['branch', '--show-current'], { cwd: root, encoding: 'utf8' }).stdout.trim(); + assert.equal(branch, 'master', `Live beta publication must run from master, not ${branch || 'a detached checkout'}.`); + const upstream = command('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], { + cwd: root, + encoding: 'utf8', + }).stdout.trim(); + assert.match(upstream, /(?:^|\/)master$/, `master must track a remote master branch; found ${upstream}.`); + const divergence = command('git', ['rev-list', '--left-right', '--count', 'HEAD...@{upstream}'], { + cwd: root, + encoding: 'utf8', + }).stdout.trim(); + assert.match(divergence, /^0\s+0$/, `master must exactly match ${upstream}; divergence was ${divergence}. Fetch and update first.`); +} + +export function assertInteractivePublishingEnvironment({ dryRun, env, stdin, stdout }) { + if (dryRun) return; + assert.ok(!env.CI, 'Live beta publication is interactive and refuses to run in CI.'); + assert.ok( + !env.NPM_TOKEN && !env.NODE_AUTH_TOKEN, + 'Unset NPM_TOKEN and NODE_AUTH_TOKEN; this command uses interactive npm authentication.' + ); + assert.ok(stdin.isTTY && stdout.isTTY, 'Live beta publication requires an interactive terminal for confirmation and npm OTP/WebAuthn.'); +} + +export function localVerificationCommands() { + return [['pnpm', 'run', 'release:check'], ...releasePackages.map(({ name }) => ['pnpm', '--filter', name, 'run', 'test:package'])]; +} + +export function publishArguments(tarballPath, { cache, dryRun = false } = {}) { + return [ + 'publish', + tarballPath, + '--tag', + 'next', + '--access', + 'public', + ...(cache ? ['--cache', cache] : []), + ...(dryRun ? ['--dry-run'] : []), + ]; +} + +function registryIntegrity(name, version, options) { + const result = npmViewResult(`${name}@${version}`, 'dist.integrity', options); + if (result.status === 0) return parseNpmView(result.stdout); + if (/E404|404 Not Found|is not in this registry/i.test(`${result.stdout}\n${result.stderr}`)) return null; + throw new Error(`Could not determine whether ${name}@${version} already exists.\n${result.stdout}${result.stderr}`); +} + +function npmView(specifier, field, options) { + const result = npmViewResult(specifier, field, options); + if (result.status !== 0) throw new Error(`npm view ${specifier} ${field} failed.\n${result.stdout}${result.stderr}`); + return parseNpmView(result.stdout); +} + +function npmViewResult(specifier, field, { cache, command, root }) { + return command('npm', ['view', specifier, field, '--json', ...(cache ? ['--cache', cache] : [])], { + cwd: root, + encoding: 'utf8', + allowFailure: true, + }); +} + +function parseNpmView(stdout) { + const value = JSON.parse(stdout); + assert.equal(typeof value, 'string', `Expected npm view to return a string; received ${stdout}.`); + return value; +} + +function assertSupportedNode(version) { + const [major, minor] = version.split('.').map(Number); + assert.ok(major > 22 || (major === 22 && minor >= 13), `Node 22.13.0 or newer is required; found ${version}.`); +} + +async function confirmVersion(version) { + const readline = createInterface({ input: process.stdin, output: process.stdout }); + try { + process.stdout.write('\nThis is irreversible. The four packages above will become publicly installable.\n'); + return await readline.question(`Type ${version} to publish, or anything else to cancel: `); + } finally { + readline.close(); + } +} + +function printCandidateSummary(candidates, version) { + printHeading(`Exact ${version} tarballs`); + for (const candidate of candidates) { + process.stdout.write(`${candidate.name.padEnd(30)} ${String(candidate.size).padStart(8)} bytes ${candidate.integrity}\n`); + } + process.stdout.write('\nPublication order is exactly the order above; rxjs is last.\n'); +} + +function printHeading(label) { + process.stdout.write(`\n=== ${label} ===\n`); +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { ...options, encoding: options.encoding ?? 'utf8' }); + if (!options.allowFailure && result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} failed (${result.status}).\n${result.stdout ?? ''}${result.stderr ?? ''}`); + } + return result; +} + +function runInteractive(command, args, { cwd }) { + const result = spawnSync(command, args, { cwd, stdio: 'inherit' }); + if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} failed (${result.status}).`); + return result; +} diff --git a/scripts/release/beta.test.mjs b/scripts/release/beta.test.mjs new file mode 100644 index 0000000000..2ffa602d88 --- /dev/null +++ b/scripts/release/beta.test.mjs @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { + assertInteractivePublishingEnvironment, + assertRepositoryReady, + assertSynchronizedVersion, + localVerificationCommands, + parseArguments, + publishArguments, + releasePackages, + validateBetaVersion, +} from './beta.mjs'; + +test('accepts one explicit RxJS 9 beta and an optional dry run', () => { + assert.deepEqual(parseArguments(['9.0.0-beta.0']), { dryRun: false, version: '9.0.0-beta.0' }); + assert.deepEqual(parseArguments(['9.0.0-beta.12', '--dry-run']), { dryRun: true, version: '9.0.0-beta.12' }); + assert.equal(validateBetaVersion('9.0.0-beta.0'), '9.0.0-beta.0'); + for (const invalid of ['9.0.0', '9.0.1-beta.0', '8.0.0-beta.0', '9.0.0-beta.01', 'next']) { + assert.throws(() => validateBetaVersion(invalid), /Expected an RxJS 9 beta version/); + } + assert.throws(() => parseArguments([]), /Usage/); + assert.throws(() => parseArguments(['9.0.0-beta.0', 'extra']), /Usage/); +}); + +test('publishes the three scoped packages before rxjs and always uses next', () => { + assert.deepEqual( + releasePackages.map(({ name }) => name), + ['@rxjs/observable-polyfill', '@rxjs/test', '@rxjs/migrate', 'rxjs'] + ); + const live = publishArguments('/tmp/rxjs.tgz'); + assert.deepEqual(live, ['publish', '/tmp/rxjs.tgz', '--tag', 'next', '--access', 'public']); + assert.deepEqual(publishArguments('/tmp/rxjs.tgz', { cache: '/tmp/cache', dryRun: true }), [ + ...live, + '--cache', + '/tmp/cache', + '--dry-run', + ]); +}); + +test('runs repository checks and all four package gates before packing', () => { + assert.deepEqual(localVerificationCommands(), [ + ['pnpm', 'run', 'release:check'], + ['pnpm', '--filter', '@rxjs/observable-polyfill', 'run', 'test:package'], + ['pnpm', '--filter', '@rxjs/test', 'run', 'test:package'], + ['pnpm', '--filter', '@rxjs/migrate', 'run', 'test:package'], + ['pnpm', '--filter', 'rxjs', 'run', 'test:package'], + ]); +}); + +test('requires synchronized package versions and the exact internal runtime dependency', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'rxjs-beta-test-')); + try { + for (const releasePackage of releasePackages) { + const directory = path.join(root, releasePackage.directory); + await mkdir(directory, { recursive: true }); + const manifest = { name: releasePackage.name, version: '9.0.0-beta.0' }; + if (releasePackage.name === 'rxjs') manifest.dependencies = { '@rxjs/observable-polyfill': '9.0.0-beta.0' }; + await writeFile(path.join(directory, 'package.json'), JSON.stringify(manifest)); + } + await assertSynchronizedVersion(root, '9.0.0-beta.0'); + const rxjsPath = path.join(root, 'packages/rxjs/package.json'); + await writeFile( + rxjsPath, + JSON.stringify({ name: 'rxjs', version: '9.0.0-beta.0', dependencies: { '@rxjs/observable-polyfill': '^9.0.0-beta.0' } }) + ); + await assert.rejects(() => assertSynchronizedVersion(root, '9.0.0-beta.0'), /must depend/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('live publication requires clean synchronized master while dry runs require only a clean checkout', () => { + const responses = new Map([ + ['status --porcelain --untracked-files=normal', ''], + ['branch --show-current', 'master\n'], + ['rev-parse --abbrev-ref --symbolic-full-name @{upstream}', 'upstream/master\n'], + ['rev-list --left-right --count HEAD...@{upstream}', '0\t0\n'], + ]); + const command = (_command, args) => ({ status: 0, stdout: responses.get(args.join(' ')) ?? '' }); + assert.doesNotThrow(() => assertRepositoryReady('/repo', { live: true, command })); + assert.doesNotThrow(() => assertRepositoryReady('/repo', { live: false, command })); + + responses.set('status --porcelain --untracked-files=normal', ' M package.json\n'); + assert.throws(() => assertRepositoryReady('/repo', { live: false, command }), /must be clean/); + responses.set('status --porcelain --untracked-files=normal', ''); + responses.set('branch --show-current', 'feature\n'); + assert.throws(() => assertRepositoryReady('/repo', { live: true, command }), /must run from master/); +}); + +test('live publication refuses CI, environment tokens, and non-interactive terminals', () => { + const terminal = { isTTY: true }; + assert.doesNotThrow(() => assertInteractivePublishingEnvironment({ dryRun: false, env: {}, stdin: terminal, stdout: terminal })); + assert.throws( + () => assertInteractivePublishingEnvironment({ dryRun: false, env: { CI: 'true' }, stdin: terminal, stdout: terminal }), + /refuses to run in CI/ + ); + assert.throws( + () => assertInteractivePublishingEnvironment({ dryRun: false, env: { NPM_TOKEN: 'secret' }, stdin: terminal, stdout: terminal }), + /Unset NPM_TOKEN/ + ); + assert.throws( + () => assertInteractivePublishingEnvironment({ dryRun: false, env: {}, stdin: { isTTY: false }, stdout: terminal }), + /interactive terminal/ + ); + assert.doesNotThrow(() => + assertInteractivePublishingEnvironment({ dryRun: true, env: { CI: 'true', NPM_TOKEN: 'secret' }, stdin: {}, stdout: {} }) + ); +}); diff --git a/scripts/release/check-release-bot-diff.mjs b/scripts/release/check-release-bot-diff.mjs deleted file mode 100644 index fb6345d602..0000000000 --- a/scripts/release/check-release-bot-diff.mjs +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env node - -import { spawnSync } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; -import { releaseBotAllowedFiles } from './release-config.mjs'; - -const root = fileURLToPath(new URL('../..', import.meta.url)); -const base = process.argv[2] ?? 'HEAD'; -const result = spawnSync('git', ['diff', '--name-only', base], { cwd: root, encoding: 'utf8' }); -if (result.status !== 0) throw new Error(result.stderr); -const files = result.stdout.split('\n').filter(Boolean); -const forbidden = files.filter((file) => !releaseBotAllowedFiles.has(file)); -if (forbidden.length > 0) throw new Error(`Release automation changed forbidden files:\n- ${forbidden.join('\n- ')}`); -process.stdout.write(`Release automation changed only ${files.length} allowlisted file(s).\n`); diff --git a/scripts/release/compare-release-candidates.mjs b/scripts/release/compare-release-candidates.mjs deleted file mode 100644 index 90aa41ec84..0000000000 --- a/scripts/release/compare-release-candidates.mjs +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env node - -import path from 'node:path'; -import { compareCandidates } from './release-candidate.mjs'; - -const [first, second, output] = process.argv.slice(2); -if (!first || !second || !output) throw new Error('Usage: compare-release-candidates.mjs '); -const manifest = await compareCandidates(path.resolve(first), path.resolve(second), path.resolve(output)); -process.stdout.write(`${JSON.stringify({ version: manifest.version, sourceCommit: manifest.sourceCommit, reproducible: true })}\n`); diff --git a/scripts/release/conventional-commit.mjs b/scripts/release/conventional-commit.mjs new file mode 100644 index 0000000000..5b3ef8e138 --- /dev/null +++ b/scripts/release/conventional-commit.mjs @@ -0,0 +1,12 @@ +const conventionalTitle = + /^(?feat|fix|perf|revert|docs|chore|refactor|test|build|ci|style)(?:\([^)\r\n]+\))?(?!)?: (?\S.*)$/; + +export function validateConventionalTitle(title) { + const match = conventionalTitle.exec(title); + if (!match?.groups) throw new Error('Title is not a supported Conventional Commit.'); + return { + breaking: match.groups.breaking === '!', + description: match.groups.description, + type: match.groups.type, + }; +} diff --git a/scripts/release/conventional-commit.test.mjs b/scripts/release/conventional-commit.test.mjs new file mode 100644 index 0000000000..55d9b3ff84 --- /dev/null +++ b/scripts/release/conventional-commit.test.mjs @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { validateConventionalTitle } from './conventional-commit.mjs'; + +test('accepts the repository Conventional Commit title forms', () => { + assert.deepEqual(validateConventionalTitle('fix: repair teardown'), { + breaking: false, + description: 'repair teardown', + type: 'fix', + }); + assert.deepEqual(validateConventionalTitle('feat(observable)!: change lifecycle'), { + breaking: true, + description: 'change lifecycle', + type: 'feat', + }); +}); + +test('rejects unsupported or empty titles', () => { + for (const title of ['', 'Update release', 'feature: wrong type', 'fix:']) { + assert.throws(() => validateConventionalTitle(title), /not a supported Conventional Commit/); + } +}); diff --git a/scripts/release/finalize-release.mjs b/scripts/release/finalize-release.mjs deleted file mode 100644 index db26c41974..0000000000 --- a/scripts/release/finalize-release.mjs +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env node - -import { spawnSync } from 'node:child_process'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { npmRegistryVersionUrl } from './release-config.mjs'; -import { verifyCandidate } from './release-candidate.mjs'; - -const candidateRoot = path.resolve(process.argv[2]); -const manifest = await verifyCandidate(candidateRoot); -for (const entry of manifest.packages) { - const url = npmRegistryVersionUrl(entry.name, entry.version); - const response = await fetch(url, { headers: { accept: 'application/json' } }); - if (response.status === 404) { - process.stdout.write(`${entry.name}@${entry.version} is not public yet.\n`); - process.exitCode = 2; - break; - } - if (!response.ok) throw new Error(`npm registry request failed for ${entry.name}: ${response.status}`); - const metadata = await response.json(); - if (metadata.dist?.integrity !== entry.integrity) { - throw new Error(`${entry.name}@${entry.version} is public but its registry integrity does not match the qualified tarball.`); - } - process.stdout.write(`Verified public ${entry.name}@${entry.version} at ${entry.integrity}.\n`); -} - -if (!process.exitCode) { - const auditRoot = await mkdtemp(path.join(tmpdir(), 'rxjs-release-signatures-')); - try { - await writeFile( - path.join(auditRoot, 'package.json'), - `${JSON.stringify( - { - name: 'rxjs-release-signature-audit', - private: true, - dependencies: Object.fromEntries(manifest.packages.map(({ name, version }) => [name, version])), - }, - null, - 2 - )}\n` - ); - run('npm', ['install', '--ignore-scripts', '--package-lock-only', '--no-audit', '--no-fund'], auditRoot); - run('npm', ['audit', 'signatures'], auditRoot); - process.stdout.write('Verified npm registry signatures and provenance for the public release train.\n'); - } finally { - await rm(auditRoot, { recursive: true, force: true }); - } -} - -function run(command, args, cwd) { - const result = spawnSync(command, args, { cwd, encoding: 'utf8' }); - if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} failed (${result.status}).\n${result.stdout}${result.stderr}`); - return result; -} diff --git a/scripts/release/generate-release-evidence.mjs b/scripts/release/generate-release-evidence.mjs deleted file mode 100644 index 196224d7ca..0000000000 --- a/scripts/release/generate-release-evidence.mjs +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env node - -import { spawnSync } from 'node:child_process'; -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { recordEvidence, verifyCandidate } from './release-candidate.mjs'; - -const root = fileURLToPath(new URL('../..', import.meta.url)); -const candidateRoot = path.resolve(root, process.argv[2] ?? '.release/candidate'); -const manifest = await verifyCandidate(candidateRoot); -if (!manifest.reproducible || manifest.independentBuilds.length !== 2) { - throw new Error('Release evidence requires two matching independent builds.'); -} - -const temporaryRoot = await mkdtemp(path.join(tmpdir(), 'rxjs-release-evidence-')); -try { - const dependencies = Object.fromEntries( - manifest.packages.map(({ name, filename }) => [name, `file:${path.join(candidateRoot, filename)}`]) - ); - await writeFile( - path.join(temporaryRoot, 'package.json'), - `${JSON.stringify({ name: 'rxjs-release-train-evidence', version: manifest.version, private: true, dependencies }, null, 2)}\n` - ); - run('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', '--package-lock-only'], temporaryRoot); - run('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund'], temporaryRoot); - const sbom = run('npm', ['sbom', '--sbom-format', 'cyclonedx', '--json'], temporaryRoot).stdout; - const parsed = JSON.parse(sbom); - delete parsed.serialNumber; - parsed.metadata ??= {}; - delete parsed.metadata.timestamp; - normalizeLocalPaths(parsed); - const sbomFilename = `rxjs-${manifest.version}.cdx.json`; - const lockFilename = `rxjs-${manifest.version}.release-lock.json`; - await writeFile(path.join(candidateRoot, sbomFilename), `${JSON.stringify(parsed, null, 2)}\n`); - const releaseLock = JSON.parse(await readFile(path.join(temporaryRoot, 'package-lock.json'), 'utf8')); - normalizeLocalPaths(releaseLock); - await writeFile(path.join(candidateRoot, lockFilename), `${JSON.stringify(releaseLock, null, 2)}\n`); - await recordEvidence(candidateRoot, [sbomFilename, lockFilename]); - process.stdout.write(`${JSON.stringify({ sbomFilename, lockFilename })}\n`); -} finally { - await rm(temporaryRoot, { recursive: true, force: true }); -} - -function normalizeLocalPaths(value) { - if (Array.isArray(value)) { - for (const item of value) normalizeLocalPaths(item); - return; - } - if (!value || typeof value !== 'object') return; - for (const [key, item] of Object.entries(value)) { - if (typeof item === 'string') { - value[key] = item.replaceAll(candidateRoot, '.').replaceAll(temporaryRoot, '.'); - } else { - normalizeLocalPaths(item); - } - } -} - -function run(command, args, cwd) { - const result = spawnSync(command, args, { cwd, encoding: 'utf8' }); - if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} failed (${result.status}).\n${result.stdout}${result.stderr}`); - return result; -} diff --git a/scripts/release/install-pinned-npm.mjs b/scripts/release/install-pinned-npm.mjs deleted file mode 100644 index a22ee17d97..0000000000 --- a/scripts/release/install-pinned-npm.mjs +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env node - -import { spawnSync } from 'node:child_process'; -import { createHash } from 'node:crypto'; -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { pathToFileURL } from 'node:url'; -import { releaseToolchain } from './release-config.mjs'; - -export function verifyRegistryIntegrity(bytes, expectedIntegrity = releaseToolchain.npmIntegrity) { - const actual = `sha512-${createHash('sha512').update(bytes).digest('base64')}`; - if (actual !== expectedIntegrity) - throw new Error(`npm registry tarball integrity mismatch: expected ${expectedIntegrity}, got ${actual}.`); - return actual; -} - -export async function installPinnedNpm(installRoot) { - const metadataUrl = `https://registry.npmjs.org/npm/${releaseToolchain.npm}`; - const metadataResponse = await fetch(metadataUrl, { headers: { accept: 'application/json' } }); - if (!metadataResponse.ok) throw new Error(`npm registry metadata request failed with HTTP ${metadataResponse.status}.`); - const metadata = await metadataResponse.json(); - if (metadata.version !== releaseToolchain.npm || metadata.dist?.integrity !== releaseToolchain.npmIntegrity) { - throw new Error('The npm registry metadata does not match the checked-in npm version and SHA-512.'); - } - const tarballResponse = await fetch(metadata.dist.tarball); - if (!tarballResponse.ok) throw new Error(`npm registry tarball request failed with HTTP ${tarballResponse.status}.`); - const bytes = Buffer.from(await tarballResponse.arrayBuffer()); - verifyRegistryIntegrity(bytes); - - await mkdir(installRoot, { recursive: true }); - const tarball = path.join(installRoot, `npm-${releaseToolchain.npm}.tgz`); - await writeFile(tarball, bytes); - const result = spawnSync('npm', ['install', '--global', '--ignore-scripts', '--prefix', installRoot, tarball], { - encoding: 'utf8', - env: { ...process.env, NPM_CONFIG_AUDIT: 'false', NPM_CONFIG_FUND: 'false' }, - }); - if (result.status !== 0) throw new Error(`Installing the verified npm CLI failed (${result.status}).\n${result.stdout}${result.stderr}`); - const executable = path.join(installRoot, 'bin', 'npm'); - await readFile(executable); - return executable; -} - -if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { - const installRoot = path.resolve(process.argv[2] ?? '.release/npm-cli'); - const executable = await installPinnedNpm(installRoot); - process.stdout.write(`${executable}\n`); -} diff --git a/scripts/release/install-pinned-npm.test.mjs b/scripts/release/install-pinned-npm.test.mjs deleted file mode 100644 index 1441df90dc..0000000000 --- a/scripts/release/install-pinned-npm.test.mjs +++ /dev/null @@ -1,17 +0,0 @@ -import assert from 'node:assert/strict'; -import { createHash } from 'node:crypto'; -import test from 'node:test'; -import fc from 'fast-check'; -import { verifyRegistryIntegrity } from './install-pinned-npm.mjs'; - -test('accepts only the checked SHA-512 bytes', () => { - fc.assert( - fc.property(fc.uint8Array({ maxLength: 4096 }), (value) => { - const bytes = Buffer.from(value); - const integrity = `sha512-${createHash('sha512').update(bytes).digest('base64')}`; - assert.equal(verifyRegistryIntegrity(bytes, integrity), integrity); - assert.throws(() => verifyRegistryIntegrity(Buffer.concat([bytes, Buffer.of(0)]), integrity), /integrity mismatch/); - }), - { numRuns: 100 } - ); -}); diff --git a/scripts/release/prepare-release-pr.mjs b/scripts/release/prepare-release-pr.mjs deleted file mode 100644 index 6f03deee4e..0000000000 --- a/scripts/release/prepare-release-pr.mjs +++ /dev/null @@ -1,166 +0,0 @@ -#!/usr/bin/env node - -import { spawnSync } from 'node:child_process'; -import { readFile, writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { assertNpmWebUrl, firstReleaseVersion, releasePackages, stagedPackagesVariable, versionedFiles } from './release-config.mjs'; -import { selectRelease } from './release-policy.mjs'; - -const root = fileURLToPath(new URL('../..', import.meta.url)); -const args = parseArgs(process.argv.slice(2)); -const mode = args.mode ?? 'auto'; -const output = path.resolve(args.output ?? path.join(root, '.release-pr.md')); -const resultOutput = path.resolve(args.result ?? path.join(root, '.release-plan-result.json')); -const manifestVersion = JSON.parse(await readFile(path.join(root, 'packages/rxjs/package.json'), 'utf8')).version; -const currentTag = latestRxjs9Tag(); -const range = currentTag ? `${currentTag}..HEAD` : 'D-054 approved beta.0 snapshot'; -const commits = currentTag - ? readCommits(range) - : [{ body: '', sha: git(['rev-parse', 'HEAD']), subject: 'feat(release): begin the RxJS 9 public beta' }]; -const headSubject = git(['log', '-1', '--format=%s']); -const plan = - headSubject === `chore(release): ${manifestVersion}` - ? { status: 'none', reason: 'The merged release commit is already being qualified.', commits: [] } - : selectRelease({ currentTag, manifestVersion, commits, mode }); - -await writeFile(resultOutput, `${JSON.stringify({ ...plan, currentTag, range }, null, 2)}\n`); -if (plan.status !== 'planned') { - process.stdout.write(`${plan.status}: ${plan.reason}\n`); -} else { - await applyVersion(plan.version); - await updateChangelog(plan); - const stagedPackagesUrl = assertNpmWebUrl(process.env[stagedPackagesVariable] ?? '', stagedPackagesVariable); - const affectedPackages = affectedReleasePackages(range); - await writeFile(output, renderPullRequestBody({ ...plan, affectedPackages, currentTag, range, stagedPackagesUrl })); - process.stdout.write(`${plan.version} on ${plan.channel}: ${plan.reason}\n`); -} - -function parseArgs(values) { - const parsed = {}; - for (let index = 0; index < values.length; index += 2) { - const key = values[index]?.replace(/^--/, ''); - if (!key || values[index + 1] === undefined) throw new Error(`Invalid argument list: ${values.join(' ')}`); - parsed[key] = values[index + 1]; - } - return parsed; -} - -function git(args, { allowFailure = false } = {}) { - const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' }); - if (result.status !== 0 && !allowFailure) throw new Error(`git ${args.join(' ')} failed:\n${result.stderr}`); - return result.stdout.trim(); -} - -function latestRxjs9Tag() { - return ( - git(['tag', '--list', '9.*', '--sort=-version:refname'], { allowFailure: true }) - .split('\n') - .find((tag) => /^9\.\d+\.\d+(?:-beta\.\d+)?$/.test(tag)) ?? null - ); -} - -function readCommits(range) { - const log = git(['log', '--format=%H%x1f%s%x1f%b%x1e', range]); - return log - .split('\x1e') - .map((record) => record.trim()) - .filter(Boolean) - .map((record) => { - const [sha, subject, body = ''] = record.split('\x1f'); - return { body: body.trim(), sha, subject }; - }); -} - -function affectedReleasePackages(range) { - if (!currentTag) return releasePackages.map(({ name }) => name); - const changed = git(['diff', '--name-only', range]).split('\n').filter(Boolean); - return releasePackages.filter(({ directory }) => changed.some((file) => file.startsWith(`${directory}/`))).map(({ name }) => name); -} - -async function applyVersion(version) { - const manifests = Object.fromEntries( - await Promise.all( - releasePackages.map(async ({ directory, name }) => [ - name, - JSON.parse(await readFile(path.join(root, directory, 'package.json'), 'utf8')), - ]) - ) - ); - for (const { directory, name } of releasePackages) { - const manifest = manifests[name]; - manifest.version = version; - for (const field of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) { - for (const dependency of releasePackages) { - if (manifest[field]?.[dependency.name] !== undefined) manifest[field][dependency.name] = version; - } - } - await writeFile(path.join(root, directory, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`); - } - - for (const relativePath of versionedFiles) { - const absolutePath = path.join(root, relativePath); - const source = await readFile(absolutePath, 'utf8'); - const replaced = source.replaceAll(firstReleaseVersion, version).replace(/9\.\d+\.\d+(?:-beta\.\d+)?/g, version); - if (source === replaced && !source.includes(version)) throw new Error(`Could not update the release identity in ${relativePath}.`); - await writeFile(absolutePath, replaced); - } - - const provenancePath = path.join(root, '.agents/skills/rxjs-next-migration/.rxjs-migrate-skill.json'); - const provenance = JSON.parse(await readFile(provenancePath, 'utf8')); - provenance.packageVersion = version; - await writeFile(provenancePath, `${JSON.stringify(provenance, null, 2)}\n`); -} - -async function updateChangelog(plan) { - const changelogPath = path.join(root, 'CHANGELOG.md'); - const previous = await readFile(changelogPath, 'utf8').catch(() => '# Changelog\n\n'); - const withoutSameVersion = previous.replace(new RegExp(`## ${escapeRegExp(plan.version)}[\\s\\S]*?(?=\\n## |$)`), '').trimEnd(); - const sections = [ - ['Breaking changes', 'breaking'], - ['Features', 'feature'], - ['Fixes', 'fix'], - ] - .map(([heading, level]) => { - const entries = plan.commits.filter(({ classification }) => classification.level === level); - if (entries.length === 0) return ''; - return `### ${heading}\n\n${entries - .map(({ classification, sha }) => `- ${classification.description} (${sha.slice(0, 7)})`) - .join('\n')}\n`; - }) - .filter(Boolean) - .join('\n'); - await writeFile( - changelogPath, - `# Changelog\n\n## ${plan.version}\n\n${sections}\n${withoutSameVersion.replace(/^# Changelog\s*/, '')}`.trimEnd() + '\n' - ); -} - -function renderPullRequestBody(plan) { - const packages = releasePackages.map(({ name }) => `- \`${name}@${plan.version}\``).join('\n'); - const affected = - plan.affectedPackages.length > 0 ? plan.affectedPackages.map((name) => `\`${name}\``).join(', ') : 'release metadata only'; - const categorized = [ - ['Breaking changes', 'breaking'], - ['Features', 'feature'], - ['Fixes', 'fix'], - ] - .map(([heading, level]) => { - const entries = plan.commits.filter(({ classification }) => classification.level === level); - return entries.length ? `### ${heading}\n\n${entries.map(({ classification }) => `- ${classification.description}`).join('\n')}` : ''; - }) - .filter(Boolean) - .join('\n\n'); - return ( - `# RxJS ${plan.version} release\n\n` + - `> [!CAUTION]\n> npm publication is irreversible for RxJS. Merging this PR authorizes qualification and npm staging; publication still requires TFA approval in npm.\n\n` + - `## Proposed release\n\n- Version: \`${plan.version}\`\n- npm channel: \`${plan.channel}\`\n- Selection reason: ${plan.reason}\n- Commit range: \`${plan.range}\`\n- Affected packages: ${affected}\n\n` + - `## Synchronized package train\n\n${packages}\n\n## Changelog\n\n${categorized}\n\n` + - `## Approval\n\n[**Open npm Staged Packages**](${plan.stagedPackagesUrl})\n\n` + - `After this PR is merged, GitHub will build and pack once, attest and qualify those exact tarballs, create the protected tag, and stage the same files. Approve \`@rxjs/observable-polyfill\`, \`@rxjs/test\`, and \`@rxjs/migrate\` before approving \`rxjs\` last.\n` - ); -} - -function escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} diff --git a/scripts/release/release-candidate.mjs b/scripts/release/release-candidate.mjs deleted file mode 100644 index 3f00835ffb..0000000000 --- a/scripts/release/release-candidate.mjs +++ /dev/null @@ -1,195 +0,0 @@ -#!/usr/bin/env node - -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import { createHash } from 'node:crypto'; -import { cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import { auditPackedPackage } from '../prerelease-adoption-lib.mjs'; -import { channelForVersion, releasePackages, releaseToolchain } from './release-config.mjs'; - -const root = fileURLToPath(new URL('../..', import.meta.url)); -if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { - const [command, directory = '.release/candidate'] = process.argv.slice(2); - const candidateRoot = path.resolve(root, directory); - const expectedSourceCommit = process.env.RELEASE_EXPECTED_SOURCE_COMMIT; - if (command === 'build') await buildCandidate(candidateRoot, { expectedSourceCommit }); - else if (command === 'verify') await verifyCandidate(candidateRoot, { expectedSourceCommit }); - else if (command === 'hydrate') await hydrateCandidate(candidateRoot, { expectedSourceCommit }); - else if (command === 'record-evidence') await recordEvidence(candidateRoot, process.argv.slice(4)); - else if (command === 'manifest-digest') process.stdout.write(`${await manifestDigest(candidateRoot)}\n`); - else throw new Error('Usage: release-candidate.mjs [candidate-directory]'); -} - -async function buildCandidate(outputRoot, { expectedSourceCommit } = {}) { - await rm(outputRoot, { recursive: true, force: true }); - await mkdir(outputRoot, { recursive: true }); - const budgets = JSON.parse(await readFile(path.join(root, 'packages/rxjs/test/release/budgets.json'), 'utf8')); - const packages = []; - const npmCache = await mkdtemp(path.join(tmpdir(), 'rxjs-release-npm-cache-')); - try { - for (const releasePackage of releasePackages) { - const result = run( - 'npm', - ['pack', '--json', '--pack-destination', outputRoot, '--cache', npmCache], - path.join(root, releasePackage.directory) - ); - const [report] = JSON.parse(result.stdout); - assert.equal(report.name, releasePackage.name); - const auditErrors = auditPackedPackage(report, budgets); - assert.deepEqual(auditErrors, [], auditErrors.join('\n')); - const tarball = path.join(outputRoot, report.filename); - const bytes = await readFile(tarball); - packages.push({ - name: report.name, - version: report.version, - filename: report.filename, - size: bytes.byteLength, - sha256: createHash('sha256').update(bytes).digest('hex'), - sha512: createHash('sha512').update(bytes).digest('hex'), - integrity: `sha512-${createHash('sha512').update(bytes).digest('base64')}`, - contents: report.files.map(({ path: filePath, size }) => ({ path: filePath, size })), - }); - } - } finally { - await rm(npmCache, { recursive: true, force: true }); - } - const versions = new Set(packages.map(({ version }) => version)); - assert.equal(versions.size, 1, 'Candidate packages must have one synchronized version.'); - const [version] = versions; - const manifest = { - schemaVersion: 2, - sourceCommit: run('git', ['rev-parse', 'HEAD'], root).stdout.trim(), - authorizingPullRequest: Number(process.env.RELEASE_AUTHORIZING_PR ?? 0), - version, - channel: channelForVersion(version), - toolchain: releaseToolchain, - build: { - id: process.env.RELEASE_BUILD_ID ?? 'local', - runner: process.env.RELEASE_RUNNER_IMAGE ?? process.platform, - }, - reproducible: false, - independentBuilds: [], - packages, - }; - await writeFile(path.join(outputRoot, 'release-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`); - await verifyCandidate(outputRoot, { expectedSourceCommit }); - process.stdout.write(`${JSON.stringify({ version, channel: manifest.channel, packages: packages.length })}\n`); -} - -export async function verifyCandidate(outputRoot, { expectedSourceCommit } = {}) { - const manifest = JSON.parse(await readFile(path.join(outputRoot, 'release-manifest.json'), 'utf8')); - assert.equal(manifest.schemaVersion, 2); - assert.deepEqual(manifest.toolchain, releaseToolchain, 'Candidate toolchain drifted.'); - if (expectedSourceCommit) assert.equal(manifest.sourceCommit, expectedSourceCommit, 'Candidate source commit changed.'); - assert.ok( - Number.isSafeInteger(manifest.authorizingPullRequest) && manifest.authorizingPullRequest >= 0, - 'Authorizing pull request is invalid.' - ); - assert.equal(manifest.packages.length, releasePackages.length); - assert.deepEqual( - manifest.packages.map(({ name }) => name), - releasePackages.map(({ name }) => name), - 'Candidate approval order or package inventory changed.' - ); - assert.equal(manifest.channel, channelForVersion(manifest.version)); - for (const entry of manifest.packages) { - assert.equal(entry.version, manifest.version); - const tarballPath = path.join(outputRoot, entry.filename); - const info = await stat(tarballPath); - const bytes = await readFile(tarballPath); - assert.equal(info.size, entry.size, `${entry.name} size changed.`); - assert.equal(createHash('sha256').update(bytes).digest('hex'), entry.sha256, `${entry.name} SHA-256 changed.`); - assert.equal(createHash('sha512').update(bytes).digest('hex'), entry.sha512, `${entry.name} SHA-512 changed.`); - assert.equal(`sha512-${createHash('sha512').update(bytes).digest('base64')}`, entry.integrity, `${entry.name} integrity changed.`); - } - const expectedFiles = new Set(['release-manifest.json', ...manifest.packages.map(({ filename }) => filename)]); - for (const evidence of manifest.evidence ?? []) { - const evidencePath = path.join(outputRoot, evidence.filename); - const bytes = await readFile(evidencePath); - assert.equal(createHash('sha512').update(bytes).digest('hex'), evidence.sha512, `${evidence.filename} SHA-512 changed.`); - expectedFiles.add(evidence.filename); - } - const actualFiles = new Set(await readdir(outputRoot)); - assert.deepEqual(actualFiles, expectedFiles, 'Candidate contains missing or additional files.'); - process.stdout.write(`Verified exact candidate ${manifest.version} (${manifest.sourceCommit}).\n`); - return manifest; -} - -export async function compareCandidates(firstRoot, secondRoot, outputRoot) { - const first = await verifyCandidate(firstRoot); - const second = await verifyCandidate(secondRoot); - const normalized = (manifest) => ({ - schemaVersion: manifest.schemaVersion, - sourceCommit: manifest.sourceCommit, - authorizingPullRequest: manifest.authorizingPullRequest, - version: manifest.version, - channel: manifest.channel, - toolchain: manifest.toolchain, - packages: manifest.packages, - }); - assert.deepEqual(normalized(second), normalized(first), 'Independent build manifest or inventory drifted.'); - for (const entry of first.packages) { - assert.deepEqual( - await readFile(path.join(secondRoot, entry.filename)), - await readFile(path.join(firstRoot, entry.filename)), - `${entry.name} was not byte-identical across independent builds.` - ); - } - await rm(outputRoot, { recursive: true, force: true }); - await cp(firstRoot, outputRoot, { recursive: true }); - first.reproducible = true; - first.independentBuilds = [first, second].map((manifest) => ({ id: manifest.build.id, runner: manifest.build.runner })); - await writeFile(path.join(outputRoot, 'release-manifest.json'), `${JSON.stringify(first, null, 2)}\n`); - return verifyCandidate(outputRoot); -} - -export async function recordEvidence(outputRoot, filenames) { - assert.ok(filenames.length > 0, 'At least one evidence filename is required.'); - const manifestPath = path.join(outputRoot, 'release-manifest.json'); - const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); - const packageFiles = new Set(manifest.packages.map(({ filename }) => filename)); - const evidence = new Map((manifest.evidence ?? []).map((entry) => [entry.filename, entry])); - for (const filename of filenames) { - assert.equal(path.basename(filename), filename, 'Evidence filenames must not contain a path.'); - assert.ok(!packageFiles.has(filename) && filename !== 'release-manifest.json', `${filename} is not an evidence filename.`); - const bytes = await readFile(path.join(outputRoot, filename)); - evidence.set(filename, { filename, size: bytes.length, sha512: createHash('sha512').update(bytes).digest('hex') }); - } - manifest.evidence = [...evidence.values()].sort((a, b) => a.filename.localeCompare(b.filename)); - await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); - return verifyCandidate(outputRoot); -} - -export async function manifestDigest(outputRoot) { - const bytes = await readFile(path.join(outputRoot, 'release-manifest.json')); - return createHash('sha512').update(bytes).digest('hex'); -} - -async function hydrateCandidate(outputRoot, { expectedSourceCommit } = {}) { - const manifest = await verifyCandidate(outputRoot, { expectedSourceCommit }); - const temporaryRoot = await mkdtemp(path.join(tmpdir(), 'rxjs-release-hydrate-')); - try { - for (const entry of manifest.packages) { - const config = releasePackages.find(({ name }) => name === entry.name); - const extractionRoot = path.join(temporaryRoot, entry.name.replaceAll('/', '-')); - await mkdir(extractionRoot, { recursive: true }); - run('tar', ['-xzf', path.join(outputRoot, entry.filename), '-C', extractionRoot], root); - const target = path.join(root, config.directory); - await rm(path.join(target, 'dist'), { recursive: true, force: true }); - await cp(path.join(extractionRoot, 'package', 'dist'), path.join(target, 'dist'), { recursive: true }); - await cp(path.join(extractionRoot, 'package', 'package.json'), path.join(target, 'package.json')); - } - } finally { - await rm(temporaryRoot, { recursive: true, force: true }); - } - process.stdout.write(`Hydrated workspace package entry points from exact candidate ${manifest.version}.\n`); -} - -function run(command, args, cwd) { - const result = spawnSync(command, args, { cwd, encoding: 'utf8' }); - if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} failed (${result.status}).\n${result.stdout}${result.stderr}`); - return result; -} diff --git a/scripts/release/release-candidate.test.mjs b/scripts/release/release-candidate.test.mjs deleted file mode 100644 index 62b9a796fa..0000000000 --- a/scripts/release/release-candidate.test.mjs +++ /dev/null @@ -1,115 +0,0 @@ -import assert from 'node:assert/strict'; -import { createHash } from 'node:crypto'; -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import test from 'node:test'; -import { compareCandidates, verifyCandidate } from './release-candidate.mjs'; -import { releasePackages, releaseToolchain } from './release-config.mjs'; - -test('verifies every byte and the fixed approval order', async () => { - const root = await mkdtemp(path.join(tmpdir(), 'rxjs-candidate-test-')); - try { - const packages = []; - for (const [index, { name }] of releasePackages.entries()) { - const filename = `package-${index}.tgz`; - const bytes = Buffer.from(`exact bytes ${name}`); - await writeFile(path.join(root, filename), bytes); - packages.push({ - name, - version: '9.0.0-beta.2', - filename, - size: bytes.byteLength, - sha256: createHash('sha256').update(bytes).digest('hex'), - sha512: createHash('sha512').update(bytes).digest('hex'), - integrity: `sha512-${createHash('sha512').update(bytes).digest('base64')}`, - contents: [], - }); - } - await writeFile( - path.join(root, 'release-manifest.json'), - JSON.stringify({ - schemaVersion: 2, - sourceCommit: 'a'.repeat(40), - authorizingPullRequest: 123, - version: '9.0.0-beta.2', - channel: 'next', - toolchain: releaseToolchain, - build: { id: 'a', runner: 'ubuntu-24.04' }, - reproducible: false, - independentBuilds: [], - packages, - }) - ); - const ambientGitHubSha = process.env.GITHUB_SHA; - process.env.GITHUB_SHA = 'b'.repeat(40); - try { - await verifyCandidate(root); - } finally { - if (ambientGitHubSha === undefined) delete process.env.GITHUB_SHA; - else process.env.GITHUB_SHA = ambientGitHubSha; - } - await verifyCandidate(root, { expectedSourceCommit: 'a'.repeat(40) }); - await assert.rejects(() => verifyCandidate(root, { expectedSourceCommit: 'b'.repeat(40) }), /Candidate source commit changed/); - await writeFile(path.join(root, packages[0].filename), 'changed'); - await assert.rejects(() => verifyCandidate(root), /size changed|SHA-256 changed/); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); - -test('requires two byte-identical independent builds and rejects inventory drift', async () => { - const first = await mkdtemp(path.join(tmpdir(), 'rxjs-candidate-first-')); - const second = await mkdtemp(path.join(tmpdir(), 'rxjs-candidate-second-')); - const output = await mkdtemp(path.join(tmpdir(), 'rxjs-candidate-output-')); - try { - for (const [directory, id] of [ - [first, 'a'], - [second, 'b'], - ]) { - const packages = []; - for (const [index, { name }] of releasePackages.entries()) { - const filename = `package-${index}.tgz`; - const bytes = Buffer.from(`exact bytes ${name}`); - await writeFile(path.join(directory, filename), bytes); - packages.push({ - name, - version: '9.0.0-beta.2', - filename, - size: bytes.length, - sha256: createHash('sha256').update(bytes).digest('hex'), - sha512: createHash('sha512').update(bytes).digest('hex'), - integrity: `sha512-${createHash('sha512').update(bytes).digest('base64')}`, - contents: [], - }); - } - await writeFile( - path.join(directory, 'release-manifest.json'), - JSON.stringify({ - schemaVersion: 2, - sourceCommit: 'a'.repeat(40), - authorizingPullRequest: 123, - version: '9.0.0-beta.2', - channel: 'next', - toolchain: releaseToolchain, - build: { id, runner: 'ubuntu-24.04' }, - reproducible: false, - independentBuilds: [], - packages, - }) - ); - } - const manifest = await compareCandidates(first, second, output); - assert.equal(manifest.reproducible, true); - assert.deepEqual( - manifest.independentBuilds.map(({ id }) => id), - ['a', 'b'] - ); - await writeFile(path.join(second, 'package-0.tgz'), 'changed'); - await assert.rejects(() => compareCandidates(first, second, output), /size changed|SHA-256 changed|byte-identical/); - await writeFile(path.join(first, 'unexpected'), 'unexpected'); - await assert.rejects(() => verifyCandidate(first), /missing or additional files/); - } finally { - await Promise.all([first, second, output].map((directory) => rm(directory, { recursive: true, force: true }))); - } -}); diff --git a/scripts/release/release-config.mjs b/scripts/release/release-config.mjs deleted file mode 100644 index 5a99b055d3..0000000000 --- a/scripts/release/release-config.mjs +++ /dev/null @@ -1,78 +0,0 @@ -export const releasePackages = [ - { name: '@rxjs/observable-polyfill', directory: 'packages/observable-polyfill' }, - { name: '@rxjs/test', directory: 'packages/test' }, - { name: '@rxjs/migrate', directory: 'packages/migrate' }, - { name: 'rxjs', directory: 'packages/rxjs' }, -]; - -export const releaseBranch = 'master'; -export const releasePullRequestBranch = 'release/rxjs-9'; -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', - pnpm: '10.34.5', - npm: '11.18.0', - npmIntegrity: 'sha512-T67M4L5wNm0cZ7EBLErcEkY1SmzEW/WJ+SADBzsFUY1UdAPfFHXFQtZ6SEXiK0+vzXysCvAsepbMaBTwnrAD+w==', -}); - -export const versionedFiles = [ - 'packages/observable-polyfill/src/index.ts', - 'packages/observable-polyfill/test/import/esm.mjs', - 'packages/observable-polyfill/test/import/commonjs.cjs', - 'packages/rxjs/test/import/fixture-scenario.mjs', - 'packages/migrate/src/version.ts', -]; - -export const releaseBotAllowedFiles = new Set([ - 'CHANGELOG.md', - 'pnpm-lock.yaml', - '.agents/skills/rxjs-next-migration/.rxjs-migrate-skill.json', - ...releasePackages.map(({ directory }) => `${directory}/package.json`), - ...versionedFiles, -]); - -export function channelForVersion(version) { - return version.includes('-') ? 'next' : 'latest'; -} - -export function npmRegistryVersionUrl(packageName, version) { - const encodedName = encodeURIComponent(packageName).replace(/^%40/, '@'); - return `https://registry.npmjs.org/${encodedName}/${encodeURIComponent(version)}`; -} - -export function assertNpmWebUrl(value, label = stagedPackagesVariable) { - let url; - try { - url = new URL(value); - } catch { - throw new Error(`${label} must be an absolute URL.`); - } - if (url.origin !== npmWebOrigin || url.username || url.password) { - throw new Error(`${label} must use the exact ${npmWebOrigin}/ origin without credentials.`); - } - return url.href; -} diff --git a/scripts/release/release-config.test.mjs b/scripts/release/release-config.test.mjs deleted file mode 100644 index 4a3c054894..0000000000 --- a/scripts/release/release-config.test.mjs +++ /dev/null @@ -1,11 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { npmRegistryVersionUrl } from './release-config.mjs'; - -test('builds npm registry version URLs for scoped and unscoped packages', () => { - assert.equal(npmRegistryVersionUrl('rxjs', '9.0.0-beta.0'), 'https://registry.npmjs.org/rxjs/9.0.0-beta.0'); - assert.equal( - npmRegistryVersionUrl('@rxjs/observable-polyfill', '9.0.0-beta.0'), - 'https://registry.npmjs.org/@rxjs%2Fobservable-polyfill/9.0.0-beta.0' - ); -}); diff --git a/scripts/release/release-doctor-policy.mjs b/scripts/release/release-doctor-policy.mjs deleted file mode 100644 index 06f33244e7..0000000000 --- a/scripts/release/release-doctor-policy.mjs +++ /dev/null @@ -1,176 +0,0 @@ -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 = []; - const jobsStart = lines.findIndex((line) => /^jobs:\s*(?:#.*)?$/.test(line)); - const jobsEnd = - jobsStart === -1 ? -1 : lines.findIndex((line, index) => index > jobsStart && /^(?!#)[A-Za-z_][A-Za-z0-9_-]*:\s*/.test(line)); - const scopedEnd = jobsEnd === -1 ? lines.length : jobsEnd; - - for (const [jobName, expectedRunner] of Object.entries(expectedRunners)) { - const jobPattern = new RegExp(`^ {2}${escapeRegExp(jobName)}:\\s*(?:#.*)?$`); - const jobStarts = []; - for (let index = jobsStart + 1; jobsStart !== -1 && index < scopedEnd; index += 1) { - if (jobPattern.test(lines[index])) jobStarts.push(index); - } - if (jobStarts.length === 0) { - errors.push(`${workflowName} is missing the ${jobName} job.`); - continue; - } - if (jobStarts.length > 1) { - errors.push(`${workflowName} defines the ${jobName} job more than once.`); - continue; - } - - const [jobStart] = jobStarts; - let actualRunner; - for (let index = jobStart + 1; index < scopedEnd; index += 1) { - const line = lines[index]; - if (/^ {2}(?!#)\S/.test(line)) break; - const runner = /^ {4}runs-on:\s*([^#]+?)(?:\s+#.*)?$/.exec(line)?.[1]?.trim(); - if (runner) { - actualRunner = runner; - break; - } - } - - if (actualRunner !== expectedRunner) { - errors.push(`${workflowName} ${jobName} job must run on ${expectedRunner}; found ${actualRunner ?? 'no runner'}.`); - } - } - - return errors; -} - -function escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} diff --git a/scripts/release/release-doctor-policy.test.mjs b/scripts/release/release-doctor-policy.test.mjs deleted file mode 100644 index 847a294b7e..0000000000 --- a/scripts/release/release-doctor-policy.test.mjs +++ /dev/null @@ -1,235 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -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: - runs-on: ubuntu-24.04 - browser: - runs-on: self-hosted - stage: - runs-on: ubuntu-24.04 -`; - - assert.deepEqual(requireWorkflowJobRunners(workflow, 'release-stage.yml', privilegedRunners), []); -}); - -test('rejects a privileged job on another runner even when a different job uses the expected runner', () => { - const workflow = `jobs: - authorize: - runs-on: self-hosted - browser: - runs-on: ubuntu-24.04 - stage: - runs-on: ubuntu-24.04 -`; - - assert.deepEqual(requireWorkflowJobRunners(workflow, 'release-stage.yml', privilegedRunners), [ - 'release-stage.yml authorize job must run on ubuntu-24.04; found self-hosted.', - ]); -}); - -test('does not accept a runner from a similarly named value outside the jobs mapping', () => { - const workflow = `env: - authorize: - runs-on: ubuntu-24.04 -jobs: - authorize: - runs-on: self-hosted - stage: - runs-on: ubuntu-24.04 -`; - - assert.deepEqual(requireWorkflowJobRunners(workflow, 'release-stage.yml', privilegedRunners), [ - 'release-stage.yml authorize job must run on ubuntu-24.04; found self-hosted.', - ]); -}); - -test('rejects duplicate privileged job definitions', () => { - const workflow = `jobs: - authorize: - runs-on: ubuntu-24.04 - authorize: - runs-on: ubuntu-24.04 - stage: - runs-on: ubuntu-24.04 -`; - - assert.deepEqual(requireWorkflowJobRunners(workflow, 'release-stage.yml', privilegedRunners), [ - 'release-stage.yml defines the authorize job more than once.', - ]); -}); - -test('rejects a missing privileged job or runner', () => { - const workflow = `jobs: - authorize: - name: Build -`; - - assert.deepEqual(requireWorkflowJobRunners(workflow, 'release-stage.yml', privilegedRunners), [ - 'release-stage.yml authorize job must run on ubuntu-24.04; found no runner.', - 'release-stage.yml is missing the stage job.', - ]); -}); diff --git a/scripts/release/release-doctor.mjs b/scripts/release/release-doctor.mjs deleted file mode 100644 index 3b5d06f5a7..0000000000 --- a/scripts/release/release-doctor.mjs +++ /dev/null @@ -1,320 +0,0 @@ -#!/usr/bin/env node - -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 { - 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'); -const errors = []; -if (releaseOperatorLogin !== 'benlesh') errors.push('The sole release operator must remain benlesh unless D-057 is explicitly reopened.'); -if ( - releaseToolchain.runner !== 'ubuntu-24.04' || - releaseToolchain.node !== '24.12.0' || - releaseToolchain.pnpm !== '10.34.5' || - releaseToolchain.npm !== '11.18.0' || - !/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(releaseToolchain.npmIntegrity) -) { - errors.push('The checked release toolchain or npm SHA-512 drifted.'); -} - -const workflowDirectory = path.join(root, '.github/workflows'); -const workflowFiles = (await readdir(workflowDirectory)).filter((file) => /\.ya?ml$/.test(file)); -const actionFiles = [...workflowFiles.map((file) => `.github/workflows/${file}`), '.github/actions/install-dependencies/action.yml']; -for (const relativePath of actionFiles) { - const source = await readFile(path.join(root, relativePath), 'utf8'); - for (const match of source.matchAll(/^\s*-?\s*uses:\s*([^\s#]+)(?:\s*#.*)?$/gm)) { - const reference = match[1]; - if (reference.startsWith('./')) continue; - if (!/@[0-9a-f]{40}$/.test(reference)) errors.push(`${relativePath} does not pin ${reference} to a full commit SHA.`); - } -} - -for (const relativePath of [...workflowFiles.map((file) => `.github/workflows/${file}`), 'package.json']) { - const source = await readFile(path.join(root, relativePath), 'utf8'); - if (/NPM_TOKEN|NODE_AUTH_TOKEN/.test(source)) errors.push(`${relativePath} contains a long-lived npm publishing token reference.`); - if (/(^|\s)npm\s+publish(?:\s|$)/m.test(source)) errors.push(`${relativePath} can call direct npm publish.`); - if (source.includes('pull_request_target')) errors.push(`${relativePath} uses pull_request_target.`); -} - -const stageWorkflow = await readFile(path.join(root, '.github/workflows/release-stage.yml'), 'utf8').catch(() => ''); -const qualificationWorkflow = await readFile(path.join(root, '.github/workflows/release-qualify.yml'), 'utf8').catch(() => ''); -for (const requirement of [ - 'id-token: write', - 'workflow_dispatch:', - 'qualification_run_id:', - 'manifest_sha512:', - "github.actor == 'benlesh'", - 'Verify typed manual authorization without npm authority', - 'needs: authorize', - 'authorize-stage.mjs', - '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', - stage: 'ubuntu-24.04', - }) -); -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@', - 'release-candidate.mjs manifest-digest', -]) { - if (!qualificationWorkflow.includes(requirement)) errors.push(`release-qualify.yml is missing ${requirement}.`); -} -errors.push( - ...requireWorkflowJobRunners(qualificationWorkflow, 'release-qualify.yml', { - build: 'ubuntu-24.04', - compare: 'ubuntu-24.04', - package: 'ubuntu-24.04', - node: 'ubuntu-24.04', - browser: 'ubuntu-24.04', - 'alternate-runtime': 'ubuntu-24.04', - safari: 'macos-15', - wpt: 'ubuntu-24.04', - evidence: 'ubuntu-24.04', - }) -); -if (!stageWorkflow.includes('install-pinned-npm.mjs')) { - errors.push('release-stage.yml must install the checked npm CLI through install-pinned-npm.mjs.'); -} -if (!stageWorkflow.includes("node-version: '24.12.0'")) { - errors.push('release-stage.yml must use exact Node 24.12.0.'); -} -if ( - `${stageWorkflow}\n${qualificationWorkflow}`.includes('actions/cache') || - `${stageWorkflow}\n${qualificationWorkflow}`.includes('cache: pnpm') -) { - errors.push('The privileged release workflow must not restore dependency or build caches.'); -} -if (stageWorkflow.includes('registry-url:')) { - errors.push('The staging workflow must not generate token-style npm registry authentication; trusted publishing uses OIDC only.'); -} -const stageScript = await readFile(path.join(root, 'scripts/release/stage-release.mjs'), 'utf8').catch(() => ''); -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' })); -if (!releasePullRequestWorkflow.includes("node-version: '24.12.0'")) { - errors.push('release-pr.yml must use exact Node 24.12.0.'); -} -for (const trustCheck of [ - "github.event.workflow_run.event == 'push'", - "github.event.workflow_run.head_branch == 'master'", - 'github.event.workflow_run.head_repository.full_name == github.repository', -]) { - if (!releasePullRequestWorkflow.includes(trustCheck)) { - errors.push(`release-pr.yml is missing the trusted workflow-run check: ${trustCheck}.`); - } -} - -const names = []; -const versions = new Set(); -for (const { directory, name } of releasePackages) { - const manifest = JSON.parse(await readFile(path.join(root, directory, 'package.json'), 'utf8')); - names.push(manifest.name); - versions.add(manifest.version); - if (manifest.name !== name) errors.push(`${directory}/package.json must identify ${name}.`); - if (manifest.repository?.url !== 'https://github.com/ReactiveX/rxjs.git') { - errors.push(`${directory}/package.json must use the exact case-sensitive ReactiveX repository URL.`); - } -} -if (versions.size !== 1) errors.push('The four release packages do not have one synchronized version.'); - -const runbook = await readFile(path.join(root, 'docs/RELEASE_PROCESS.md'), 'utf8').catch(() => ''); -const normalizedRunbook = runbook.replaceAll('`', ''); -if (!/^# RxJS 9 secure release process\n\n## Basic steps\n/.test(runbook)) errors.push('The public runbook must begin with Basic steps.'); -if (runbook.indexOf('## Security considerations') < runbook.indexOf('## Basic steps')) { - errors.push('Security considerations must follow Basic steps.'); -} -for (const requiredText of [ - 'one maintainer', - 'zero approvals', - 'automatically creates or refreshes the release PR', - 'release-manifest.json SHA-512', - 'WebAuthn', -]) { - if (!normalizedRunbook.toLowerCase().includes(requiredText.toLowerCase())) errors.push(`The runbook is missing: ${requiredText}.`); -} -if ( - /@ReactiveX\/release-maintainers|add-reviewer|required code-owner|environment reviewer requirement/i.test( - `${runbook}\n${stageWorkflow}\n${qualificationWorkflow}` - ) -) { - errors.push('Release documentation or workflows still imply a second reviewer or release team.'); -} -for (const { name } of releasePackages) { - if (!runbook.includes(`\`${name}\``)) errors.push(`The public runbook does not identify ${name}.`); -} -const reviewed = /Last reviewed:\s*(\d{4}-\d{2}-\d{2})\./.exec(runbook)?.[1]; -const reviewedAt = reviewed ? Date.parse(`${reviewed}T00:00:00Z`) : Number.NaN; -if (!Number.isFinite(reviewedAt) || reviewedAt > Date.now() + 24 * 60 * 60_000 || Date.now() - reviewedAt > 366 * 24 * 60 * 60_000) { - errors.push('The public release runbook has not been reviewed within 366 days.'); -} - -if (strict || process.env[stagedPackagesVariable]) { - try { - const stagedUrl = assertNpmWebUrl(process.env[stagedPackagesVariable] ?? '', stagedPackagesVariable); - if (strict) { - const response = await fetch(stagedUrl, { method: 'HEAD', redirect: 'manual' }); - if (response.status === 404 || response.status >= 500) { - errors.push(`${stagedPackagesVariable} returned HTTP ${response.status}; manually verify and update the authenticated route.`); - } - } - } catch (error) { - errors.push(error.message); - } -} - -if (strict) { - 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(); -} - -if (errors.length > 0) throw new Error(`Release doctor found ${errors.length} problem(s):\n- ${errors.join('\n- ')}`); -process.stdout.write( - `Release doctor passed for ${names.join(', ')} at ${[...versions][0]}.` + - (strict ? ` ${stagedPackagesVariable} is configured on the required npm origin.` : '') + - '\n' -); - -async function validateTagRuleset() { - const repository = process.env.GITHUB_REPOSITORY; - const token = process.env.GH_TOKEN; - const rulesetId = process.env.RELEASE_TAG_RULESET_ID; - if (!repository || !token || !rulesetId) { - errors.push('GITHUB_REPOSITORY, GH_TOKEN, and RELEASE_TAG_RULESET_ID are required to audit tag protection.'); - return; - } - try { - const response = await fetch(`https://api.github.com/repos/${repository}/rulesets/${encodeURIComponent(rulesetId)}`, { - 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}.`); - const ruleset = await response.json(); - 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) { - errors.push('RELEASE_BRANCH_RULESET_ID is required to audit single-maintainer master protection.'); - return; - } - try { - const ruleset = await readRuleset(rulesetId); - errors.push(...auditBranchRuleset(ruleset).map((error) => `Ruleset ${rulesetId}: ${error}`)); - } catch (error) { - errors.push(`Could not audit release branch ruleset ${rulesetId}: ${error.message}`); - } -} - -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 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', - }, - }); -} diff --git a/scripts/release/release-policy.mjs b/scripts/release/release-policy.mjs deleted file mode 100644 index 1e66bd5beb..0000000000 --- a/scripts/release/release-policy.mjs +++ /dev/null @@ -1,114 +0,0 @@ -import { firstReleaseVersion } from './release-config.mjs'; - -const conventionalTitle = - /^(?feat|fix|perf|revert|docs|chore|refactor|test|build|ci|style)(?:\([^)\r\n]+\))?(?!)?: (?\S.*)$/; - -export function parseVersion(version) { - const match = /^(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?0|[1-9]\d*)(?:-beta\.(?0|[1-9]\d*))?$/.exec(version); - if (!match?.groups) throw new Error(`Unsupported RxJS 9 version: ${version}`); - return { - major: Number(match.groups.major), - minor: Number(match.groups.minor), - patch: Number(match.groups.patch), - beta: match.groups.beta === undefined ? null : Number(match.groups.beta), - }; -} - -export function classifyConventionalCommit(subject, body = '') { - const match = conventionalTitle.exec(subject); - if (!match?.groups) return { level: 'invalid', subject, reason: 'title is not a supported Conventional Commit' }; - const breaking = match.groups.breaking === '!' || hasPopulatedBreakingFooter(body); - const level = breaking - ? 'breaking' - : match.groups.type === 'feat' - ? 'feature' - : ['fix', 'perf', 'revert'].includes(match.groups.type) - ? 'fix' - : 'none'; - return { description: match.groups.description, level, subject, type: match.groups.type }; -} - -function hasPopulatedBreakingFooter(body) { - const footerPattern = /(?:^|\n)(?:\*\*)?BREAKING CHANGES?:(?:\*\*)?[^\S\r\n]*([^\r\n]*(?:\r?\n[ \t]+[^\r\n]*)*)/gi; - for (const match of body.matchAll(footerPattern)) { - if (hasTextOutsideHtmlComments(match[1])) return true; - } - return false; -} - -function hasTextOutsideHtmlComments(value) { - let index = 0; - while (index < value.length) { - if (value.startsWith('', index + 4); - if (commentEnd === -1) return false; - index = commentEnd + 3; - } else if (/\S/.test(value[index])) { - return true; - } else { - index += 1; - } - } - return false; -} - -export function selectRelease({ currentTag, manifestVersion = firstReleaseVersion, commits, mode = 'auto' }) { - const classified = commits.map((commit) => ({ ...commit, classification: classifyConventionalCommit(commit.subject, commit.body) })); - const invalid = classified.filter(({ classification }) => classification.level === 'invalid'); - if (invalid.length > 0) { - return { status: 'blocked', reason: `Invalid Conventional Commit title: ${invalid[0].subject}`, commits: classified }; - } - - const releasable = classified.filter(({ classification }) => classification.level !== 'none'); - if (mode === 'promote-stable') { - const current = parseVersion(currentTag ?? manifestVersion); - if (current.beta === null) return { status: 'blocked', reason: 'Stable promotion is available only from a beta.', commits: classified }; - return releaseResult(`${current.major}.${current.minor}.${current.patch}`, 'latest', 'explicit stable promotion', classified); - } - if (mode !== 'auto') return { status: 'blocked', reason: `Unknown release mode: ${mode}`, commits: classified }; - if (releasable.length === 0) return { status: 'none', reason: 'No release-relevant commits accumulated.', commits: classified }; - - if (!currentTag) { - return releaseResult(manifestVersion, 'next', 'first RxJS 9 beta', classified); - } - - const current = parseVersion(currentTag); - if (current.major !== 9) - return { status: 'blocked', reason: `The latest release tag is not an RxJS 9 version: ${currentTag}`, commits: classified }; - if (current.beta !== null) { - return releaseResult( - `${current.major}.${current.minor}.${current.patch}-beta.${current.beta + 1}`, - 'next', - 'beta counter increment', - classified - ); - } - - const levels = new Set(releasable.map(({ classification }) => classification.level)); - if (levels.has('breaking')) { - return { - status: 'blocked', - reason: 'A breaking change after stable RxJS 9 requires 10.0.0; the 9.x release train is blocked.', - commits: classified, - }; - } - if (levels.has('feature')) { - return releaseResult(`${current.major}.${current.minor + 1}.0`, 'latest', 'highest accumulated change is a feature', classified); - } - return releaseResult( - `${current.major}.${current.minor}.${current.patch + 1}`, - 'latest', - 'highest accumulated change is a fix', - classified - ); -} - -function releaseResult(version, channel, reason, commits) { - return { channel, commits, reason, status: 'planned', version }; -} - -export function validatePullRequestTitle(title) { - const classification = classifyConventionalCommit(title); - if (classification.level === 'invalid') throw new Error(classification.reason); - return classification; -} diff --git a/scripts/release/release-policy.test.mjs b/scripts/release/release-policy.test.mjs deleted file mode 100644 index 068b5d7e47..0000000000 --- a/scripts/release/release-policy.test.mjs +++ /dev/null @@ -1,106 +0,0 @@ -import assert from 'node:assert/strict'; -import fc from 'fast-check'; -import test from 'node:test'; -import { classifyConventionalCommit, selectRelease, validatePullRequestTitle } from './release-policy.mjs'; - -const commit = (subject, body = '') => ({ body, sha: subject.padEnd(40, '0').slice(0, 40), subject }); - -test('increments only the beta counter for fixes, features, and breaking changes during beta', () => { - for (const subject of ['fix(core): repair teardown', 'feat(map): add projection option', 'feat(api)!: remove legacy form']) { - const result = selectRelease({ currentTag: '9.0.0-beta.7', commits: [commit(subject)] }); - assert.equal(result.channel, 'next'); - assert.equal(result.reason, 'beta counter increment'); - assert.equal(result.status, 'planned'); - assert.equal(result.version, '9.0.0-beta.8'); - } -}); - -test('selects patch and minor releases by the highest accumulated stable change', () => { - assert.equal(selectRelease({ currentTag: '9.2.3', commits: [commit('fix(core): correct error')] }).version, '9.2.4'); - const result = selectRelease({ - currentTag: '9.2.3', - commits: [commit('fix(core): correct error'), commit('feat(test): add helper')], - }); - assert.equal(result.version, '9.3.0'); - assert.equal(result.channel, 'latest'); -}); - -test('blocks breaking stable changes and permits explicit stable promotion', () => { - assert.equal(selectRelease({ currentTag: '9.2.3', commits: [commit('feat(api)!: break shape')] }).status, 'blocked'); - assert.match(selectRelease({ currentTag: '9.2.3', commits: [commit('feat(api)!: break shape')] }).reason, /10\.0\.0/); - const promotion = selectRelease({ currentTag: '9.0.0-beta.9', commits: [commit('docs: clarify example')], mode: 'promote-stable' }); - assert.equal(promotion.channel, 'latest'); - assert.equal(promotion.reason, 'explicit stable promotion'); - assert.equal(promotion.status, 'planned'); - assert.equal(promotion.version, '9.0.0'); -}); - -test('does not release documentation or internal chores', () => { - assert.equal( - selectRelease({ currentTag: '9.1.0', commits: [commit('docs: explain release'), commit('chore: format files')] }).status, - 'none' - ); -}); - -test('uses beta.0 for the first release and validates Conventional Commit titles', () => { - assert.equal(selectRelease({ currentTag: null, commits: [commit('feat(core): initial beta')] }).version, '9.0.0-beta.0'); - assert.equal(classifyConventionalCommit('fix(core): correct teardown').level, 'fix'); - assert.throws(() => validatePullRequestTitle('Correct teardown'), /supported Conventional Commit/); -}); - -test('ignores the empty pull-request template breaking-change placeholder', () => { - assert.equal( - classifyConventionalCommit('fix(core): correct teardown', '**BREAKING CHANGE:** ').level, - 'fix' - ); - assert.equal( - classifyConventionalCommit('fix(core): correct teardown', 'BREAKING CHANGE: changes cancellation ownership').level, - 'breaking' - ); - assert.equal( - classifyConventionalCommit('fix(core): correct teardown', '**BREAKING CHANGE:** changes cancellation ownership').level, - 'breaking' - ); - assert.equal( - classifyConventionalCommit('fix(core): correct teardown', '**BREAKING CHANGE:** ').level, - 'fix' - ); - assert.equal( - classifyConventionalCommit('fix(core): correct teardown', '**BREAKING CHANGE:** changes cancellation ownership') - .level, - 'breaking' - ); -}); - -test('selects beta versions monotonically for arbitrary counters and releasable titles', () => { - fc.assert( - fc.property( - fc.nat({ max: 1_000_000 }), - fc.constantFrom('fix(core): repair lifecycle', 'feat(core): add operator', 'feat(core)!: change contract'), - (beta, subject) => { - const result = selectRelease({ currentTag: `9.0.0-beta.${beta}`, commits: [commit(subject)] }); - assert.equal(result.version, `9.0.0-beta.${beta + 1}`); - assert.equal(result.channel, 'next'); - } - ), - { numRuns: 200 } - ); -}); - -test('classifies an indented multi-line breaking-change footer as breaking', () => { - assert.equal( - classifyConventionalCommit('fix(core): correct teardown', 'BREAKING CHANGE:\n changes cancellation ownership').level, - 'breaking' - ); - assert.equal( - classifyConventionalCommit('fix(core): correct teardown', '**BREAKING CHANGES:**\n\tchanges cancellation ownership').level, - 'breaking' - ); - assert.equal( - classifyConventionalCommit( - 'fix(core): correct teardown', - '**BREAKING CHANGE:** \n\nBREAKING CHANGE:\n changes cancellation ownership' - ).level, - 'breaking' - ); -}); diff --git a/scripts/release/stage-release.mjs b/scripts/release/stage-release.mjs deleted file mode 100644 index d40c84a7a0..0000000000 --- a/scripts/release/stage-release.mjs +++ /dev/null @@ -1,166 +0,0 @@ -#!/usr/bin/env node - -import { spawnSync } from 'node:child_process'; -import { createHash } from 'node:crypto'; -import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import { assertNpmWebUrl, releasePackages, stagedPackagesVariable } from './release-config.mjs'; -import { verifyCandidate } from './release-candidate.mjs'; - -const root = fileURLToPath(new URL('../..', import.meta.url)); -if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { - const [command, directory = '.release/candidate', outputFile = '.release/staged-release.json'] = process.argv.slice(2); - const candidateRoot = path.resolve(root, directory); - const outputPath = path.resolve(root, outputFile); - if (command === 'publish') await stageCandidate(candidateRoot, outputPath); - else if (command === 'comment') process.stdout.write(await renderStagingComment(outputPath, process.env[stagedPackagesVariable] ?? '')); - else throw new Error('Usage: stage-release.mjs [candidate-directory] [staged-result-file]'); -} - -async function stageCandidate(candidateRoot, outputPath) { - const manifest = await verifyCandidate(candidateRoot, { - expectedSourceCommit: process.env.RELEASE_EXPECTED_SOURCE_COMMIT, - }); - const state = { - schemaVersion: 1, - version: manifest.version, - channel: manifest.channel, - sourceCommit: manifest.sourceCommit, - status: 'staging', - packages: [], - }; - await persist(); - try { - for (const expected of releasePackages) { - const entry = manifest.packages.find(({ name }) => name === expected.name); - const result = spawnSync('npm', ['stage', 'publish', path.join(candidateRoot, entry.filename), '--tag', manifest.channel, '--json'], { - cwd: root, - encoding: 'utf8', - env: { ...process.env, NPM_CONFIG_PROVENANCE: 'true' }, - }); - if (result.status !== 0) throw new Error(`Staging ${entry.name} failed (${result.status}).\n${result.stdout}${result.stderr}`); - const parsed = parseStageOutput(result.stdout); - const stagedEntry = { - name: entry.name, - version: entry.version, - distTag: manifest.channel, - stageId: parsed.stageId, - ...(parsed.url ? { url: assertNpmWebUrl(parsed.url, `${entry.name} staged-package URL`) } : {}), - sha512: entry.sha512, - integrity: entry.integrity, - stagedDigestVerified: false, - }; - state.packages.push(stagedEntry); - await persist(); - stagedEntry.stagedSha512 = await downloadAndVerifyStage(parsed.stageId, entry); - stagedEntry.stagedDigestVerified = true; - await persist(); - } - state.status = 'staged'; - await persist(); - } catch (error) { - state.status = 'partial'; - state.error = error.message; - await persist(); - throw error; - } - - async function persist() { - await writeFile(outputPath, `${JSON.stringify(state, null, 2)}\n`); - } -} - -async function downloadAndVerifyStage(stageId, entry) { - const downloadRoot = await mkdtemp(path.join(tmpdir(), 'rxjs-npm-stage-download-')); - try { - const result = spawnSync('npm', ['stage', 'download', stageId], { - cwd: downloadRoot, - encoding: 'utf8', - env: process.env, - }); - if (result.status !== 0) { - throw new Error(`Downloading npm stage ${stageId} failed (${result.status}).\n${result.stdout}${result.stderr}`); - } - return await verifyDownloadedStage(downloadRoot, entry); - } finally { - await rm(downloadRoot, { recursive: true, force: true }); - } -} - -export async function verifyDownloadedStage(downloadRoot, entry) { - const files = (await readdir(downloadRoot)).filter((file) => file.endsWith('.tgz')); - if (files.length !== 1) throw new Error(`npm stage download produced ${files.length} tarballs; expected exactly one.`); - const bytes = await readFile(path.join(downloadRoot, files[0])); - const stagedSha512 = createHash('sha512').update(bytes).digest('hex'); - if (bytes.byteLength !== entry.size || stagedSha512 !== entry.sha512) { - throw new Error(`${entry.name} npm-staged bytes do not match the qualified tarball. Reject every stage in this candidate.`); - } - return stagedSha512; -} - -export function parseStageOutput(stdout) { - let parsed; - try { - parsed = JSON.parse(stdout); - } catch { - parsed = null; - } - const values = parsed ? flatten(parsed) : []; - const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - const stageId = - values.find(({ key, value }) => /^stage-?id$/i.test(key) && typeof value === 'string' && uuid.test(value))?.value ?? - values.find(({ key, value }) => /^id$/i.test(key) && typeof value === 'string' && uuid.test(value))?.value ?? - stdout.match(/\b(?:stage(?:\s+|[-_])?id)\s*[:=]\s*["']?([0-9a-f-]{36})/i)?.[1]; - if (!stageId || !uuid.test(stageId)) { - throw new Error(`npm did not return a supported stage ID. Preserve this output and inspect npm staging:\n${stdout}`); - } - const returnedUrl = values.find( - ({ key, value }) => /url|href|link/i.test(key) && typeof value === 'string' && value.startsWith('http') - )?.value; - let url; - if (returnedUrl) { - try { - url = assertNpmWebUrl(returnedUrl, 'npm returned stage URL'); - } catch { - // Registry/API links are not rendered. Stage IDs remain the supported fallback. - } - } - return { stageId, ...(url ? { url } : {}) }; -} - -function flatten(value, key = '') { - if (Array.isArray(value)) return value.flatMap((item) => flatten(item, key)); - if (value && typeof value === 'object') return Object.entries(value).flatMap(([childKey, child]) => flatten(child, childKey)); - return [{ key, value }]; -} - -export async function renderStagingComment(stagedResultPath, configuredUrl) { - const state = JSON.parse(await readFile(stagedResultPath, 'utf8')); - const stagedPackagesUrl = assertNpmWebUrl(configuredUrl, stagedPackagesVariable); - const complete = - state.status === 'staged' && - state.packages.length === releasePackages.length && - state.packages.every((entry, index) => entry.name === releasePackages[index].name && entry.stagedDigestVerified === true); - const rows = state.packages - .map( - (entry, index) => - `| ${index + 1} | \`${entry.name}\` | \`${entry.version}\` | \`${entry.distTag}\` | \`${entry.stageId}\` | \`${entry.sha512}\` | ${ - entry.stagedDigestVerified ? 'verified' : '**not verified**' - } |${entry.url ? ` [Open stage](${assertNpmWebUrl(entry.url, `${entry.name} stage URL`)}) |` : ' — |'}` - ) - .join('\n'); - const command = complete ? 'approve' : 'reject'; - const commands = state.packages.map(({ name, stageId }) => `# ${name}\nnpm stage ${command} ${stageId}`).join('\n\n'); - return ( - `# ${complete ? 'npm approval required' : 'DO NOT APPROVE — reject partial staging'} for ${state.version}\n\n` + - (complete - ? `> [!CAUTION]\n> RxJS cannot be unpublished. Verify the package, version, channel, stage ID, and SHA-512 below before approving. Approve \`rxjs\` last.\n\n` - : `> [!WARNING]\n> Staging or staged-digest verification did not complete. Approve nothing. Open npm and reject every stage for this candidate with TFA, including any stage missing from this receipt, then create a fresh candidate and version.\n\n`) + - `[**Open npm Staged Packages**](${stagedPackagesUrl})\n\n` + - `| Order | Package | Version | Dist-tag | Stage ID | Qualified and staged SHA-512 | Staged download | Direct stage |\n| ---: | --- | --- | --- | --- | --- | --- | --- |\n${rows}\n\n` + - `## CLI fallback\n\n\`\`\`sh\n${commands}\n\`\`\`\n\n` + - `Every command requires npm TFA. If any value differs from this comment, reject the stages and create a fresh candidate.\n` - ); -} diff --git a/scripts/release/stage-release.test.mjs b/scripts/release/stage-release.test.mjs deleted file mode 100644 index 21446c61df..0000000000 --- a/scripts/release/stage-release.test.mjs +++ /dev/null @@ -1,107 +0,0 @@ -import assert from 'node:assert/strict'; -import { createHash } from 'node:crypto'; -import fc from 'fast-check'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import test from 'node:test'; -import { parseStageOutput, renderStagingComment, verifyDownloadedStage } from './stage-release.mjs'; -import { releasePackages } from './release-config.mjs'; -import { assertNpmWebUrl } from './release-config.mjs'; - -test('extracts supported stage IDs and validates returned npm links', () => { - const stageId = '123e4567-e89b-42d3-a456-426614174000'; - assert.deepEqual(parseStageOutput(`{"id":"rxjs@9.0.0","stageId":"${stageId}","url":"https://www.npmjs.com/example"}`), { - stageId, - url: 'https://www.npmjs.com/example', - }); - assert.deepEqual(parseStageOutput(`{"stageId":"${stageId}","url":"https://registry.npmjs.org/internal"}`), { - stageId, - }); - assert.throws(() => parseStageOutput('{"ok":true}'), /supported stage ID/); -}); - -test('parses arbitrary supported UUID stage output and never renders a foreign npm origin', () => { - fc.assert( - fc.property(fc.uuid(), fc.string({ maxLength: 100 }), (stageId, suffix) => { - const parsed = parseStageOutput(JSON.stringify({ stageId, url: `https://evil.example/${encodeURIComponent(suffix)}` })); - assert.equal(parsed.stageId, stageId); - assert.equal(parsed.url, undefined); - assert.throws(() => assertNpmWebUrl(`https://npmjs.com/${encodeURIComponent(suffix)}`), /exact https:\/\/www\.npmjs\.com/); - }), - { numRuns: 200 } - ); -}); - -test('requires the downloaded npm stage to match the qualified bytes', async () => { - const root = await mkdtemp(path.join(tmpdir(), 'rxjs-stage-download-')); - try { - const bytes = Buffer.from('exact staged tarball'); - const sha512 = createHash('sha512').update(bytes).digest('hex'); - await writeFile(path.join(root, 'download.tgz'), bytes); - assert.equal(await verifyDownloadedStage(root, { name: 'rxjs', size: bytes.byteLength, sha512 }), sha512); - await writeFile(path.join(root, 'download.tgz'), 'changed'); - await assert.rejects(() => verifyDownloadedStage(root, { name: 'rxjs', size: bytes.byteLength, sha512 }), /do not match/); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); - -test('renders the exact approval order, hashes, links, and CLI fallbacks', async () => { - const root = await mkdtemp(path.join(tmpdir(), 'rxjs-stage-comment-')); - try { - const file = path.join(root, 'state.json'); - await writeFile( - file, - JSON.stringify({ - status: 'staged', - version: '9.0.0-beta.3', - packages: releasePackages.map(({ name }, index) => ({ - name, - version: '9.0.0-beta.3', - distTag: 'next', - stageId: `stage_${index}`, - sha512: `digest_${index}`, - stagedDigestVerified: true, - })), - }) - ); - const comment = await renderStagingComment(file, 'https://www.npmjs.com/settings/example/packages'); - assert.ok(comment.indexOf('`@rxjs/observable-polyfill`') < comment.indexOf('| `rxjs` |')); - assert.match(comment, /npm stage approve stage_3/); - assert.match(comment, /Approve `rxjs` last/); - assert.match(comment, /Qualified and staged SHA-512/); - await assert.rejects(() => renderStagingComment(file, 'https://npmjs.com/not-exact'), /exact https:\/\/www\.npmjs\.com/); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); - -test('turns a partial staging receipt into rejection instructions', async () => { - const root = await mkdtemp(path.join(tmpdir(), 'rxjs-stage-reject-')); - try { - const file = path.join(root, 'state.json'); - await writeFile( - file, - JSON.stringify({ - status: 'partial', - version: '9.0.0-beta.3', - packages: [ - { - name: '@rxjs/observable-polyfill', - version: '9.0.0-beta.3', - distTag: 'next', - stageId: 'stage_polyfill', - sha512: 'abc', - }, - ], - }) - ); - const comment = await renderStagingComment(file, 'https://www.npmjs.com/settings/example/packages'); - assert.match(comment, /DO NOT APPROVE/); - assert.match(comment, /npm stage reject stage_polyfill/); - assert.doesNotMatch(comment, /npm stage approve/); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); diff --git a/scripts/release/validate-commit-message.mjs b/scripts/release/validate-commit-message.mjs index e04980fed6..981403c72f 100644 --- a/scripts/release/validate-commit-message.mjs +++ b/scripts/release/validate-commit-message.mjs @@ -1,10 +1,10 @@ #!/usr/bin/env node import { readFile } from 'node:fs/promises'; -import { validatePullRequestTitle } from './release-policy.mjs'; +import { validateConventionalTitle } from './conventional-commit.mjs'; const messagePath = process.argv[2]; if (!messagePath) throw new Error('Usage: validate-commit-message.mjs '); const [title] = (await readFile(messagePath, 'utf8')).split(/\r?\n/); -const result = validatePullRequestTitle(title.trim()); -process.stdout.write(`Validated ${result.type} Conventional Commit message (${result.level}).\n`); +const result = validateConventionalTitle(title.trim()); +process.stdout.write(`Validated ${result.type} Conventional Commit message.\n`); diff --git a/scripts/release/validate-pr-title.mjs b/scripts/release/validate-pr-title.mjs index 1d723df43b..04e9a9c6ff 100644 --- a/scripts/release/validate-pr-title.mjs +++ b/scripts/release/validate-pr-title.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node -import { validatePullRequestTitle } from './release-policy.mjs'; +import { validateConventionalTitle } from './conventional-commit.mjs'; const title = process.argv.slice(2).join(' ').trim(); if (!title) throw new Error('Usage: validate-pr-title.mjs '); -const result = validatePullRequestTitle(title); -process.stdout.write(`Validated ${result.type} Conventional Commit title (${result.level}).\n`); +const result = validateConventionalTitle(title); +process.stdout.write(`Validated ${result.type} Conventional Commit title.\n`); diff --git a/scripts/release/verify-npm-dry-runs.mjs b/scripts/release/verify-npm-dry-runs.mjs deleted file mode 100644 index 13a4257617..0000000000 --- a/scripts/release/verify-npm-dry-runs.mjs +++ /dev/null @@ -1,88 +0,0 @@ -#!/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 deleted file mode 100644 index 418a2be7cd..0000000000 --- a/scripts/release/verify-npm-dry-runs.test.mjs +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index bded31f9cd..0000000000 --- a/scripts/release/wait-for-required-checks.mjs +++ /dev/null @@ -1,34 +0,0 @@ -#!/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 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) { - const response = await fetch(`https://api.github.com/repos/${repository}/commits/${commit}/check-runs?per_page=100`, { - headers: { accept: 'application/vnd.github+json', authorization: `Bearer ${token}`, 'x-github-api-version': '2022-11-28' }, - }); - if (!response.ok) throw new Error(`GitHub check-runs request failed: ${response.status} ${await response.text()}`); - const payload = await response.json(); - const byName = new Map(payload.check_runs.map((check) => [check.name, check])); - const failures = required.filter( - (name) => byName.has(name) && byName.get(name).status === 'completed' && byName.get(name).conclusion !== 'success' - ); - if (failures.length > 0) throw new Error(`Required master checks failed: ${failures.join(', ')}`); - const pending = required.filter((name) => byName.get(name)?.conclusion !== 'success'); - if (pending.length === 0) { - process.stdout.write(`All required checks passed for ${commit}: ${required.join(', ')}\n`); - break; - } - if (Date.now() >= deadline) throw new Error(`Timed out waiting for required master checks: ${pending.join(', ')}`); - process.stdout.write(`Waiting for required master checks: ${pending.join(', ')}\n`); - await new Promise((resolve) => setTimeout(resolve, 15_000)); -}