diff --git a/.github/workflows/runtime_build_and_test.yml b/.github/workflows/runtime_build_and_test.yml index 09630c90a918..ef106e743154 100644 --- a/.github/workflows/runtime_build_and_test.yml +++ b/.github/workflows/runtime_build_and_test.yml @@ -10,12 +10,11 @@ on: pull_request: paths-ignore: - compiler/** + # Manual re-runs are allowed. The workflow always runs on the SHA it was + # dispatched from (github.sha); no user-supplied ref is accepted, because + # downstream workflows (e.g. runtime_release_from_source) consume this + # workflow's artifacts under a protected environment with NPM_TOKEN. workflow_dispatch: - inputs: - commit_sha: - required: false - type: string - default: '' permissions: {} @@ -37,8 +36,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - with: - ref: ${{ github.event.inputs.commit_sha != '' && github.event.inputs.commit_sha || github.event.pull_request.head.sha || github.sha }} - name: Check cache hit uses: actions/cache/restore@v4 id: node_modules @@ -76,8 +73,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - with: - ref: ${{ github.event.inputs.commit_sha != '' && github.event.inputs.commit_sha || github.event.pull_request.head.sha || github.sha }} - name: Check cache hit uses: actions/cache/restore@v4 id: node_modules @@ -123,7 +118,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ github.event.inputs.commit_sha != '' && github.event.inputs.commit_sha || github.event.pull_request.head.sha || github.sha }} + ref: ${{ github.event.pull_request.head.sha || github.sha }} - uses: actions/github-script@v7 id: set-matrix with: @@ -142,7 +137,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ github.event.inputs.commit_sha != '' && github.event.inputs.commit_sha || github.event.pull_request.head.sha || github.sha }} + ref: ${{ github.event.pull_request.head.sha || github.sha }} - uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' @@ -170,7 +165,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ github.event.inputs.commit_sha != '' && github.event.inputs.commit_sha || github.event.pull_request.head.sha || github.sha }} + ref: ${{ github.event.pull_request.head.sha || github.sha }} - uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' @@ -200,7 +195,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ github.event.inputs.commit_sha != '' && github.event.inputs.commit_sha || github.event.pull_request.head.sha || github.sha }} + ref: ${{ github.event.pull_request.head.sha || github.sha }} - uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' @@ -256,7 +251,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ github.event.inputs.commit_sha != '' && github.event.inputs.commit_sha || github.event.pull_request.head.sha || github.sha }} + ref: ${{ github.event.pull_request.head.sha || github.sha }} - uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' @@ -326,7 +321,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ github.event.inputs.commit_sha != '' && github.event.inputs.commit_sha || github.event.pull_request.head.sha || github.sha }} + ref: ${{ github.event.pull_request.head.sha || github.sha }} - uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' @@ -416,7 +411,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ github.event.inputs.commit_sha != '' && github.event.inputs.commit_sha || github.event.pull_request.head.sha || github.sha }} + ref: ${{ github.event.pull_request.head.sha || github.sha }} - uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' @@ -465,7 +460,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ github.event.inputs.commit_sha != '' && github.event.inputs.commit_sha || github.event.pull_request.head.sha || github.sha }} + ref: ${{ github.event.pull_request.head.sha || github.sha }} - uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' @@ -505,7 +500,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ github.event.inputs.commit_sha != '' && github.event.inputs.commit_sha || github.event.pull_request.head.sha || github.sha }} + ref: ${{ github.event.pull_request.head.sha || github.sha }} - uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' @@ -531,7 +526,7 @@ jobs: merge-multiple: true - name: Display structure of build run: ls -R build - - run: echo ${{ github.event.inputs.commit_sha != '' && github.event.inputs.commit_sha || github.event.pull_request.head.sha || github.sha }} >> build/COMMIT_SHA + - run: echo ${{ github.event.pull_request.head.sha || github.sha }} >> build/COMMIT_SHA - name: Scrape warning messages run: | mkdir -p ./build/__test_utils__ @@ -568,7 +563,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ github.event.inputs.commit_sha != '' && github.event.inputs.commit_sha || github.event.pull_request.head.sha || github.sha }} + ref: ${{ github.event.pull_request.head.sha || github.sha }} - uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' @@ -606,7 +601,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ github.event.inputs.commit_sha != '' && github.event.inputs.commit_sha || github.event.pull_request.head.sha || github.sha }} + ref: ${{ github.event.pull_request.head.sha || github.sha }} - uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' @@ -641,7 +636,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ github.event.inputs.commit_sha != '' && github.event.inputs.commit_sha || github.event.pull_request.head.sha || github.sha }} + ref: ${{ github.event.pull_request.head.sha || github.sha }} - uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' @@ -682,7 +677,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ github.event.inputs.commit_sha != '' && github.event.inputs.commit_sha || github.event.pull_request.head.sha || github.sha }} + ref: ${{ github.event.pull_request.head.sha || github.sha }} - uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' @@ -756,7 +751,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ github.event.inputs.commit_sha != '' && github.event.inputs.commit_sha || github.event.pull_request.head.sha || github.sha }} + ref: ${{ github.event.pull_request.head.sha || github.sha }} - uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' @@ -816,7 +811,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ github.event.inputs.commit_sha != '' && github.event.inputs.commit_sha || github.event.pull_request.head.sha || github.sha }} + ref: ${{ github.event.pull_request.head.sha || github.sha }} - uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' @@ -874,7 +869,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ github.event.inputs.commit_sha != '' && github.event.inputs.commit_sha || github.event.pull_request.head.sha || github.sha }} + ref: ${{ github.event.pull_request.head.sha || github.sha }} - uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' @@ -923,7 +918,7 @@ jobs: node ./scripts/print-warnings/print-warnings.js > build/__test_utils__/ReactAllWarnings.js - name: Display structure of build for PR run: ls -R build - - run: echo ${{ github.event.inputs.commit_sha != '' && github.event.inputs.commit_sha || github.event.pull_request.head.sha || github.sha }} >> build/COMMIT_SHA + - run: echo ${{ github.event.pull_request.head.sha || github.sha }} >> build/COMMIT_SHA - run: node ./scripts/tasks/danger - name: Archive sizebot results uses: actions/upload-artifact@v4 diff --git a/.github/workflows/runtime_prereleases.yml b/.github/workflows/runtime_prereleases.yml deleted file mode 100644 index 6559b1449971..000000000000 --- a/.github/workflows/runtime_prereleases.yml +++ /dev/null @@ -1,110 +0,0 @@ -name: (Runtime) Publish Prereleases - -on: - workflow_call: - inputs: - commit_sha: - required: true - default: '' - type: string - release_channel: - required: true - type: string - dist_tag: - required: true - type: string - enableFailureNotification: - description: 'Whether to notify the team on Discord when the release fails. Useful if this workflow is called from an automation.' - required: false - type: boolean - only_packages: - description: Packages to publish (space separated) - type: string - skip_packages: - description: Packages to NOT publish (space separated) - type: string - dry: - required: true - description: Dry run instead of publish? - type: boolean - default: true - secrets: - DISCORD_WEBHOOK_URL: - description: 'Discord webhook URL to notify on failure. Only required if enableFailureNotification is true.' - required: false - GH_TOKEN: - required: true - NPM_TOKEN: - required: true - -permissions: {} - -env: - TZ: /usr/share/zoneinfo/America/Los_Angeles - # https://github.com/actions/cache/blob/main/tips-and-workarounds.md#cache-segment-restore-timeout - SEGMENT_DOWNLOAD_TIMEOUT_MINS: 1 - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - -jobs: - publish_prerelease: - name: Publish prelease (${{ inputs.release_channel }}) ${{ inputs.commit_sha }} @${{ inputs.dist_tag }} - runs-on: ubuntu-latest - permissions: - # We use github.token to download the build artifact from a previous runtime_build_and_test.yml run - actions: read - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - cache: yarn - cache-dependency-path: yarn.lock - - name: Restore cached node_modules - uses: actions/cache@v4 - id: node_modules - with: - path: | - **/node_modules - key: runtime-release-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock', 'scripts/release/yarn.lock') }} - - name: Ensure clean build directory - run: rm -rf build - - run: yarn install --frozen-lockfile - if: steps.node_modules.outputs.cache-hit != 'true' - - run: yarn --cwd scripts/release install --frozen-lockfile - if: steps.node_modules.outputs.cache-hit != 'true' - - run: cp ./scripts/release/ci-npmrc ~/.npmrc - - run: | - GH_TOKEN=${{ secrets.GH_TOKEN }} scripts/release/prepare-release-from-ci.js --skipTests -r ${{ inputs.release_channel }} --commit=${{ inputs.commit_sha }} - - name: Check prepared files - run: ls -R build/node_modules - - if: '${{ inputs.only_packages }}' - name: 'Publish ${{ inputs.only_packages }}' - run: | - scripts/release/publish.js \ - --ci \ - --tags=${{ inputs.dist_tag }} \ - --onlyPackages=${{ inputs.only_packages }} ${{ (inputs.dry && '') || '\'}} - ${{ inputs.dry && '--dry' || '' }} - - if: '${{ inputs.skip_packages }}' - name: 'Publish all packages EXCEPT ${{ inputs.skip_packages }}' - run: | - scripts/release/publish.js \ - --ci \ - --tags=${{ inputs.dist_tag }} \ - --skipPackages=${{ inputs.skip_packages }} ${{ (inputs.dry && '') || '\'}} - ${{ inputs.dry && '--dry' || '' }} - - if: '${{ !inputs.skip_packages && !inputs.only_packages }}' - name: 'Publish all packages' - run: | - scripts/release/publish.js \ - --ci \ - --tags=${{ inputs.dist_tag }} ${{ (inputs.dry && '') || '\'}} - ${{ inputs.dry && '--dry' || '' }} - - name: Notify Discord on failure - if: failure() && inputs.enableFailureNotification == true - uses: tsickert/discord-webhook@86dc739f3f165f16dadc5666051c367efa1692f4 - with: - webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }} - embed-author-name: "GitHub Actions" - embed-title: '[Runtime] Publish of ${{ inputs.release_channel }}@${{ inputs.dist_tag}} release failed' - embed-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }} diff --git a/.github/workflows/runtime_prereleases_manual.yml b/.github/workflows/runtime_prereleases_manual.yml deleted file mode 100644 index 407d931e9073..000000000000 --- a/.github/workflows/runtime_prereleases_manual.yml +++ /dev/null @@ -1,102 +0,0 @@ -name: (Runtime) Publish Prereleases Manual - -on: - workflow_dispatch: - inputs: - prerelease_commit_sha: - required: true - only_packages: - description: Packages to publish (space separated) - type: string - skip_packages: - description: Packages to NOT publish (space separated) - type: string - dry: - required: true - description: Dry run instead of publish? - type: boolean - default: true - experimental_only: - type: boolean - description: Only publish to the experimental tag - default: false - force_notify: - description: Force a Discord notification? - type: boolean - default: false - -permissions: {} - -env: - TZ: /usr/share/zoneinfo/America/Los_Angeles - -jobs: - notify: - if: ${{ inputs.force_notify || inputs.dry == false || inputs.dry == 'false' }} - runs-on: ubuntu-latest - steps: - - name: Discord Webhook Action - uses: tsickert/discord-webhook@86dc739f3f165f16dadc5666051c367efa1692f4 - with: - webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }} - embed-author-name: ${{ github.event.sender.login }} - embed-author-url: ${{ github.event.sender.html_url }} - embed-author-icon-url: ${{ github.event.sender.avatar_url }} - embed-title: "⚠️ Publishing ${{ inputs.experimental_only && 'EXPERIMENTAL' || 'CANARY & EXPERIMENTAL' }} release ${{ (inputs.dry && ' (dry run)') || '' }}" - embed-description: | - ```json - ${{ toJson(inputs) }} - ``` - embed-url: https://github.com/facebook/react/actions/runs/${{ github.run_id }} - - publish_prerelease_canary: - if: ${{ !inputs.experimental_only }} - name: Publish to Canary channel - uses: facebook/react/.github/workflows/runtime_prereleases.yml@main - permissions: - # We use github.token to download the build artifact from a previous runtime_build_and_test.yml run - actions: read - with: - commit_sha: ${{ inputs.prerelease_commit_sha }} - release_channel: stable - # The tags to use when publishing canaries. The main one we should - # always include is "canary" but we can use multiple (e.g. alpha, - # beta, rc). To declare multiple, use a comma-separated string, like - # this: - # dist_tag: "canary,alpha,beta,rc" - # - # TODO: We currently tag canaries with "next" in addition to "canary" - # because this used to be called the "next" channel and some - # downstream consumers might still expect that tag. We can remove this - # after some time has elapsed and the change has been communicated. - dist_tag: canary,next - only_packages: ${{ inputs.only_packages }} - skip_packages: ${{ inputs.skip_packages }} - dry: ${{ inputs.dry }} - secrets: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - publish_prerelease_experimental: - name: Publish to Experimental channel - uses: facebook/react/.github/workflows/runtime_prereleases.yml@main - permissions: - # We use github.token to download the build artifact from a previous runtime_build_and_test.yml run - actions: read - # NOTE: Intentionally running these jobs sequentially because npm - # will sometimes fail if you try to concurrently publish two - # different versions of the same package, even if they use different - # dist tags. - needs: publish_prerelease_canary - # Ensures the job runs even if canary is skipped - if: always() - with: - commit_sha: ${{ inputs.prerelease_commit_sha }} - release_channel: experimental - dist_tag: experimental - only_packages: ${{ inputs.only_packages }} - skip_packages: ${{ inputs.skip_packages }} - dry: ${{ inputs.dry }} - secrets: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/runtime_prereleases_nightly.yml b/.github/workflows/runtime_prereleases_nightly.yml deleted file mode 100644 index f13a92e46f40..000000000000 --- a/.github/workflows/runtime_prereleases_nightly.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: (Runtime) Publish Prereleases Nightly - -on: - schedule: - # At 10 minutes past 16:00 on Mon, Tue, Wed, Thu, and Fri - - cron: 10 16 * * 1,2,3,4,5 - -permissions: {} - -env: - TZ: /usr/share/zoneinfo/America/Los_Angeles - -jobs: - publish_prerelease_canary: - name: Publish to Canary channel - uses: facebook/react/.github/workflows/runtime_prereleases.yml@main - permissions: - # We use github.token to download the build artifact from a previous runtime_build_and_test.yml run - actions: read - with: - commit_sha: ${{ github.sha }} - release_channel: stable - dist_tag: canary,next - enableFailureNotification: true - dry: false - secrets: - DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - publish_prerelease_experimental: - name: Publish to Experimental channel - uses: facebook/react/.github/workflows/runtime_prereleases.yml@main - permissions: - # We use github.token to download the build artifact from a previous runtime_build_and_test.yml run - actions: read - # NOTE: Intentionally running these jobs sequentially because npm - # will sometimes fail if you try to concurrently publish two - # different versions of the same package, even if they use different - # dist tags. - needs: publish_prerelease_canary - with: - commit_sha: ${{ github.sha }} - release_channel: experimental - dist_tag: experimental - enableFailureNotification: true - dry: false - secrets: - DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/runtime_release_from_ci.yml b/.github/workflows/runtime_release_from_ci.yml new file mode 100644 index 000000000000..4ba50d2b5768 --- /dev/null +++ b/.github/workflows/runtime_release_from_ci.yml @@ -0,0 +1,219 @@ +name: (Runtime) Release From CI + +on: + workflow_dispatch: + inputs: + type: + required: true + description: Type of release to publish + type: choice + # The `─── ... ───` entries are visual separators in the dropdown. + # They're rejected by the `resolve` job below, so picking one fails + # the workflow up front instead of silently doing the wrong release. + # GitHub Actions requires choice values to be unique, so the strings + # differ by the number of dashes. + options: + # publishes canary + experimental. + - nightly + - "────────────────" + - "─────────────────" + # semver stable published with @latest. + - stable-latest + - "──────────────────" + - "───────────────────" + # semver stable published with @backport (used for patches to + # older release lines that shouldn't move @latest). + - stable-backport + - "────────────────────" + - "─────────────────────" + # only experimental is published. + - experimental_only + only_packages: + description: Packages to publish (space separated allow-list; empty means all) + type: string + dry: + description: Dry run + type: boolean + default: false + force_notify: + description: Force a Discord notification? + type: boolean + default: false + schedule: + # At 10 minutes past 16:00 on Mon, Tue, Wed, Thu, and Fri. + # Scheduled runs always publish a nightly (see `resolve` job). + - cron: 10 16 * * 1,2,3,4,5 + +permissions: {} + +env: + TZ: /usr/share/zoneinfo/America/Los_Angeles + # https://github.com/actions/cache/blob/main/tips-and-workarounds.md#cache-segment-restore-timeout + SEGMENT_DOWNLOAD_TIMEOUT_MINS: 1 + +jobs: + resolve: + name: Resolve release inputs + runs-on: ubuntu-latest + outputs: + release_type: ${{ steps.resolve.outputs.release_type }} + steps: + - name: Resolve release inputs + id: resolve + run: | + # Scheduled runs always publish a nightly. Manual dispatches always + # supply `inputs.type`. Anything else is unsupported and fails fast. + if [ "${{ github.event_name }}" = "schedule" ]; then + release_type=nightly + elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + release_type="${{ inputs.type }}" + else + echo "Unsupported event: ${{ github.event_name }}" >&2 + exit 1 + fi + # Reject the dropdown's separator entries (and anything else that + # isn't one of the four real release types). Without this, picking + # a separator would fall through to all of publish.js's `if:` gates + # being false and the job would succeed without doing anything. + case "$release_type" in + nightly|stable-latest|stable-backport|experimental_only) ;; + *) + echo "Invalid release type: '$release_type' — pick one of nightly, stable-latest, stable-backport, experimental_only." >&2 + exit 1 + ;; + esac + echo "release_type=$release_type" >> "$GITHUB_OUTPUT" + + notify_starting: + name: Notify Discord (release starting) + # Manual dispatches always notify before the release starts so the team + # has a heads-up that a release is incoming. Scheduled (nightly) runs + # don't notify up front; we only notify on failure (see `notify` job). + if: ${{ github.event_name == 'workflow_dispatch' && vars.DISABLE_DISCORD_NOTIFICATIONS != 'true' }} + runs-on: ubuntu-latest + steps: + - uses: tsickert/discord-webhook@86dc739f3f165f16dadc5666051c367efa1692f4 + with: + webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }} + embed-author-name: ${{ github.event.sender.login }} + embed-author-url: ${{ github.event.sender.html_url }} + embed-author-icon-url: ${{ github.event.sender.avatar_url }} + embed-title: "⚠️ Publishing ${{ inputs.type }} release from source" + embed-description: | + ```json + ${{ toJson(inputs) }} + ``` + embed-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + + publish: + name: Publish release + needs: resolve + if: ${{ !cancelled() && needs.resolve.result == 'success' }} + runs-on: ubuntu-latest + # Protected environment — requires reviewer approval before the publish + # job starts running. + environment: npm + permissions: + id-token: write + contents: read + # `actions: read` lets prepare-release-from-ci.js fetch the + # runtime_build_and_test workflow's artifacts for github.sha. + actions: read + env: + # Required by scripts/release/shared-commands/download-build-artifacts.js. + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + # Always check out the commit the workflow file itself was loaded from. + # Crucially, no user-supplied ref is accepted, since this job runs in a + # protected environment. + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + - uses: actions/setup-node@v4 + with: + # Using modern Node.js that ships with NPM supporting trusted publishing + node-version: "24" + # scripts/release is the only thing this job actually needs to run — + # publish.js and prepare-release-from-ci.js both live there and use its + # own node_modules. Cache is owned by this workflow (this is the only + # consumer), so on a miss we install and save. + - name: Restore cached scripts/release node_modules + uses: actions/cache@v4 + id: release_node_modules + with: + path: scripts/release/node_modules + key: release-scripts-node_modules-v1-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('scripts/release/yarn.lock') }} + - run: yarn --cwd scripts/release install --frozen-lockfile + if: steps.release_node_modules.outputs.cache-hit != 'true' + + # ----- stable (semver) — either @latest or @backport ----- + - name: Stage semver stable artifacts + if: ${{ startsWith(needs.resolve.outputs.release_type, 'stable-') }} + run: scripts/release/prepare-release-from-ci.js --skipTests -r latest --commit=${{ github.sha }} + - name: Publish semver stable to @latest + if: ${{ needs.resolve.outputs.release_type == 'stable-latest' }} + run: | + scripts/release/publish.js \ + --tag=latest \ + ${{ inputs.only_packages && format('--onlyPackages={0}', inputs.only_packages) || '' }} \ + ${{ inputs.dry && '--dry' || '' }} + # Backport releases stay on the `@backport` dist-tag — we don't move + # @latest, and (under OIDC) we can't add/remove dist-tags after publish + # anyway. The tag goes on at publish time and is left in place. + - name: Publish semver stable to @backport + if: ${{ needs.resolve.outputs.release_type == 'stable-backport' }} + run: | + scripts/release/publish.js \ + --tag=backport \ + ${{ inputs.only_packages && format('--onlyPackages={0}', inputs.only_packages) || '' }} \ + ${{ inputs.dry && '--dry' || '' }} + + # ----- nightly: canary first, then experimental ----- + # NOTE: Intentionally running sequentially because npm will sometimes + # fail if you try to concurrently publish two different versions of the + # same package, even if they use different dist tags. + - name: Stage canary artifacts + if: ${{ needs.resolve.outputs.release_type == 'nightly' }} + run: scripts/release/prepare-release-from-ci.js --skipTests -r stable --commit=${{ github.sha }} + # Single tag only — OIDC publish tokens can't add additional dist-tags + # after publish, so the historical `canary,next` aliasing is gone. + - name: Publish canary to @canary + if: ${{ needs.resolve.outputs.release_type == 'nightly' }} + run: | + scripts/release/publish.js \ + --tag=canary \ + ${{ inputs.only_packages && format('--onlyPackages={0}', inputs.only_packages) || '' }} \ + ${{ inputs.dry && '--dry' || '' }} + + # ----- experimental (nightly + experimental_only) ----- + - name: Stage experimental artifacts + if: ${{ needs.resolve.outputs.release_type == 'nightly' || needs.resolve.outputs.release_type == 'experimental_only' }} + run: scripts/release/prepare-release-from-ci.js --skipTests -r experimental --commit=${{ github.sha }} + - name: Publish experimental to @experimental + if: ${{ needs.resolve.outputs.release_type == 'nightly' || needs.resolve.outputs.release_type == 'experimental_only' }} + run: | + scripts/release/publish.js \ + --tag=experimental \ + ${{ inputs.only_packages && format('--onlyPackages={0}', inputs.only_packages) || '' }} \ + ${{ inputs.dry && '--dry' || '' }} + + notify: + name: Notify Discord on failure + needs: [resolve, publish] + # Runs for every workflow run (manual + scheduled) and only fires when + # something didn't complete successfully — i.e. an actual failure or a + # cancellation. Successful runs stay silent. + if: ${{ always() && (needs.resolve.result == 'failure' || needs.resolve.result == 'cancelled' || needs.publish.result == 'failure' || needs.publish.result == 'cancelled') && vars.DISABLE_DISCORD_NOTIFICATIONS != 'true' }} + runs-on: ubuntu-latest + steps: + - name: Discord Webhook Action + uses: tsickert/discord-webhook@86dc739f3f165f16dadc5666051c367efa1692f4 + with: + webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }} + embed-author-name: "GitHub Actions" + embed-title: "❌ [Runtime] Release from source failed (${{ needs.resolve.outputs.release_type || inputs.type || 'nightly' }})" + embed-description: | + resolve: `${{ needs.resolve.result }}` + publish: `${{ needs.publish.result }}` + event: `${{ github.event_name }}` + embed-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }} diff --git a/.github/workflows/runtime_releases_from_npm_manual.yml b/.github/workflows/runtime_releases_from_npm_manual.yml deleted file mode 100644 index f164e9f08066..000000000000 --- a/.github/workflows/runtime_releases_from_npm_manual.yml +++ /dev/null @@ -1,128 +0,0 @@ -name: (Runtime) Publish Releases from NPM Manual - -on: - workflow_dispatch: - inputs: - version_to_promote: - required: true - description: Current npm version (non-experimental) to promote - type: string - version_to_publish: - required: true - description: Version to publish for the specified packages - type: string - only_packages: - description: Packages to publish (space separated) - type: string - skip_packages: - description: Packages to NOT publish (space separated) - type: string - tags: - description: NPM tags (space separated) - type: string - default: untagged - dry: - required: true - description: Dry run instead of publish? - type: boolean - default: true - force_notify: - description: Force a Discord notification? - type: boolean - default: false - -permissions: {} - -env: - TZ: /usr/share/zoneinfo/America/Los_Angeles - # https://github.com/actions/cache/blob/main/tips-and-workarounds.md#cache-segment-restore-timeout - SEGMENT_DOWNLOAD_TIMEOUT_MINS: 1 - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - -jobs: - notify: - if: ${{ inputs.force_notify || inputs.dry == false || inputs.dry == 'false' }} - runs-on: ubuntu-latest - steps: - - name: Discord Webhook Action - uses: tsickert/discord-webhook@86dc739f3f165f16dadc5666051c367efa1692f4 - with: - webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }} - embed-author-name: ${{ github.event.sender.login }} - embed-author-url: ${{ github.event.sender.html_url }} - embed-author-icon-url: ${{ github.event.sender.avatar_url }} - embed-title: "⚠️ Publishing release from NPM${{ (inputs.dry && ' (dry run)') || '' }}" - embed-description: | - ```json - ${{ toJson(inputs) }} - ``` - embed-url: https://github.com/facebook/react/actions/runs/${{ github.run_id }} - - publish: - name: Publish releases - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - cache: yarn - cache-dependency-path: yarn.lock - - name: Restore cached node_modules - uses: actions/cache@v4 - id: node_modules - with: - path: | - **/node_modules - key: runtime-release-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock', 'scripts/release/yarn.lock') }} - - name: Ensure clean build directory - run: rm -rf build - - run: yarn install --frozen-lockfile - if: steps.node_modules.outputs.cache-hit != 'true' - - run: yarn --cwd scripts/release install --frozen-lockfile - if: steps.node_modules.outputs.cache-hit != 'true' - - run: cp ./scripts/release/ci-npmrc ~/.npmrc - - if: '${{ inputs.only_packages }}' - name: 'Prepare ${{ inputs.only_packages }} from NPM' - run: | - scripts/release/prepare-release-from-npm.js \ - --ci \ - --skipTests \ - --version=${{ inputs.version_to_promote }} \ - --publishVersion=${{ inputs.version_to_publish }} \ - --onlyPackages=${{ inputs.only_packages }} - - if: '${{ inputs.skip_packages }}' - name: 'Prepare all packages EXCEPT ${{ inputs.skip_packages }} from NPM' - run: | - scripts/release/prepare-release-from-npm.js \ - --ci \ - --skipTests \ - --version=${{ inputs.version_to_promote }} \ - --publishVersion=${{ inputs.version_to_publish }} \ - --skipPackages=${{ inputs.skip_packages }} - - name: Check prepared files - run: ls -R build/node_modules - - if: '${{ inputs.only_packages }}' - name: 'Publish ${{ inputs.only_packages }}' - run: | - scripts/release/publish.js \ - --ci \ - --tags=${{ inputs.tags }} \ - --publishVersion=${{ inputs.version_to_publish }} \ - --onlyPackages=${{ inputs.only_packages }} ${{ (inputs.dry && '') || '\'}} - ${{ inputs.dry && '--dry' || '' }} - - if: '${{ inputs.skip_packages }}' - name: 'Publish all packages EXCEPT ${{ inputs.skip_packages }}' - run: | - scripts/release/publish.js \ - --ci \ - --tags=${{ inputs.tags }} \ - --publishVersion=${{ inputs.version_to_publish }} \ - --skipPackages=${{ inputs.skip_packages }} ${{ (inputs.dry && '') || '\'}} - ${{ inputs.dry && '--dry' || '' }} - - name: Archive released package for debugging - uses: actions/upload-artifact@v4 - with: - name: build - path: | - ./build/node_modules diff --git a/scripts/release/README.md b/scripts/release/README.md index 77042a5dac8b..33d65642da1c 100644 --- a/scripts/release/README.md +++ b/scripts/release/README.md @@ -19,18 +19,9 @@ The release process consists of several phases, each one represented by one of t A typical release cycle goes like this: 1. When a commit is pushed to the React repo, [GitHub Actions](https://github.com/facebook/react/actions) will build all release bundles and run unit tests against both the source code and the built bundles. -2. Each weekday, an automated CI cron job publishes prereleases to the `next` and `experimental` channels, from tip of the main branch. - 1. You can also [trigger an automated prerelease via the GitHub UI](#trigger-an-automated-prerelease), instead of waiting until the next time the cron job runs. - 2. For advanced cases, you can [**manually prepare and publish to the `next` channel**](#publishing-release) using the [`prepare-release-from-ci`](#prepare-release-from-ci) and [`publish`](#publish) scripts; or to the [**`experimental` channel**](#publishing-an-experimental-release) using the same scripts (but different build artifacts). -3. Finally, a "next" release can be [**promoted to stable**](#publishing-a-stable-release)1 using the [`prepare-release-from-npm`](#prepare-release-from-npm) and [`publish`](#publish) scripts. (This process is always manual.) - -The high level process of creating releases is [documented below](#process). Individual scripts are documented as well: -* [`build-release-locally`](#build-release-locally): Build a release locally from the checked out source code. -* [`prepare-release-from-ci`](#prepare-release-from-ci): Download a pre-built release from CI. -* [`prepare-release-from-npm`](#prepare-release-from-npm): Prepare an NPM "next" release to be published as a "stable" release. -* [`publish`](#publish): Publish the downloaded (or prepared) release to NPM. - -1. [**Creating a patch release**](#creating-a-patch-release) has a slightly different process than a major/minor release. +2. Each weekday, an automated CI cron job publishes prereleases to the `canary` and `experimental` channels, from tip of the main branch. + You can also [trigger an automated prerelease via the GitHub UI](#trigger-an-automated-prerelease), instead of waiting until the next time the cron job runs. +3. Finally, a "canary" release can be [**promoted to stable**](#publishing-a-stable-release)1 (This process is always manual.) ## Trigger an Automated Prerelease @@ -38,151 +29,45 @@ If your code lands in the main branch, it will be automatically published to the 1. Wait for the commit you want to release to finish its [(Runtime) Build and Test workflow](https://github.com/facebook/react/actions/workflows/runtime_build_and_test.yml), as the prerelease script needs to download the build from that workflow. 2. Copy the full git sha of whichever commit you are trying to release -3. Go to https://github.com/facebook/react/actions/workflows/runtime_prereleases_manual.yml +3. Go to https://github.com/facebook/react/actions/workflows/runtime_release_from_ci.yml 4. Paste the git sha into the "Run workflow" dropdown 5. Let the job finish and it will be released on npm This will grab the specified revision on the main branch and publish it to the Next and Experimental channels. -## Publishing Without Tags - -The sections below include meaningful `--tags` in the instructions. However, keep in mind that **the `--tags` arguments is optional**, and you can omit it if you don't want to tag the release on npm at all. This can be useful when preparing breaking changes. - -## Publishing Next -"Next" builds are meant to be lightweight and published often. In most cases, they can be published using artifacts built by Circle CI. - -To prepare a build for a particular commit: -1. Choose a commit from [the commit log](https://github.com/facebook/react/commits/main). -2. Copy the SHA (by clicking the 📋 button) -5. Run the [`prepare-release-from-ci`](#prepare-release-from-ci) script with the SHA 1 you found: -```sh -scripts/release/prepare-release-from-ci.js -r stable --commit=0e526bc -``` - -Once the build has been checked out and tested locally, you're ready to publish it: -```sh -scripts/release/publish.js --tags next -``` - -1: You can omit the `commit` param if you just want to release the latest commit as to "next". ## Publishing an Experimental Release -Experimental releases are special because they have additional features turned on. - -The steps for publishing an experimental release are almost the same as for publishing a "next" release except for the release channel (`-r`) flag. - -```sh -scripts/release/prepare-release-from-ci.js -r experimental --commit=0e526bc -``` +Same as for a prerelease except choose `experimental-only` as the type -Once the build has been checked out and tested locally, you're ready to publish it. When publishing an experimental release, use the `experimental` tag: +## Publishing Without Tags -```sh -scripts/release/publish.js --tags experimental -``` +The sections below include meaningful `--tags` in the instructions. However, keep in mind that **the `--tags` arguments is optional**, and you can omit it if you don't want to tag the release on npm at all. This can be useful when preparing breaking changes. ## Publishing a Stable Release -Stable releases should always be created from the "next" channel. This encourages better testing of the actual release artifacts and reduces the chance of unintended changes accidentally being included in a stable release. - -To prepare a stable release, choose a "next" version and run the [`prepare-release-from-npm`](#prepare-release-from-npm) script 1: - -```sh -scripts/release/prepare-release-from-npm.js --version=0.0.0-241c4467e-20200129 -``` - -This script will prompt you to select stable version numbers for each of the packages. It will update the package JSON versions (and dependencies) based on the numbers you select. - -Once this step is complete, you're ready to publish the release: - -```sh -scripts/release/publish.js --tags latest - -# Or, if you want to bump "next" as well: -scripts/release/publish.js --tags latest next -``` - -After successfully publishing the release, follow the on-screen instructions to ensure that all of the appropriate post-release steps are executed. - -1: You can omit the `version` param if you just want to promote the latest "next" candidate to stable. - -## Creating a Patch Release - -Patch releases should always be created by branching from a previous release. This reduces the likelihood of unstable changes being accidentally included in the release. - -Begin by creating a branch from the previous git tag1: - -```sh -git checkout -b 16.8.3 v16.8.2 -``` - -Next cherry pick any changes from main that you want to include in the release: - -```sh -git cherry-pick -``` - -Once you have cherry picked all of the commits you want to include in the release, push your feature branch and create a Pull Request (so that Circle CI will create a build): - -```sh -git push origin 16.8.3 -``` - -Once CI is complete, follow the regular [**next**](#publishing-release) and [**promote to stable**](#publishing-a-stable-release) processes. - -1: The `build-info.json` artifact can also be used to identify the appropriate commit (e.g. [unpkg.com/react@16.8.3/build-info.json](https://unpkg.com/react@16.8.3/build-info.json) shows us that react version 16.8.3 was created from commit [`29b7b775f`](https://github.com/facebook/react/commit/29b7b775f)). - -# Scripts - -## `build-release-locally` -Creates a "next" build from the current (local) Git revision. - -**This script is an escape hatch.** It allows a release to be created without pushing a commit to be verified by Circle CI. **It does not run any automated unit tests.** Testing is solely the responsibility of the release engineer. - -Note that this script git-archives the React repo (at the current revision) to a temporary directory before building, so **uncommitted changes are not included in the build**. - -#### Example usage -To create a build from the current branch and revision: -```sh -scripts/release/build-release-locally.js -``` - -## `prepare-release-from-ci` -Downloads build artifacts from Circle CI in preparation to be published to NPM as either a "next" or "experimental" release. - -All artifacts built by Circle CI have already been unit-tested (both source and bundles) but these candidates should **always be manually tested** before being published. Upon completion, this script prints manual testing instructions. - -#### Example usage -To prepare the artifacts created by Circle CI for commit [0e526bc](https://github.com/facebook/react/commit/0e526bc) you would run: -```sh -scripts/release/prepare-release-from-ci.js --commit=0e526bc -r stable -``` - -## `prepare-release-from-npm` -Checks out a "next" release from NPM and prepares it to be published as a stable release. - -This script prompts for new (stable) release versions for each public package and updates the package contents (both `package.json` and inline version numbers) to match. It also updates inter-package dependencies to account for the new versions. - -"Next" releases have already been tested but it is still a good idea to **manually test and verify a release** before publishing to ensure that e.g. version numbers are correct. Upon completion, this script prints manual testing instructions. +Stable releases should always be created from the "canary" channel. This encourages better testing of the actual release artifacts and reduces the chance of unintended changes accidentally being included in a stable release. -#### Example usage -To promote the "next" release `0.0.0-241c4467e-20200129` (aka commit [241c4467e](https://github.com/facebook/react/commit/241c4467e)) to stable: -```sh -scripts/release/prepare-release-from-npm.js --version=0.0.0-241c4467e-20200129 -``` +Before promoting, make sure the versions have been bumped: -## `publish` -Publishes the current contents of `build/node_modules` to NPM. +1. [ReactVersions.js](../../ReactVersions.js) +1. `package.json` files for each package +1. [packages/shared/ReactVersion.js](../../packages/shared/ReactVersion.js) -This script publishes each public package to NPM and updates the specified tag(s) to match. **It does not test or verify the local package contents before publishing**. This should be done by the release engineer prior to running the script. +Once the "canary" release has been tested and verified, you can promote it to stable by running the [Publish release](./actions/workflows/runtime_release_from_ci.yml) GitHub Action workflow. This workflow will prepare the release artifacts and publish them to NPM as either `stable-latest` (e.g. for `react@latest`) or `stable-backport` for an older release line that shouldn't move `@latest` (published under the `@backport` dist-tag instead). -Upon completion, this script provides instructions for tagging the Git commit that the package was created from and updating the release CHANGELOG. +> [!IMPORTANT] +> The designated commit must be able to build in CI. If runtime_build_and_test.yml fails for that commit, the release workflow will also fail. -**Specify a `--dry` flag when running this script if you want to skip the NPM-publish step.** In this event, the script will print the NPM commands but it will not actually run them. +### Backport -#### Example usage -To publish a release to NPM as both `next` and `latest`: -```sh -scripts/release/publish.js --tags latest next -``` +1. Pick a commit from `main` which you want to backport +1. Choose which release you want to backport it to +1. Find or create a branch for that release following our release branch naming convention `releases/**/*` e.g. `releases/19.1.x` +1. Make sure versions (`ReactVersions.js`, `ReactVersion.js`, `package.json`) are set to an unreleased version +1. Cherry-pick desired commits +1. Push to branch +1. [Publish release](./actions/workflows/runtime_release_from_ci.yml) + - `workflow_from` is the newly pushed commit containing cherry-picked changes, + - `type` is either `stable-latest` (e.g. for `react@latest`) or `stable-backport` for an older release line that shouldn't move `@latest` (published under the `@backport` dist-tag instead). +1. For stable releases the workflow will prepare everything for an automated publish. However, the final publish step will require manual approval in the GitHub Actions UI. diff --git a/scripts/release/prepare-release-from-npm-commands/check-out-packages.js b/scripts/release/prepare-release-from-npm-commands/check-out-packages.js deleted file mode 100644 index ea7d1252a06e..000000000000 --- a/scripts/release/prepare-release-from-npm-commands/check-out-packages.js +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env node - -'use strict'; - -const {exec} = require('child-process-promise'); -const {existsSync} = require('fs'); -const {join} = require('path'); -const {execRead, logPromise} = require('../utils'); -const theme = require('../theme'); - -const run = async ({cwd, local, packages, version}) => { - if (local) { - // Sanity test - if (!existsSync(join(cwd, 'build', 'node_modules', 'react'))) { - console.error(theme.error`No local build exists.`); - process.exit(1); - } - return; - } - - if (!existsSync(join(cwd, 'build'))) { - await exec(`mkdir ./build`, {cwd}); - } - - // Cleanup from previous builds - await exec(`rm -rf ./build/node_modules*`, {cwd}); - await exec(`mkdir ./build/node_modules`, {cwd}); - - const nodeModulesPath = join(cwd, 'build/node_modules'); - - // Checkout "next" release from NPM for all local packages - for (let i = 0; i < packages.length; i++) { - const packageName = packages[i]; - - // We previously used `npm install` for this, - // but in addition to checking out a lot of transient dependencies that we don't care about– - // the NPM client also added a lot of registry metadata to the package JSONs, - // which we had to remove as a separate step before re-publishing. - // It's easier for us to just download and extract the tarball. - const url = await execRead( - `npm view ${packageName}@${version} dist.tarball` - ); - const filePath = join(nodeModulesPath, `${packageName}.tgz`); - const packagePath = join(nodeModulesPath, `${packageName}`); - const tempPackagePath = join(nodeModulesPath, 'package'); - - // Download packages from NPM and extract them to the expected build locations. - await exec(`curl -L ${url} > ${filePath}`, {cwd}); - await exec(`tar -xvzf ${filePath} -C ${nodeModulesPath}`, {cwd}); - await exec(`mv ${tempPackagePath} ${packagePath}`, {cwd}); - await exec(`rm ${filePath}`, {cwd}); - } -}; - -module.exports = async params => { - return logPromise( - run(params), - theme`Checking out "next" from NPM {version ${params.version}}` - ); -}; diff --git a/scripts/release/prepare-release-from-npm-commands/confirm-stable-version-numbers.js b/scripts/release/prepare-release-from-npm-commands/confirm-stable-version-numbers.js deleted file mode 100644 index 6bf49132149a..000000000000 --- a/scripts/release/prepare-release-from-npm-commands/confirm-stable-version-numbers.js +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env node - -'use strict'; - -const prompt = require('prompt-promise'); -const semver = require('semver'); -const theme = require('../theme'); -const {confirm} = require('../utils'); - -const run = async ({ci, skipPackages}, versionsMap) => { - const groupedVersionsMap = new Map(); - - // Group packages with the same source versions. - // We want these to stay lock-synced anyway. - // This will require less redundant input from the user later, - // and reduce the likelihood of human error (entering the wrong version). - versionsMap.forEach((version, packageName) => { - if (!groupedVersionsMap.has(version)) { - groupedVersionsMap.set(version, [packageName]); - } else { - groupedVersionsMap.get(version).push(packageName); - } - }); - - if (ci !== true) { - // Prompt user to confirm or override each version group if not running in CI. - const entries = [...groupedVersionsMap.entries()]; - for (let i = 0; i < entries.length; i++) { - const [bestGuessVersion, packages] = entries[i]; - const packageNames = packages.map(name => theme.package(name)).join(', '); - - let version = bestGuessVersion; - if ( - skipPackages.some(skipPackageName => packages.includes(skipPackageName)) - ) { - await confirm( - theme`{spinnerSuccess ✓} Version for ${packageNames} will remain {version ${bestGuessVersion}}` - ); - } else { - const defaultVersion = bestGuessVersion - ? theme.version(` (default ${bestGuessVersion})`) - : ''; - version = - (await prompt( - theme`{spinnerSuccess ✓} Version for ${packageNames}${defaultVersion}: ` - )) || bestGuessVersion; - prompt.done(); - } - - // Verify a valid version has been supplied. - try { - semver(version); - - packages.forEach(packageName => { - versionsMap.set(packageName, version); - }); - } catch (error) { - console.log( - theme`{spinnerError ✘} Version {version ${version}} is invalid.` - ); - - // Prompt again - i--; - } - } - } -}; - -// Run this directly because it's fast, -// and logPromise would interfere with console prompting. -module.exports = run; diff --git a/scripts/release/prepare-release-from-npm-commands/get-latest-next-version.js b/scripts/release/prepare-release-from-npm-commands/get-latest-next-version.js deleted file mode 100644 index f40730d119ae..000000000000 --- a/scripts/release/prepare-release-from-npm-commands/get-latest-next-version.js +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env node - -'use strict'; - -const {execRead, logPromise} = require('../utils'); - -const run = async () => { - const version = await execRead('npm info react@canary version'); - - return version; -}; - -module.exports = async params => { - return logPromise(run(params), 'Determining latest "canary" release version'); -}; diff --git a/scripts/release/prepare-release-from-npm-commands/guess-stable-version-numbers.js b/scripts/release/prepare-release-from-npm-commands/guess-stable-version-numbers.js deleted file mode 100644 index c0072b663717..000000000000 --- a/scripts/release/prepare-release-from-npm-commands/guess-stable-version-numbers.js +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env node - -'use strict'; - -const semver = require('semver'); -const {execRead, logPromise} = require('../utils'); - -const run = async ( - {cwd, packages, skipPackages, ci, publishVersion}, - versionsMap -) => { - const branch = await execRead('git branch | grep \\* | cut -d " " -f2', { - cwd, - }); - - for (let i = 0; i < packages.length; i++) { - const packageName = packages[i]; - - if (ci === true) { - if (publishVersion != null) { - versionsMap.set(packageName, publishVersion); - } else { - console.error( - 'When running in CI mode, a publishVersion must be supplied' - ); - process.exit(1); - } - } else { - try { - // In case local package JSONs are outdated, - // guess the next version based on the latest NPM release. - const version = await execRead(`npm show ${packageName} version`); - - if (skipPackages.includes(packageName)) { - versionsMap.set(packageName, version); - } else { - const {major, minor, patch} = semver(version); - - // Guess the next version by incrementing patch. - // The script will confirm this later. - // By default, new releases from mains should increment the minor version number, - // and patch releases should be done from branches. - if (branch === 'main') { - versionsMap.set(packageName, `${major}.${minor + 1}.0`); - } else { - versionsMap.set(packageName, `${major}.${minor}.${patch + 1}`); - } - } - } catch (error) { - // If the package has not yet been published, - // we'll require a version number to be entered later. - versionsMap.set(packageName, null); - } - } - } -}; - -module.exports = async (params, versionsMap) => { - return logPromise( - run(params, versionsMap), - 'Guessing stable version numbers' - ); -}; diff --git a/scripts/release/prepare-release-from-npm-commands/parse-params.js b/scripts/release/prepare-release-from-npm-commands/parse-params.js deleted file mode 100644 index 10dbfb4e51f5..000000000000 --- a/scripts/release/prepare-release-from-npm-commands/parse-params.js +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env node - -'use strict'; - -const commandLineArgs = require('command-line-args'); -const {splitCommaParams} = require('../utils'); - -const paramDefinitions = [ - { - name: 'local', - type: Boolean, - description: - 'Skip NPM and use the build already present in "build/node_modules".', - defaultValue: false, - }, - { - name: 'onlyPackages', - type: String, - multiple: true, - description: 'Packages to include in publishing', - defaultValue: [], - }, - { - name: 'skipPackages', - type: String, - multiple: true, - description: 'Packages to exclude from publishing', - defaultValue: [], - }, - { - name: 'skipTests', - type: Boolean, - description: 'Skip automated fixture tests.', - defaultValue: false, - }, - { - name: 'version', - type: String, - description: - 'Version of published "next" release (e.g. 0.0.0-0e526bcec-20210202)', - }, - { - name: 'publishVersion', - type: String, - description: 'Version to publish', - }, - { - name: 'ci', - type: Boolean, - description: 'Run in automated environment, without interactive prompts.', - defaultValue: false, - }, -]; - -module.exports = () => { - const params = commandLineArgs(paramDefinitions); - - splitCommaParams(params.skipPackages); - splitCommaParams(params.onlyPackages); - - return params; -}; diff --git a/scripts/release/prepare-release-from-npm-commands/update-stable-version-numbers.js b/scripts/release/prepare-release-from-npm-commands/update-stable-version-numbers.js deleted file mode 100644 index eef68255e2ba..000000000000 --- a/scripts/release/prepare-release-from-npm-commands/update-stable-version-numbers.js +++ /dev/null @@ -1,167 +0,0 @@ -#!/usr/bin/env node - -'use strict'; - -const clear = require('clear'); -const {readFileSync, writeFileSync} = require('fs'); -const {readJson, writeJson} = require('fs-extra'); -const {join, relative} = require('path'); -const {confirm, execRead, printDiff} = require('../utils'); -const theme = require('../theme'); - -const run = async ({cwd, packages, version, ci}, versionsMap) => { - const nodeModulesPath = join(cwd, 'build/node_modules'); - - // Cache all package JSONs for easy lookup below. - const sourcePackageJSONs = new Map(); - for (let i = 0; i < packages.length; i++) { - const packageName = packages[i]; - const sourcePackageJSON = await readJson( - join(cwd, 'packages', packageName, 'package.json') - ); - sourcePackageJSONs.set(packageName, sourcePackageJSON); - } - - const updateDependencies = async (targetPackageJSON, key) => { - const targetDependencies = targetPackageJSON[key]; - if (targetDependencies) { - const sourceDependencies = sourcePackageJSONs.get(targetPackageJSON.name)[ - key - ]; - - for (let i = 0; i < packages.length; i++) { - const dependencyName = packages[i]; - const targetDependency = targetDependencies[dependencyName]; - - if (targetDependency) { - // For example, say we're updating react-dom's dependency on scheduler. - // We compare source packages to determine what the new scheduler dependency constraint should be. - // To do this, we look at both the local version of the scheduler (e.g. 0.11.0), - // and the dependency constraint in the local version of react-dom (e.g. scheduler@^0.11.0). - const sourceDependencyVersion = - sourcePackageJSONs.get(dependencyName).version; - const sourceDependencyConstraint = sourceDependencies[dependencyName]; - - // If the source dependency's version and the constraint match, - // we will need to update the constraint to point at the dependency's new release version, - // (e.g. scheduler@^0.11.0 becomes scheduler@^0.12.0 when we release scheduler 0.12.0). - // Otherwise we leave the constraint alone (e.g. react@^16.0.0 doesn't change between releases). - // Note that in both cases, we must update the target package JSON, - // since "next" releases are all locked to the version (e.g. 0.0.0-0e526bcec-20210202). - if ( - sourceDependencyVersion === - sourceDependencyConstraint.replace(/^[\^\~]/, '') - ) { - targetDependencies[dependencyName] = - sourceDependencyConstraint.replace( - sourceDependencyVersion, - versionsMap.get(dependencyName) - ); - } else { - targetDependencies[dependencyName] = sourceDependencyConstraint; - } - } - } - } - }; - - // Update all package JSON versions and their dependencies/peerDependencies. - // This must be done in a way that respects semver constraints (e.g. 16.7.0, ^16.7.0, ^16.0.0). - // To do this, we use the dependencies defined in the source package JSONs, - // because the "next" dependencies have already been flattened to an exact match (e.g. 0.0.0-0e526bcec-20210202). - for (let i = 0; i < packages.length; i++) { - const packageName = packages[i]; - const packageJSONPath = join(nodeModulesPath, packageName, 'package.json'); - const packageJSON = await readJson(packageJSONPath); - packageJSON.version = versionsMap.get(packageName); - - await updateDependencies(packageJSON, 'dependencies'); - await updateDependencies(packageJSON, 'peerDependencies'); - - await writeJson(packageJSONPath, packageJSON, {spaces: 2}); - } - - clear(); - - // Print the map of versions and their dependencies for confirmation. - const printDependencies = (maybeDependency, label) => { - if (maybeDependency) { - for (let dependencyName in maybeDependency) { - if (packages.includes(dependencyName)) { - console.log( - theme`• {package ${dependencyName}} {version ${maybeDependency[dependencyName]}} {dimmed ${label}}` - ); - } - } - } - }; - for (let i = 0; i < packages.length; i++) { - const packageName = packages[i]; - const packageJSONPath = join(nodeModulesPath, packageName, 'package.json'); - const packageJSON = await readJson(packageJSONPath); - console.log( - theme`\n{package ${packageName}} {version ${versionsMap.get( - packageName - )}}` - ); - printDependencies(packageJSON.dependencies, 'dependency'); - printDependencies(packageJSON.peerDependencies, 'peer'); - } - if (ci !== true) { - await confirm('Do the versions above look correct?'); - } - - clear(); - - if (packages.includes('react')) { - // We print the diff to the console for review, - // but it can be large so let's also write it to disk. - const diffPath = join(cwd, 'build', 'temp.diff'); - let diff = ''; - let numFilesModified = 0; - - // Find-and-replace hardcoded version (in built JS) for renderers. - for (let i = 0; i < packages.length; i++) { - const packageName = packages[i]; - const packagePath = join(nodeModulesPath, packageName); - - let files = await execRead( - `find ${packagePath} -name '*.js' -exec echo {} \\;`, - {cwd} - ); - files = files.split('\n'); - files.forEach(path => { - const newStableVersion = versionsMap.get(packageName); - const beforeContents = readFileSync(path, 'utf8', {cwd}); - let afterContents = beforeContents; - // Replace all "next" version numbers (e.g. header @license). - while (afterContents.indexOf(version) >= 0) { - afterContents = afterContents.replace(version, newStableVersion); - } - if (beforeContents !== afterContents) { - numFilesModified++; - // Using a relative path for diff helps with the snapshot test - diff += printDiff(relative(cwd, path), beforeContents, afterContents); - writeFileSync(path, afterContents, {cwd}); - } - }); - } - writeFileSync(diffPath, diff, {cwd}); - console.log(theme.header(`\n${numFilesModified} files have been updated.`)); - console.log( - theme`A full diff is available at {path ${relative(cwd, diffPath)}}.` - ); - if (ci !== true) { - await confirm('Do the changes above look correct?'); - } - } else { - console.log( - theme`Skipping React renderer version update because React is not included in the release.` - ); - } - - clear(); -}; - -// Run this directly because logPromise would interfere with printing package dependencies. -module.exports = run; diff --git a/scripts/release/prepare-release-from-npm.js b/scripts/release/prepare-release-from-npm.js deleted file mode 100755 index bb67fddd3780..000000000000 --- a/scripts/release/prepare-release-from-npm.js +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env node - -'use strict'; - -const {join} = require('path'); -const {getPublicPackages, handleError} = require('./utils'); - -const checkOutPackages = require('./prepare-release-from-npm-commands/check-out-packages'); -const confirmStableVersionNumbers = require('./prepare-release-from-npm-commands/confirm-stable-version-numbers'); -const getLatestNextVersion = require('./prepare-release-from-npm-commands/get-latest-next-version'); -const guessStableVersionNumbers = require('./prepare-release-from-npm-commands/guess-stable-version-numbers'); -const parseParams = require('./prepare-release-from-npm-commands/parse-params'); -const printPrereleaseSummary = require('./shared-commands/print-prerelease-summary'); -const testPackagingFixture = require('./shared-commands/test-packaging-fixture'); -const updateStableVersionNumbers = require('./prepare-release-from-npm-commands/update-stable-version-numbers'); -const theme = require('./theme'); - -const run = async () => { - try { - const params = parseParams(); - params.cwd = join(__dirname, '..', '..'); - - const isExperimental = params.version.includes('experimental'); - - if (!params.version) { - params.version = await getLatestNextVersion(); - } - - if (params.onlyPackages.length > 0 && params.skipPackages.length > 0) { - console.error( - '--onlyPackages and --skipPackages cannot be used together' - ); - process.exit(1); - } - - params.packages = await getPublicPackages(isExperimental); - params.packages = params.packages.filter(packageName => { - if (params.onlyPackages.length > 0) { - return params.onlyPackages.includes(packageName); - } - return !params.skipPackages.includes(packageName); - }); - - // Map of package name to upcoming stable version. - // This Map is initially populated with guesses based on local versions. - // The developer running the release later confirms or overrides each version. - const versionsMap = new Map(); - - if (isExperimental) { - console.error( - theme.error`Cannot promote an experimental build to stable.` - ); - process.exit(1); - } - - await checkOutPackages(params); - await guessStableVersionNumbers(params, versionsMap); - await confirmStableVersionNumbers(params, versionsMap); - await updateStableVersionNumbers(params, versionsMap); - - if (!params.skipTests) { - await testPackagingFixture(params); - } - - await printPrereleaseSummary(params, true); - } catch (error) { - handleError(error); - } -}; - -run(); diff --git a/scripts/release/publish-commands/check-npm-permissions.js b/scripts/release/publish-commands/check-npm-permissions.js deleted file mode 100644 index 834053100571..000000000000 --- a/scripts/release/publish-commands/check-npm-permissions.js +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env node - -'use strict'; - -const {execRead, logPromise} = require('../utils'); -const theme = require('../theme'); - -const run = async ({cwd, packages, version}) => { - const currentUser = await execRead('npm whoami'); - const failedProjects = []; - - const checkProject = async project => { - const owners = (await execRead(`npm owner ls ${project}`)) - .split('\n') - .filter(owner => owner) - .map(owner => owner.split(' ')[0]); - - if (!owners.includes(currentUser)) { - failedProjects.push(project); - } - }; - - await logPromise( - Promise.all(packages.map(checkProject)), - theme`Checking NPM permissions for {underline ${currentUser}}.` - ); - - if (failedProjects.length) { - console.error( - theme` - {error Insufficient NPM permissions} - \nNPM user {underline ${currentUser}} is not an owner for: ${failedProjects - .map(name => theme.package(name)) - .join(', ')} - \nPlease contact a React team member to be added to the above project(s). - ` - .replace(/\n +/g, '\n') - .trim() - ); - process.exit(1); - } -}; - -module.exports = run; diff --git a/scripts/release/publish-commands/confirm-skipped-packages.js b/scripts/release/publish-commands/confirm-skipped-packages.js deleted file mode 100644 index 3e7bc03d6854..000000000000 --- a/scripts/release/publish-commands/confirm-skipped-packages.js +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env node - -'use strict'; - -const clear = require('clear'); -const {confirm} = require('../utils'); -const theme = require('../theme'); - -const run = async ({cwd, packages, skipPackages, tags}) => { - if (skipPackages.length === 0) { - return; - } - - clear(); - - console.log( - theme`{spinnerSuccess ✓} The following packages will not be published as part of this release` - ); - - skipPackages.forEach(packageName => { - console.log(theme`• {package ${packageName}}`); - }); - - await confirm('Do you want to proceed?'); - - clear(); -}; - -// Run this directly because it's fast, -// and logPromise would interfere with console prompting. -module.exports = run; diff --git a/scripts/release/publish-commands/confirm-version-and-tags.js b/scripts/release/publish-commands/confirm-version-and-tags.js index 2c6fed58d193..45f64e693387 100644 --- a/scripts/release/publish-commands/confirm-version-and-tags.js +++ b/scripts/release/publish-commands/confirm-version-and-tags.js @@ -2,29 +2,14 @@ 'use strict'; -const clear = require('clear'); const {readJson} = require('fs-extra'); const {join} = require('path'); -const {confirm} = require('../utils'); const theme = require('../theme'); -const run = async ({cwd, packages, tags, ci}) => { - clear(); - - if (tags.length === 0) { - console.error('Expected at least one tag.'); - process.exit(1); - } else if (tags.length === 1) { - console.log( - theme`{spinnerSuccess ✓} You are about the publish the following packages under the tag {tag ${tags}}:` - ); - } else { - console.log( - theme`{spinnerSuccess ✓} You are about the publish the following packages under the tags {tag ${tags.join( - ', ' - )}}:` - ); - } +const run = async ({cwd, packages, tag}) => { + console.log( + theme`{spinnerSuccess ✓} You are about the publish the following packages under the tag {tag ${tag}}:` + ); for (let i = 0; i < packages.length; i++) { const packageName = packages[i]; @@ -36,20 +21,11 @@ const run = async ({cwd, packages, tags, ci}) => { ); const packageJSON = await readJson(packageJSONPath); console.log( - theme`• {package ${packageName}} {version ${packageJSON.version}}` + `::group::${theme`{package ${packageName}} {version ${packageJSON.version}}`}` ); - if (ci) { - console.log(packageJSON); - } - } - - if (!ci) { - await confirm('Do you want to proceed?'); + console.log(packageJSON); + console.log('::endgroup::'); } - - clear(); }; -// Run this directly because it's fast, -// and logPromise would interfere with console prompting. module.exports = run; diff --git a/scripts/release/publish-commands/parse-params.js b/scripts/release/publish-commands/parse-params.js index 69a53737accc..67dd4fa6a2ff 100644 --- a/scripts/release/publish-commands/parse-params.js +++ b/scripts/release/publish-commands/parse-params.js @@ -13,11 +13,12 @@ const paramDefinitions = [ defaultValue: false, }, { - name: 'tags', + name: 'tag', type: String, - multiple: true, - description: 'NPM tags to point to the new release.', - defaultValue: ['untagged'], + description: + 'NPM dist-tag to attach at publish time. OIDC trusted publishing ' + + 'authorizes a single tag per publish, so only one value is accepted ' + + '— passing comma-separated tags or repeating --tag is rejected.', }, { name: 'onlyPackages', @@ -33,40 +34,39 @@ const paramDefinitions = [ description: 'Packages to exclude from publishing', defaultValue: [], }, - { - name: 'ci', - type: Boolean, - description: 'Run in automated environment, without interactive prompts.', - defaultValue: false, - }, - { - name: 'publishVersion', - type: String, - description: 'Version to publish', - }, ]; module.exports = () => { const params = commandLineArgs(paramDefinitions); splitCommaParams(params.skipPackages); splitCommaParams(params.onlyPackages); - splitCommaParams(params.tags); - params.tags.forEach(tag => { - switch (tag) { - case 'latest': - case 'canary': - case 'next': - case 'experimental': - case 'alpha': - case 'beta': - case 'rc': - case 'untagged': - break; - default: - console.error('Unsupported tag: "' + tag + '"'); - process.exit(1); - break; - } - }); + + // Single-tag invariant. `command-line-args` already collapses multiple + // --tag occurrences to the last value (since `multiple` is not set), but it + // happily accepts `--tag a,b` as the literal string "a,b". Reject that + // here so the failure is loud and obvious instead of being deferred to a + // later "Unsupported tag" message that doesn't explain the cause. + if (params.tag == null || params.tag === '') { + console.error('--tag is required and must be a single dist-tag.'); + process.exit(1); + } + if (params.tag.includes(',') || params.tag.includes(' ')) { + console.error('Only a single --tag is allowed, got: "' + params.tag + '"'); + process.exit(1); + } + switch (params.tag) { + case 'latest': + case 'canary': + case 'experimental': + case 'backport': + case 'alpha': + case 'beta': + case 'rc': + break; + default: + console.error('Unsupported tag: "' + params.tag + '"'); + process.exit(1); + } + return params; }; diff --git a/scripts/release/publish-commands/print-follow-up-instructions.js b/scripts/release/publish-commands/print-follow-up-instructions.js deleted file mode 100644 index 92eac820e26b..000000000000 --- a/scripts/release/publish-commands/print-follow-up-instructions.js +++ /dev/null @@ -1,129 +0,0 @@ -#!/usr/bin/env node - -'use strict'; - -const clear = require('clear'); -const {existsSync} = require('fs'); -const {readJsonSync} = require('fs-extra'); -const {join} = require('path'); -const theme = require('../theme'); -const {execRead} = require('../utils'); - -const run = async ({cwd, packages, tags}) => { - // Tags are named after the react version. - const {version} = readJsonSync( - `${cwd}/build/node_modules/react/package.json` - ); - - clear(); - - if (tags.length === 1 && tags[0] === 'next') { - console.log( - theme`{header A "next" release} {version ${version}} {header has been published!}` - ); - } else if (tags.length === 1 && tags[0] === 'experimental') { - console.log( - theme`{header An "experimental" release} {version ${version}} {header has been published!}` - ); - } else { - const nodeModulesPath = join(cwd, 'build/node_modules'); - - console.log( - theme.caution`The release has been published but you're not done yet!` - ); - - if (tags.includes('latest')) { - // All packages are built from a single source revision, - // so it is safe to read build info from any one of them. - const arbitraryPackageName = packages[0]; - // FIXME: New build script does not output build-info.json. It's only used - // by this post-publish print job, and only for "latest" releases, so I've - // disabled it as a workaround so the publish script doesn't crash for - // "next" and "experimental" pre-releases. - const {commit} = readJsonSync( - join( - cwd, - 'build', - 'node_modules', - arbitraryPackageName, - 'build-info.json' - ) - ); - - console.log(); - console.log( - theme.header`Please review and commit all local, staged changes.` - ); - - console.log(); - console.log('Version numbers have been updated in the following files:'); - for (let i = 0; i < packages.length; i++) { - const packageName = packages[i]; - console.log(theme.path`• packages/%s/package.json`, packageName); - } - const status = await execRead( - 'git diff packages/shared/ReactVersion.js', - {cwd} - ); - if (status) { - console.log(theme.path`• packages/shared/ReactVersion.js`); - } - - console.log(); - console.log( - theme`{header Don't forget to also update and commit the }{path CHANGELOG}` - ); - - // Prompt the release engineer to tag the commit and update the CHANGELOG. - // (The script could automatically do this, but this seems safer.) - console.log(); - console.log( - theme.header`Tag the source for this release in Git with the following command:` - ); - console.log( - theme` {command git tag -a v}{version %s} {command -m "v%s"} {version %s}`, - version, - version, - commit - ); - console.log(theme.command` git push origin --tags`); - - console.log(); - console.log(theme.header`Lastly, please fill in the release on GitHub.`); - console.log( - theme.link`https://github.com/facebook/react/releases/tag/v%s`, - version - ); - console.log( - theme`\nThe GitHub release should also include links to the following artifacts:` - ); - for (let i = 0; i < packages.length; i++) { - const packageName = packages[i]; - if (existsSync(join(nodeModulesPath, packageName, 'umd'))) { - const {version: packageVersion} = readJsonSync( - join(nodeModulesPath, packageName, 'package.json') - ); - console.log( - theme`{path • %s:} {link https://unpkg.com/%s@%s/umd/}`, - packageName, - packageName, - packageVersion - ); - } - } - - // Update reactjs.org so the React version shown in the header is up to date. - console.log(); - console.log( - theme.header`Once you've pushed changes, update the docs site.` - ); - console.log( - 'This will ensure that any newly-added error codes can be decoded.' - ); - - console.log(); - } - } -}; - -module.exports = run; diff --git a/scripts/release/publish-commands/prompt-for-otp.js b/scripts/release/publish-commands/prompt-for-otp.js deleted file mode 100644 index c89c2884630e..000000000000 --- a/scripts/release/publish-commands/prompt-for-otp.js +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env node - -'use strict'; - -const prompt = require('prompt-promise'); -const theme = require('../theme'); - -const run = async () => { - while (true) { - const otp = await prompt('NPM 2-factor auth code: '); - prompt.done(); - - if (otp) { - return otp; - } else { - console.log(); - console.log(theme.error`Two-factor auth is required to publish.`); - // (Ask again.) - } - } -}; - -module.exports = run; diff --git a/scripts/release/publish-commands/publish-to-npm.js b/scripts/release/publish-commands/publish-to-npm.js index f1e62657c0c3..dfd467df18a7 100644 --- a/scripts/release/publish-commands/publish-to-npm.js +++ b/scripts/release/publish-commands/publish-to-npm.js @@ -3,97 +3,52 @@ 'use strict'; const {spawnSync} = require('child_process'); -const {exec} = require('child-process-promise'); const {readJsonSync} = require('fs-extra'); const {join} = require('path'); -const {confirm} = require('../utils'); const theme = require('../theme'); -const run = async ({cwd, dry, tags, ci}, packageName, otp) => { +const run = async ({cwd, dry, tag}, packageName) => { const packagePath = join(cwd, 'build/node_modules', packageName); const {version} = readJsonSync(join(packagePath, 'package.json')); // Check if this package version has already been published. // If so we might be resuming from a previous run. - // We could infer this by comparing the build-info.json, - // But for now the easiest way is just to ask if this is expected. - const {status} = spawnSync('npm', ['view', `${packageName}@${version}`]); - const packageExists = status === 0; + const {status: npmViewStatus} = spawnSync('npm', [ + 'view', + `${packageName}@${version}`, + ]); + const packageExists = npmViewStatus === 0; if (packageExists) { console.log( theme`{package ${packageName}} {version ${version}} has already been published.` ); - if (!ci) { - await confirm('Is this expected?'); - } - } else { - console.log( - theme`{spinnerSuccess ✓} Publishing {package ${packageName}}${dry ? ' (dry-run)' : ''}` - ); - - // Publish the package and tag it. - if (!dry) { - if (!ci) { - await exec(`npm publish --tag=${tags[0]} --otp=${otp}`, { - cwd: packagePath, - }); - console.log(theme.command(` cd ${packagePath}`)); - console.log( - theme.command(` npm publish --tag=${tags[0]} --otp=${otp}`) - ); - } else { - await exec(`npm publish --tag=${tags[0]}`, { - cwd: packagePath, - }); - console.log(theme.command(` cd ${packagePath}`)); - console.log(theme.command(` npm publish --tag=${tags[0]}`)); - } - } - - for (let j = 1; j < tags.length; j++) { - if (!dry) { - if (!ci) { - await exec( - `npm dist-tag add ${packageName}@${version} ${tags[j]} --otp=${otp}`, - {cwd: packagePath} - ); - console.log( - theme.command( - ` npm dist-tag add ${packageName}@${version} ${tags[j]} --otp=${otp}` - ) - ); - } else { - await exec(`npm dist-tag add ${packageName}@${version} ${tags[j]}`, { - cwd: packagePath, - }); - console.log( - theme.command( - ` npm dist-tag add ${packageName}@${version} ${tags[j]}` - ) - ); - } - } - } + return; + } - if (tags.includes('untagged')) { - // npm doesn't let us publish without a tag at all, - // so for one-off publishes we clean it up ourselves. - if (!dry) { - if (!ci) { - await exec(`npm dist-tag rm ${packageName} untagged --otp=${otp}`); - console.log( - theme.command( - ` npm dist-tag rm ${packageName} untagged --otp=${otp}` - ) - ); - } else { - await exec(`npm dist-tag rm ${packageName} untagged`); - console.log( - theme.command(` npm dist-tag rm ${packageName} untagged`) - ); - } - } - } + console.log( + `::group::${theme`{spinnerSuccess ✓} Publishing {package ${packageName}}${dry ? ' (--dry-run)' : ''}`}` + ); + // OIDC trusted publishing authorizes exactly one publish per call, and + // can't add/remove dist-tags after the fact — so we only ever publish under + // a single tag, and that tag is the one chosen at workflow dispatch time. + const args = ['publish', `--tag`, tag]; + if (dry) { + args.push('--dry-run'); + } + console.log(theme.command(` cd ${packagePath}`)); + console.log(theme.command(` npm ${args.join(' ')}`)); + + const {error, status: publishStatus} = spawnSync('npm', args, { + cwd: packagePath, + stdio: ['ignore', 'inherit', 'inherit'], + }); + console.log('::endgroup::'); + if (error) { + throw error; + } else if (publishStatus !== 0) { + throw new Error( + `Failed to publish ${packageName}@${version} with tag ${tag}` + ); } }; diff --git a/scripts/release/publish-commands/update-stable-version-numbers.js b/scripts/release/publish-commands/update-stable-version-numbers.js deleted file mode 100644 index 76a6d6f1622c..000000000000 --- a/scripts/release/publish-commands/update-stable-version-numbers.js +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env node - -'use strict'; - -const {readFileSync, writeFileSync} = require('fs'); -const {readJson, writeJson} = require('fs-extra'); -const {join} = require('path'); - -const run = async ({cwd, packages, skipPackages, tags}) => { - if (!tags.includes('latest')) { - // Don't update version numbers for alphas. - return; - } - - const nodeModulesPath = join(cwd, 'build/node_modules'); - const packagesPath = join(cwd, 'packages'); - - // Update package versions and dependencies (in source) to mirror what was published to NPM. - for (let i = 0; i < packages.length; i++) { - const packageName = packages[i]; - const publishedPackageJSON = await readJson( - join(nodeModulesPath, packageName, 'package.json') - ); - const sourcePackageJSONPath = join( - packagesPath, - packageName, - 'package.json' - ); - const sourcePackageJSON = await readJson(sourcePackageJSONPath); - sourcePackageJSON.version = publishedPackageJSON.version; - sourcePackageJSON.dependencies = publishedPackageJSON.dependencies; - sourcePackageJSON.peerDependencies = publishedPackageJSON.peerDependencies; - - await writeJson(sourcePackageJSONPath, sourcePackageJSON, {spaces: 2}); - } - - // Update the shared React version source file. - // (Unless this release does not include an update to React) - if (!skipPackages.includes('react')) { - const sourceReactVersionPath = join(cwd, 'packages/shared/ReactVersion.js'); - const {version} = await readJson( - join(nodeModulesPath, 'react', 'package.json') - ); - const sourceReactVersion = readFileSync( - sourceReactVersionPath, - 'utf8' - ).replace(/export default '[^']+';/, `export default '${version}';`); - writeFileSync(sourceReactVersionPath, sourceReactVersion); - } -}; - -module.exports = run; diff --git a/scripts/release/publish-commands/validate-tags.js b/scripts/release/publish-commands/validate-tags.js index fbd58e9b3763..7580b31d94f4 100644 --- a/scripts/release/publish-commands/validate-tags.js +++ b/scripts/release/publish-commands/validate-tags.js @@ -6,8 +6,7 @@ const {readJson} = require('fs-extra'); const {join} = require('path'); const theme = require('../theme'); -const run = async ({cwd, packages, tags}) => { - // Prevent a "next" release from ever being published as @latest +const run = async ({cwd, packages, tag}) => { // All canaries share a version number, so it's okay to check any of them. const arbitraryPackageName = packages[0]; const packageJSONPath = join( @@ -18,42 +17,33 @@ const run = async ({cwd, packages, tags}) => { 'package.json' ); const {version} = await readJson(packageJSONPath); + const isExperimentalVersion = version.indexOf('experimental') !== -1; if (version.indexOf('-') !== -1) { - if (tags.includes('latest')) { - if (isExperimentalVersion) { - console.log( - theme`{error Experimental release} {version ${version}} {error cannot be tagged as} {tag latest}` - ); - } else { - console.log( - theme`{error Next release} {version ${version}} {error cannot be tagged as} {tag latest}` - ); - } - process.exit(1); - } - if (tags.includes('next') && isExperimentalVersion) { + // Prerelease: canary or experimental. + if (tag === 'latest' || tag === 'backport') { console.log( - theme`{error Experimental release} {version ${version}} {error cannot be tagged as} {tag next}` + theme`{error Prerelease} {version ${version}} {error cannot be tagged as} {tag ${tag}}` ); process.exit(1); } - if (tags.includes('experimental') && !isExperimentalVersion) { + if (tag === 'experimental' && !isExperimentalVersion) { console.log( - theme`{error Next release} {version ${version}} {error cannot be tagged as} {tag experimental}` + theme`{error Canary release} {version ${version}} {error cannot be tagged as} {tag experimental}` ); process.exit(1); } - } else { - if (!tags.includes('latest')) { + if (tag === 'canary' && isExperimentalVersion) { console.log( - theme`{error Stable release} {version ${version}} {error must always be tagged as} {tag latest}` + theme`{error Experimental release} {version ${version}} {error cannot be tagged as} {tag canary}` ); process.exit(1); } - if (tags.includes('experimental')) { + } else { + // Semver stable: must publish under @latest or @backport. + if (tag !== 'latest' && tag !== 'backport') { console.log( - theme`{error Stable release} {version ${version}} {error cannot be tagged as} {tag experimental}` + theme`{error Stable release} {version ${version}} {error must be tagged as} {tag latest} {error or} {tag backport}` ); process.exit(1); } diff --git a/scripts/release/publish-using-ci-workflow.js b/scripts/release/publish-using-ci-workflow.js deleted file mode 100644 index 33ca8d3dad71..000000000000 --- a/scripts/release/publish-using-ci-workflow.js +++ /dev/null @@ -1,148 +0,0 @@ -'use strict'; - -const fetch = require('node-fetch'); - -const {logPromise} = require('./utils'); -const theme = require('./theme'); - -const CIRCLE_TOKEN = process.env.CIRCLE_CI_API_TOKEN; - -if (!CIRCLE_TOKEN) { - console.error( - theme.error( - 'Missing required environment variable: CIRCLE_CI_API_TOKEN\n' + - 'Grab it here: https://app.circleci.com/settings/user/tokens' - ) - ); - process.exit(1); -} - -function sleep(ms) { - return new Promise(resolve => { - setTimeout(() => resolve(), ms); - }); -} - -async function getPublishWorkflowID(pipelineID) { - // Since we just created the pipeline in a POST request, the server may 404. - // Try a few times before giving up. - for (let i = 0; i < 20; i++) { - const pipelineWorkflowsResponse = await fetch( - `https://circleci.com/api/v2/pipeline/${pipelineID}/workflow` - ); - if (pipelineWorkflowsResponse.ok) { - const pipelineWorkflowsJSON = await pipelineWorkflowsResponse.json(); - const workflows = pipelineWorkflowsJSON.items; - if (workflows.length !== 0) { - return workflows[0].id; - } - } - // CircleCI server may be stale. Wait a sec and try again. - await sleep(1000); - } - return null; -} - -async function pollUntilWorkflowFinishes(workflowID) { - while (true) { - const workflowResponse = await fetch( - `https://circleci.com/api/v2/workflow/${workflowID}` - ); - const workflow = await workflowResponse.json(); - switch (workflow.status) { - case 'running': - // Workflow still running. Wait a bit then check again. - await sleep(2000); - continue; - case 'success': - // Publish succeeded! Continue. - return; - case 'not_run': - case 'failed': - case 'error': - case 'failing': - case 'on_hold': - case 'canceled': - case 'unauthorized': - default: - console.error( - theme.error( - `Failed to publish. Workflow exited with status: ${workflow.status}` - ) - ); - console.error( - `Visit https://app.circleci.com/pipelines/workflows/${workflowID} for details.` - ); - process.exit(1); - break; - } - } -} - -async function main() { - const headCommitResponse = await fetch( - 'https://api.github.com/repos/facebook/react/commits/main' - ); - const headCommitJSON = await headCommitResponse.json(); - const headCommitSha = headCommitJSON.sha; - - const pipelineResponse = await fetch( - 'https://circleci.com/api/v2/project/github/facebook/react/pipeline', - { - method: 'post', - body: JSON.stringify({ - parameters: { - prerelease_commit_sha: headCommitSha, - }, - }), - headers: { - 'Circle-Token': CIRCLE_TOKEN, - 'Content-Type': 'application/json', - }, - } - ); - - if (!pipelineResponse.ok) { - console.error( - theme.error( - `Failed to access CircleCI. Responded with status: ${pipelineResponse.status}` - ) - ); - process.exit(1); - } - - const pipelineJSON = await pipelineResponse.json(); - const pipelineID = pipelineJSON.id; - - const workflowID = await logPromise( - getPublishWorkflowID(pipelineID), - theme`{header Creating CI workflow}`, - 2 * 1000 // Estimated time: 2 seconds, - ); - - if (workflowID === null) { - console.warn( - theme.yellow( - 'Created a CI pipeline to publish the packages, but the script timed ' + - "out when requesting the associated workflow ID. It's still " + - 'possible the workflow was created.\n\n' + - 'Visit ' + - 'https://app.circleci.com/pipelines/github/facebook/react?branch=main ' + - 'for a list of the latest workflows.' - ) - ); - process.exit(1); - } - - await logPromise( - pollUntilWorkflowFinishes(workflowID), - theme`{header Publishing in CI workflow}: https://app.circleci.com/pipelines/workflows/${workflowID}`, - 2 * 60 * 1000 // Estimated time: 2 minutes, - ); -} - -main().catch(error => { - console.error(theme.error('Failed to trigger publish workflow.')); - console.error(error.message); - process.exit(1); -}); diff --git a/scripts/release/publish.js b/scripts/release/publish.js index f9e450b55920..63308da03078 100755 --- a/scripts/release/publish.js +++ b/scripts/release/publish.js @@ -3,19 +3,12 @@ 'use strict'; const {join} = require('path'); -const {readJsonSync} = require('fs-extra'); -const clear = require('clear'); const {getPublicPackages, handleError} = require('./utils'); const theme = require('./theme'); -const checkNPMPermissions = require('./publish-commands/check-npm-permissions'); -const confirmSkippedPackages = require('./publish-commands/confirm-skipped-packages'); const confirmVersionAndTags = require('./publish-commands/confirm-version-and-tags'); const parseParams = require('./publish-commands/parse-params'); -const printFollowUpInstructions = require('./publish-commands/print-follow-up-instructions'); -const promptForOTP = require('./publish-commands/prompt-for-otp'); const publishToNPM = require('./publish-commands/publish-to-npm'); -const updateStableVersionNumbers = require('./publish-commands/update-stable-version-numbers'); const validateTags = require('./publish-commands/validate-tags'); const validateSkipPackages = require('./publish-commands/validate-skip-packages'); @@ -23,10 +16,8 @@ const run = async () => { try { const params = parseParams(); - const version = - params.publishVersion ?? - readJsonSync('./build/node_modules/react/package.json').version; - const isExperimental = version.includes('experimental'); + // Publishing experimental versions as stable is forbidden + const isExperimental = false; params.cwd = join(__dirname, '..', '..'); params.packages = await getPublicPackages(isExperimental); @@ -59,53 +50,31 @@ const run = async () => { }); await validateTags(params); - await confirmSkippedPackages(params); await confirmVersionAndTags(params); await validateSkipPackages(params); - await checkNPMPermissions(params); + // npm ownership / whoami no longer applies — OIDC trusted publishing + // verifies the publisher via the registry's per-package config, not via a + // logged-in npm user. const packageNames = params.packages; - if (params.ci) { - let failed = false; - for (let i = 0; i < packageNames.length; i++) { - try { - const packageName = packageNames[i]; - await publishToNPM(params, packageName, null); - } catch (error) { - failed = true; - console.error(error.message); - console.log(); - console.log( - theme.error`Publish failed. Will attempt to publish remaining packages.` - ); - } - } - if (failed) { - console.log(theme.error`One or more packages failed to publish.`); - process.exit(1); - } - } else { - clear(); - let otp = await promptForOTP(params); - for (let i = 0; i < packageNames.length; ) { + let failed = false; + for (let i = 0; i < packageNames.length; i++) { + try { const packageName = packageNames[i]; - try { - await publishToNPM(params, packageName, otp); - i++; - } catch (error) { - console.error(error.message); - console.log(); - console.log( - theme.error`Publish failed. Enter a fresh otp code to retry.` - ); - otp = await promptForOTP(params); - // Try publishing package again - continue; - } + await publishToNPM(params, packageName, null); + } catch (error) { + failed = true; + console.error(error.message); + console.log(); + console.log( + theme.error`Publish failed. Will attempt to publish remaining packages.` + ); } - await updateStableVersionNumbers(params); - await printFollowUpInstructions(params); + } + if (failed) { + console.log(theme.error`One or more packages failed to publish.`); + process.exit(1); } } catch (error) { handleError(error); diff --git a/scripts/release/shared-commands/download-build-artifacts.js b/scripts/release/shared-commands/download-build-artifacts.js index 79d4ac6c6ee2..9756aba897d8 100644 --- a/scripts/release/shared-commands/download-build-artifacts.js +++ b/scripts/release/shared-commands/download-build-artifacts.js @@ -14,8 +14,7 @@ if (process.env.GH_TOKEN == null) { process.exit(1); } -const OWNER = 'facebook'; -const REPO = 'react'; +const REPO = process.env.GITHUB_REPOSITORY || 'facebook/react'; const WORKFLOW_ID = 'runtime_build_and_test.yml'; const GITHUB_HEADERS = ` -H "Accept: application/vnd.github+json" \ @@ -49,7 +48,7 @@ function getWorkflowId() { async function getWorkflowRun(commit) { const res = await exec( - `curl -L ${GITHUB_HEADERS} https://api.github.com/repos/${OWNER}/${REPO}/actions/workflows/${getWorkflowId()}/runs?head_sha=${commit}` + `curl -L ${GITHUB_HEADERS} https://api.github.com/repos/${REPO}/actions/workflows/${getWorkflowId()}/runs?head_sha=${commit}` ); const json = JSON.parse(res.stdout); @@ -67,7 +66,7 @@ async function getWorkflowRun(commit) { async function getArtifact(workflowRunId, artifactName) { const res = await exec( - `curl -L ${GITHUB_HEADERS} https://api.github.com/repos/${OWNER}/${REPO}/actions/runs/${workflowRunId}/artifacts?per_page=100&name=${artifactName}` + `curl -L ${GITHUB_HEADERS} https://api.github.com/repos/${REPO}/actions/runs/${workflowRunId}/artifacts?per_page=100&name=${artifactName}` ); const json = JSON.parse(res.stdout); @@ -103,7 +102,7 @@ async function processArtifact(artifact, opts) { // Use https://cli.github.com/manual/gh_attestation_verify to verify artifact if (executableIsAvailable('gh')) { await exec( - `gh attestation verify artifacts_combined.zip --repo=${OWNER}/${REPO}`, + `gh attestation verify artifacts_combined.zip --repo=${REPO}`, { cwd: tmpDir, }