diff --git a/.buildkite/jobs/pipeline.publish.yml b/.buildkite/jobs/pipeline.publish.yml deleted file mode 100644 index 701b55febad..00000000000 --- a/.buildkite/jobs/pipeline.publish.yml +++ /dev/null @@ -1,16 +0,0 @@ - - wait - - label: ":package: Publish" - env: - JAVA_HOME: /opt/openjdk/jdk-17.0.9.jdk/Contents/Home/ - if: "build.pull_request.id == null" - command: - - "nvm install" - - "./scripts/ci.sh" - - "yarn run release" - timeout_in_minutes: 60 - retry: - automatic: - - exit_status: [1, -1] - limit: 2 - - diff --git a/.buildkite/jobs/pipeline.release.yml b/.buildkite/jobs/pipeline.release.yml deleted file mode 100644 index ce8978d8fdb..00000000000 --- a/.buildkite/jobs/pipeline.release.yml +++ /dev/null @@ -1,20 +0,0 @@ - - block: ":rocket: Release!" - prompt: "Fill out the details for release" - if: 'build.message =~ /^release\$/i' - fields: - - text: "VERSION" - key: "version" - - text: "NPM_TAG" - key: "npm-tag" - default: 'null' - required: false - - text: "BUILD_DOCUMENTATION_VERSION" - key: "build-documentation-version" - default: 'null' - hint: 'Leave NULL if no version is specified' - - text: "REMOVE_DOCUMENTATION_VERSION" - key: "remove-documentation-version" - default: 'null' - hint: 'Leave NULL if no version is specified' - - diff --git a/.buildkite/pipeline.sh b/.buildkite/pipeline.sh index fb5185575f7..10b5c4872aa 100755 --- a/.buildkite/pipeline.sh +++ b/.buildkite/pipeline.sh @@ -2,11 +2,9 @@ echo "steps:" -cat .buildkite/jobs/pipeline.release.yml cat .buildkite/jobs/pipeline.android_rn_78.yml cat .buildkite/jobs/pipeline.android_rn_84.yml cat .buildkite/jobs/pipeline.android_rn_85.yml cat .buildkite/jobs/pipeline.ios_rn_78.yml cat .buildkite/jobs/pipeline.ios_rn_84.yml cat .buildkite/jobs/pipeline.ios_rn_85.yml -cat .buildkite/jobs/pipeline.publish.yml diff --git a/.claude/skills/rn-version-upgrade.md b/.claude/skills/rn-version-upgrade.md index 77521d86695..74b1616c369 100644 --- a/.claude/skills/rn-version-upgrade.md +++ b/.claude/skills/rn-version-upgrade.md @@ -63,7 +63,7 @@ Also check the full peer dependency tree: `npm info react-native@ peerD ## Phase 2: CI Infrastructure Setup -### 2a. Create Buildkite Pipeline Jobs +### 2a. Maintain Buildkite CI Jobs Copy an existing job file and update the version: @@ -88,7 +88,7 @@ cat .buildkite/jobs/pipeline.android_rn_84.yml cat .buildkite/jobs/pipeline.ios_rn_84.yml ``` -Insert them in version order, before `pipeline.publish.yml`. +Insert them in version order alongside the other platform jobs. Release publishing is dispatched separately through the GitHub Actions release workflow. ### 2c. Optionally Drop Old Versions diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index c3a61c142f3..a5bdcfb287a 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -22,7 +22,8 @@ jobs: run: | cd website/ npm i -g npq@3.14.0 - npq install + npq --dry-run + npm ci --legacy-peer-deps npm run build gh-release: if: github.event_name != 'pull_request' @@ -54,6 +55,7 @@ jobs: git config --global user.name "yogevbd" cd website/ npm i -g npq@3.14.0 - npq install + npq --dry-run + npm ci --legacy-peer-deps npm run build npx docusaurus deploy diff --git a/.github/workflows/release-checks.yml b/.github/workflows/release-checks.yml new file mode 100644 index 00000000000..b23669ac365 --- /dev/null +++ b/.github/workflows/release-checks.yml @@ -0,0 +1,34 @@ +name: Release checks + +on: + pull_request: + branches: [master] + push: + branches: [master] + +permissions: + contents: read + +jobs: + package: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 + with: + persist-credentials: false + - uses: actions/setup-node@65d868f8d4d85d7d4abb7de0875cde3fcc8798f5 + with: + node-version: '24.19.0' + package-manager-cache: false + - name: Test release authorization and recovery + run: node --test scripts/release/*.test.cjs + - name: Install, build, and test the npm package on Linux + run: | + node .yarn/releases/yarn-4.12.0.cjs install --immutable + node .yarn/releases/yarn-4.12.0.cjs prepare + node .yarn/releases/yarn-4.12.0.cjs test-js + - name: Verify package contents and entry points + env: + RELEASE_DIR: ${{ runner.temp }}/rnn-package + run: node scripts/release/index.cjs pack-check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000000..6b49c713d16 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,218 @@ +name: Release +run-name: ${{ inputs.operation }} RNN ${{ inputs.version || inputs.kind }} by @${{ github.actor }} + +on: + workflow_dispatch: + inputs: + operation: + description: 'Dry run, publish, or resume an interrupted publishing run' + type: choice + options: [dry-run, publish, resume] + default: dry-run + kind: + description: 'Release or on-demand snapshot (ignored for resume)' + type: choice + options: [release, snapshot] + default: release + version: + description: 'Exact semver for release; leave empty for snapshots and resume' + type: string + npm_tag: + description: 'latest for stable, next for prerelease, snapshot for snapshots' + type: choice + options: [latest, next, snapshot] + default: latest + documentation_version: + description: 'Optional documentation version to create (x.y.z)' + type: string + remove_documentation_version: + description: 'Optional existing documentation version to replace/remove' + type: string + source_run_id: + description: 'Original publish run ID, required only for resume' + type: string + +permissions: {} + +concurrency: + group: rnn-npm-release + cancel-in-progress: false + +env: + RELEASE_OPERATION: ${{ inputs.operation }} + RELEASE_KIND: ${{ inputs.kind }} + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_NPM_TAG: ${{ inputs.npm_tag }} + RELEASE_DOCUMENTATION_VERSION: ${{ inputs.documentation_version }} + RELEASE_REMOVE_DOCUMENTATION_VERSION: ${{ inputs.remove_documentation_version }} + RELEASE_SOURCE_RUN_ID: ${{ inputs.source_run_id }} + RELEASE_PUBLISH_ENABLED: ${{ vars.RELEASE_PUBLISH_ENABLED }} + RELEASE_DIR: ${{ github.workspace }}/../release-artifacts + GH_TOKEN: ${{ github.token }} + +jobs: + prepare: + if: github.repository == 'wix/react-native-navigation' && github.ref == 'refs/heads/master' + runs-on: ubuntu-24.04 + timeout-minutes: 45 + permissions: + contents: read + actions: read + statuses: read + pull-requests: read + outputs: + artifact_id: ${{ steps.upload.outputs.artifact-id || steps.resume.outputs.artifact_id }} + source_run_id: ${{ steps.resume.outputs.source_run_id || github.run_id }} + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@65d868f8d4d85d7d4abb7de0875cde3fcc8798f5 + with: + node-version: '24.19.0' + package-manager-cache: false + - name: Check admin authorization + run: node scripts/release/index.cjs authorize + - name: Locate original release artifact + if: inputs.operation == 'resume' + id: resume + run: node scripts/release/index.cjs resume-info + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + if: inputs.operation == 'resume' + with: + artifact-ids: ${{ steps.resume.outputs.artifact_id }} + run-id: ${{ steps.resume.outputs.source_run_id }} + github-token: ${{ github.token }} + path: ${{ env.RELEASE_DIR }} + merge-multiple: true + - name: Verify recovery identity and native CI + if: inputs.operation == 'resume' + run: node scripts/release/index.cjs verify-resume + - name: Plan exact version and check native CI + if: inputs.operation != 'resume' + id: plan + run: node scripts/release/index.cjs plan + - name: Install locked dependencies + if: inputs.operation != 'resume' + run: node .yarn/releases/yarn-4.12.0.cjs install --immutable + - name: Install locked documentation dependencies + if: inputs.operation != 'resume' && steps.plan.outputs.documentation_version != '' + working-directory: website + run: npm ci --legacy-peer-deps + - name: Set version and generate optional documentation + if: inputs.operation != 'resume' + run: node scripts/release/index.cjs set-version + - name: Build and test + if: inputs.operation != 'resume' + run: | + node .yarn/releases/yarn-4.12.0.cjs prepare + node .yarn/releases/yarn-4.12.0.cjs test-js + node --test scripts/release/*.test.cjs + - name: Validate versioned documentation + if: inputs.operation != 'resume' && steps.plan.outputs.documentation_version != '' + working-directory: website + run: npm run build + - name: Pack and record immutable release inputs + if: inputs.operation != 'resume' + run: node scripts/release/index.cjs prepare-artifact + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + if: inputs.operation != 'resume' + id: upload + with: + name: release-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ env.RELEASE_DIR }}/ + if-no-files-found: error + retention-days: 90 + + record: + needs: prepare + if: inputs.operation != 'dry-run' + environment: npm-release + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + actions: read + statuses: read + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@65d868f8d4d85d7d4abb7de0875cde3fcc8798f5 + with: + node-version: '24.19.0' + package-manager-cache: false + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + artifact-ids: ${{ needs.prepare.outputs.artifact_id }} + run-id: ${{ needs.prepare.outputs.source_run_id }} + github-token: ${{ github.token }} + path: ${{ env.RELEASE_DIR }} + merge-multiple: true + - name: Persist the release commit + run: node scripts/release/index.cjs record + + publish: + needs: [prepare, record] + environment: npm-release + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + actions: read + statuses: read + id-token: write + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@65d868f8d4d85d7d4abb7de0875cde3fcc8798f5 + with: + node-version: '24.19.0' + package-manager-cache: false + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + artifact-ids: ${{ needs.prepare.outputs.artifact_id }} + run-id: ${{ needs.prepare.outputs.source_run_id }} + github-token: ${{ github.token }} + path: ${{ env.RELEASE_DIR }} + merge-multiple: true + - name: Publish verified tarball using npm trusted publishing + run: node scripts/release/index.cjs publish + + finalize: + needs: [prepare, publish] + environment: npm-release + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + actions: read + statuses: read + pull-requests: write + issues: write + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@65d868f8d4d85d7d4abb7de0875cde3fcc8798f5 + with: + node-version: '24.19.0' + package-manager-cache: false + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + artifact-ids: ${{ needs.prepare.outputs.artifact_id }} + run-id: ${{ needs.prepare.outputs.source_run_id }} + github-token: ${{ github.token }} + path: ${{ env.RELEASE_DIR }} + merge-multiple: true + - name: Verify npm publication and finish GitHub release + run: node scripts/release/index.cjs finalize diff --git a/.gitignore b/.gitignore index ca5349d92b7..a5c1f31219a 100644 --- a/.gitignore +++ b/.gitignore @@ -233,7 +233,6 @@ playground/android/app/.settings/ AndroidE2E/.settings/ website/yarn.lock playground/ios/Podfile.lock -website/package-lock.json # detox artifacts artifacts/ diff --git a/.grenrc.js b/.grenrc.js deleted file mode 100644 index aa0ddebdf8a..00000000000 --- a/.grenrc.js +++ /dev/null @@ -1,67 +0,0 @@ -module.exports = { - template: { - commit: ({message, url, author, name}) => - `- [${message}](${url}) - ${author ? `@${author}` : name}`, - issue: ({name, labels, text, url, user_login, user_url}) => `${processLabels(labels)}${name} [${text}](${url}) by [${user_login}](${user_url})`, - label: '[**{{label}}**]', - noLabel: 'closed', - group: '\n## {{heading}}\n', - changelogTitle: '# Changelog\n\n', - release: '## {{release}} ({{date}})\n{{body}}', - releaseSeparator: '\n---\n\n', - }, - groupBy: { - 'Enhancements:': ['type: accepted/enhancement', 'internal'], - 'Fixed:': ['type: accepted/bug'], - Features: ['feature'], - }, - groupPostProcessor: (groupContent) => { - const lines = groupContent.split('\n'); - const iosIssues = []; - const androidIssues = []; - const otherIssues = []; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - if (line.includes('## ') || line === '') continue; - else if (line.includes('[iOS] ')) iosIssues.push(line.replace('[iOS] ', '')); - else if (line.includes('[Android] ')) androidIssues.push(line.replace('[Android] ', '')); - else otherIssues.push(line); - } - - const groupHeader = groupContent.substr(0, groupContent.indexOf(':\n')); - return `${groupHeader}${generateSection(undefined, otherIssues)}${generateSection('iOS', iosIssues)}${generateSection('Android', androidIssues)}`; - }, - ignoreIssuesWith: ['skip-changelog'], - ignoreTagsWith: ['snapshot', 'v1', 'v2', '0\..\..', '1\..\..', '2\..\..', '3\..\..', '4\..\..', '5\..\..', '6\..\..'], - dataSource: 'prs', - changelogFilename: 'CHANGELOG.gren.md', - override: true, - generate: true, - tags: 'all' -}; - -function generateSection(name, issues) { - if (!issues.length) return ''; - let section = `\n${name ? `### ${name}\n` : ''}`; - - issues.forEach(issue => { - section += `- ${issue}\n`; - }); - - return `${section}\n`; -} - -function processLabels(labels) { - const includesIOS = labels.includes('**platform: iOS**'); - const includesAndroid = labels.includes('**platform: Android**'); - if (includesIOS && includesAndroid) { - return ''; - } else if (includesIOS) { - return '[iOS] ' - } else if (includesAndroid) { - return '[Android] ' - } - - return ''; -} diff --git a/.npmignore b/.npmignore index 8c5a69fa9f5..21f6c9e1240 100644 --- a/.npmignore +++ b/.npmignore @@ -260,6 +260,5 @@ jsconfig.json babel.config.js .watchmanconfig .logo.png -.grenrc.js .clang-format .prettierrc diff --git a/README.md b/README.md index 04fbb42b1c6..eeb958b30da 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ NPM downloads NPM latest version NPM snapshot version - NPM snapshot version + GitHub Release workflow

@@ -35,3 +35,8 @@ Apps using React Native Navigation may target iOS 11 and Android 5.0 (API 21). Y # Installation As `react-native-navigation` is a native navigation library - integrating it into your app will require editing native files. Follow the installation guides in the [documentation](https://wix.github.io/react-native-navigation/). + +## Releases + +Repository administrators: see the [release runbook](docs/RELEASING.md) for manual releases, +npm trusted publishing setup, and recovery. diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 00000000000..4fdc6c5bf15 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,177 @@ +# Releasing React Native Navigation + +The [Release workflow](https://github.com/wix/react-native-navigation/actions/workflows/release.yml) +is the only supported npm publisher. Repository admins start it manually on `master`. +Both regular releases and snapshots use npm trusted publishing with GitHub OIDC. +Buildkite runs native tests; it no longer versions or publishes packages. Its expired npm +credentials must not be renewed as a fallback. + +## One-time configuration + +A repository admin and an npm package administrator must complete these steps before +enabling publication. Creating or merging the workflow does not configure npm trust. + +1. Create the GitHub environment `npm-release`. Under deployment branches and tags, + select only the **branch** `master`. Protect `master` and review changes to release + workflows and helpers. Required environment reviewers are optional; enabling them + introduces approvals for the record, publish, and finalize jobs. +2. In the npm settings for `react-native-navigation`, add a GitHub Actions trusted publisher: + + | Field | Value | + | ----------------- | ------------------------- | + | Organization | `wix` | + | Repository | `react-native-navigation` | + | Workflow filename | `release.yml` | + | Environment | `npm-release` | + | Allowed action | Direct `npm publish` | + + Explicitly allow direct publication: a stage-only configuration cannot complete this + workflow. Standalone `npm dist-tag` permission is not needed. See + [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/). + +3. Enable GitHub Actions for this repository. Check Wix's Actions policy permits the pinned checkout, setup-node, upload-artifact, + and download-artifact actions. Allow workflow-created pull requests. Ensure existing + repository rules permit version branches and release tags without a bypass. +4. Check the GitHub commit status `buildkite/react-native-navigation` represents all + Android/iOS lanes in `.buildkite/pipeline.sh`. It must finish successfully on the + exact `master` commit selected by the release run. The status creator must match + the Buildkite app ID/login pinned in `scripts/release/rnn.cjs`. A failed, absent, or pending status + blocks real publication, including snapshots. A dry run reports it without publishing. +5. Verify a dry run and inspect its artifact. Then set the repository Actions variable + `RELEASE_PUBLISH_ENABLED` to `true`. Without it, publish and resume fail closed. +6. Run an intentional snapshot publication and verify the npm version, integrity, + provenance, and `snapshot` dist-tag. Confirm that `latest` did not change. +7. Remove obsolete release-only npm secret injection and restrict traditional token + publishing in npm's package settings. Do not renew expired tokens. A shared credential + must not be revoked until its other consumers have been identified. + +No npm write token is needed in GitHub. `GITHUB_TOKEN` performs repository bookkeeping; +only the publish job has `id-token: write`. Publishing runs on a GitHub-hosted Ubuntu +runner. The workflow uses Node 24.19.0, including npm 11.17.0; update this tested pair +together. Older npm releases can execute `prepare` during `npm pack --ignore-scripts`. + +## Start a release + +Open **Actions → Release → Run workflow**, select `master`, and supply: + +| Input | Meaning | +| ------------------------------ | ------------------------------------------------------------------------------ | +| `operation` | `dry-run` (default), `publish`, or `resume` | +| `kind` | `release` or `snapshot` | +| `version` | Exact semver for releases; empty for snapshots | +| `npm_tag` | `latest` for stable versions, `next` for prereleases, `snapshot` for snapshots | +| `documentation_version` | Optional numeric `x.y.z` documentation snapshot | +| `remove_documentation_version` | Optional existing version to remove while generating its replacement | +| `source_run_id` | Original publishing run ID, for resume only | + +Examples: + +- Stable: `kind=release`, `version=8.9.1`, `npm_tag=latest`. +- Prerelease: `kind=release`, `version=8.9.0-rc.1`, `npm_tag=next`. +- Snapshot: `kind=snapshot`, leave version empty, `npm_tag=snapshot`. + +The selected version must be newer than npm `latest`. Build metadata such as `+build.1` +is intentionally unsupported. Snapshots use `-snapshot.gha.`, with the +base derived from the greater of the source package version and npm `latest`. The +run attempt is not part of the version. Snapshots do not create tags, GitHub releases, +version PRs, or documentation changes. + +Users with write access may see GitHub's Run workflow button, but both the original +actor and the user rerunning/resuming the workflow must have repository-admin permission. +The workflow rechecks authorization before every job that changes external state. + +## What the workflow does + +1. Records the exact source SHA, chosen version/channel, and native CI result. +2. Installs the locked Yarn dependencies, changes release metadata, builds JS and + declarations with Bob, and runs JavaScript and release-helper tests on Linux. +3. Optionally installs the locked website dependencies, generates the requested docs + version, and builds the site. Removal requires an existing numeric version; replacing + an existing version requires explicitly selecting that same version for removal. +4. Creates a local release commit containing only the version and optional versioned + docs, then packs the already built package. Package contents and exported paths are + checked. Android/iOS sources ship in the package; this job does not compile native apps. +5. Uploads a versioned manifest, the tarball, release notes, and a Git bundle as + `release--`. A dry run ends here with no external release mutations. +6. For real releases, persists `ci/update-version-`, then publishes the verified + tarball using OIDC. The publisher does not install project dependencies or rebuild it. +7. Verifies npm integrity, tags the source commit on `master` with the bare version (for example + `8.9.1`), creates a GitHub release, and opens a version/documentation PR with the + `release` label. An existing PR template is included with its checkboxes left for review. + +Release notes include merged PRs from the preceding reachable stable tag through the +source commit. They preserve feature/enhancement/fix categories, platform groups, and +`skip-changelog` exclusions. No second `gren` invocation or timed retry is involved. + +Review and merge the version PR after publication. Documentation deploys through the +existing documentation workflow after that merge. Events created with `GITHUB_TOKEN` do not +start Actions workflows, so Release checks will not run on the version PR; Buildkite still +builds it through its webhook. Inspect the PR's checks before merging. The release workflow +does not invoke engine notification. + +## Resume a partial release + +Use **Re-run failed jobs** when the original run still has its preparation outputs, or +dispatch `operation=resume` with `source_run_id` set to the original **publish** run. +Other release inputs are ignored in resume mode. Do not enter a different version to +recover from a GitHub or network error. + +Recovery validates the upstream workflow identity, source SHA, native CI, artifact +integrity, release commit, and package metadata. It downloads the saved artifacts and +does not rebuild against the current branch. If the version is still absent from npm and +`master` has advanced, re-run the original publishing run instead of dispatching a new +resume run: npm provenance must identify the source commit that built the package. A new +resume dispatch may finish GitHub bookkeeping for an already published matching tarball. + +| State | Result | +| ------------------------------------------------- | -------------------------------------------------------------------------- | +| Version absent from npm | Publish the original tarball, provided its channel would not move backward | +| Same version and same integrity already published | Skip publication and finish GitHub steps | +| Same version with different integrity | Stop; investigate the conflicting publication | +| Registry failure or lost response | Fail without choosing another version; resume when healthy | +| Existing branch/tag/PR matches | Reuse it | +| Existing branch/tag/PR conflicts | Stop; never force-push or move a tag | +| Dry-run artifact selected | Reject; start a fresh publish run | +| Artifact expired/deleted | Stop automatic recovery | + +Artifacts request 90-day retention, subject to organization limits. Retain artifacts until +the version PR is merged and the release is verified. After artifact loss, an admin must +investigate the existing registry version and persisted release branch manually; do not +reconstruct and publish different bytes under the same version or automatically fall back +to the old pipeline. npm and GitHub do not provide a cross-service atomic transaction. + +The concurrency group serializes all release attempts. GitHub may replace a pending run +when another dispatch is queued; it does not cancel the running publisher. Check each run's +state rather than assuming every click is queued indefinitely. + +## Validate changes to the release tooling + +With Node 24.19.0 and npm 11.17.0: + +```sh +node --test scripts/release/*.test.cjs +node .yarn/releases/yarn-4.12.0.cjs install --immutable +node .yarn/releases/yarn-4.12.0.cjs prepare +node .yarn/releases/yarn-4.12.0.cjs test-js +RELEASE_DIR=/tmp/rnn-package node scripts/release/index.cjs pack-check +``` + +`pack-check` only prepares and inspects a local tarball. The read-only Release checks +workflow runs the tests and clean Linux package check on PRs. A real OIDC publication +still needs the default-branch workflow and the npm/GitHub configuration above. + +## Removed release paths and retained consumers + +The Buildkite release form, publishing job, legacy `scripts/release.js`, `gren` config, +and root/playground `github-release-notes` dependencies have been removed. Automatic +Buildkite snapshots are replaced by manual snapshots. Native Buildkite testing remains. + +Outside the repository, remove obsolete release schedules/hooks and release-only +`NPM_TOKEN`/`NPM_EMAIL` injection. Inspect any old Jenkins configuration referenced by +historical setup; the old README badge alone does not prove an active publisher exists. + +Keep the tracked registry-only `.npmrc`, documentation deployment's `GH_PAGES_DEPLOY`, +and any Git credential still used by `scripts/test-snapshot.js` to record reference images. +Do not delete shared secrets merely because the old publisher also used them. + +For the next repository, use [the migration checklist](release-migration-checklist.md). diff --git a/docs/release-migration-checklist.md b/docs/release-migration-checklist.md new file mode 100644 index 00000000000..a1e35ffd5ad --- /dev/null +++ b/docs/release-migration-checklist.md @@ -0,0 +1,69 @@ +# Migrating another repository to trusted publishing + +RNN establishes a release contract: admin dispatch, explicit versions, immutable build +artifacts, OIDC publication, and recovery of the same package bytes. Copy the contract +before extracting shared code. Repository-specific build and package policy lives in +`scripts/release/rnn.cjs`; the current workflow and manifest validator intentionally accept +only RNN. Another repository must implement and test its own adapter and authorization. + +## Inventory before changing a publisher + +- Read the target repository's instructions and release tests. +- List every publishable package, its source directory, version authority, dist-tags, + Git tag convention, and dependency relationship to the other packages. +- Trace release-trigger inputs, lifecycle hooks, generated artifacts, documentation, + release notes, PRs, and any native packaging commands. +- Identify native CI evidence by exact commit. Distinguish the tools needed to produce + the npm package from tools needed only for testing. +- Inventory CI schedules, webhooks, release credentials, and other consumers of shared + credentials. A release script deletion alone does not remove an external trigger. + +## Implement the repository adapter + +- Keep a stable repository-local `release.yml` entry point and protected environment. +- Configure allowed branches, admin authorization, packages, runners, build commands, + expected tarball contents, version/dependency rewrites, and tag naming. +- Resolve the complete package/version plan before the first publish. Use an ordered + package list and store each package's integrity in the manifest. +- Build without npm publishing credentials. Native preparation can use separate runner + jobs; the OIDC publisher must run on a supported GitHub-hosted runner. +- Preserve artifacts and verify their original workflow/source identity during recovery. +- Publish each package only if absent. If already present, require matching integrity. + Never bump all versions after a partial publish. Multi-package publication is not atomic. +- Register npm trust separately for every package. If introducing reusable workflows, + check npm's caller-workflow identity rules and grant the required OIDC permissions at + the caller/callee boundaries. Pin shared implementations to reviewed commit SHAs. + +## Known differences from RNN + +| Repository | Migration requirement | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| react-native-navigation | One package, Bob JS/types build, native sources, bare version Git tags | +| react-native-ui-lib | Separate `uilib-native` and `react-native-ui-lib` versions; native package first when selected; rewrite workspace dependencies; preserve package-qualified tags | +| Detox | Discover and validate publishable workspaces; preserve synchronized versions; trace Android and iOS native artifact packaging before choosing runners | + +UI-lib's starting points are `scripts/release/release.js`, `releaseUtils.js`, and the +existing release-script tests. Its dependency rewrite must use the planned or verified +published native version, including when only one package needs publication. + +Detox's starting points are `scripts/ci.release.js`, `scripts/utils/publishNewVersion.js`, +`scripts/ci.android-release.js`, `scripts/ci.ios-release.sh`, and the Buildkite release +pipelines. Android packaging invokes the SDK and Gradle. Resolve the current iOS packaging +entry point before replacing its artifact download flow. Do not assume RNN's Linux-only +package preparation applies to Detox. + +## Clean up and verify + +- Delete obsolete publishing jobs, forms, entry points, credential-writing helpers, + retry-by-version-increment code, dead dependencies, and stale instructions. +- Preserve native tests, useful history, documentation deployment, and shared credentials + with remaining consumers. +- Test dry-run non-mutation, unauthorized dispatch/rerun, version/channel validation, + source CI gating, package completeness, and lost-response/partial-publication recovery. +- Verify clean runner builds, then a deliberate prerelease/snapshot using real OIDC. +- Document one-time configuration, remaining infrastructure cleanup, and artifact-loss + recovery. Expired token-based publishers are not an automatic fallback. + +Extract the common publisher, authorization, and artifact-state helpers only after a +second adopter confirms their interface. Build commands, package graphs, documentation, +and version conventions should remain repository-specific. diff --git a/package.json b/package.json index 5624cb564ca..3ffef8a151e 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "test-e2e-android-ci": "yarn workspace react-native-navigation-playground test-e2e-android-ci", "test-e2e-ios-ci": "yarn workspace react-native-navigation-playground test-e2e-ios-ci", "test-all": "node ./scripts/test-all", - "release": "node ./scripts/release", + "test-release": "node --test scripts/release/*.test.cjs", "lint": "eslint --ext .js,.jsx,.ts,.tsx ./" }, "peerDependencies": { @@ -124,7 +124,6 @@ "eslint-formatter-codeframe": "^7.32.1", "eslint-plugin-jest": "^28.11.0", "eslint-plugin-prettier": "3.1.4", - "github-release-notes": "https://github.com/yogevbd/github-release-notes/tarball/e601b3dba72dcd6cba323c1286ea6dd0c0110b58", "identity-obj-proxy": "3.0.0", "jest": "^29.6.3", "lint-staged": "10.2.11", @@ -183,4 +182,4 @@ ] ] } -} \ No newline at end of file +} diff --git a/playground/package.json b/playground/package.json index a4dac6abbad..b923d55b724 100644 --- a/playground/package.json +++ b/playground/package.json @@ -62,7 +62,6 @@ "eslint-formatter-codeframe": "^7.32.1", "eslint-plugin-jest": "^28.11.0", "eslint-plugin-prettier": "3.1.4", - "github-release-notes": "https://github.com/yogevbd/github-release-notes/tarball/e601b3dba72dcd6cba323c1286ea6dd0c0110b58", "identity-obj-proxy": "3.0.0", "jest": "^29.6.3", "lint-staged": "10.2.11", diff --git a/scripts/documentation.js b/scripts/documentation.js index 231db403248..7bc423f7676 100644 --- a/scripts/documentation.js +++ b/scripts/documentation.js @@ -1,42 +1,70 @@ -const shellUtils = require('shell-utils'); -const exec = shellUtils.exec; +const childProcess = require('child_process'); const fs = require('fs'); -const includes = require('lodash/includes'); +const path = require('path'); -const docsPath = `${process.cwd()}/website`; -const docsVersionsJsonPath = `${docsPath}/versions.json`; +const docsPath = path.resolve(__dirname, '../website'); + +function release(version, removeVersion, options = {}) { + const targetDocsPath = options.docsPath || docsPath; + const versionsPath = path.join(targetDocsPath, 'versions.json'); + + _validateVersion(version, 'version'); + if (removeVersion !== undefined && removeVersion !== '') { + _validateVersion(removeVersion, 'removeVersion'); + } + + const versions = _readDocsVersionsJson(versionsPath); + const shouldRemoveVersion = removeVersion !== undefined && removeVersion !== ''; + + if (shouldRemoveVersion && !versions.includes(removeVersion)) { + throw new Error(`Documentation version does not exist: ${removeVersion}`); + } + if (versions.includes(version) && (!shouldRemoveVersion || removeVersion !== version)) { + throw new Error(`Documentation version already exists: ${version}`); + } + + if (shouldRemoveVersion) { + _removeDocsVersion(targetDocsPath, versionsPath, versions, removeVersion); + } -function release(version, removeVersion) { console.log(`Building documentation version: ${version}`); - if (_versionExists(removeVersion)) _removeDocsVersion(removeVersion); - const originalDir = process.cwd(); - process.chdir(docsPath); - exec.execSync(`npm install`); - exec.execSync(`npm run docusaurus docs:version ${version}`); - exec.execSync(`git add ../website`); - process.chdir(originalDir); + childProcess.execFileSync('npm', ['run', 'docusaurus', '--', 'docs:version', version], { + cwd: targetDocsPath, + stdio: 'inherit', + }); } -function _removeDocsVersion(version) { - console.log(`Removing documentation version: ${version}`); - exec.execSync(`rm -rf ${docsPath}/versioned_docs/version-${version}`); - exec.execSync(`rm -f ${docsPath}/versioned_sidebars/version-${version}-sidebars.json`); - const docsVersionsJson = _readDocsVersionsJson(); - docsVersionsJson.splice(docsVersionsJson.indexOf(version), 1); - _writeDocsVersionsJson(docsVersionsJson); +function _validateVersion(version, name) { + if (typeof version !== 'string' || !/^\d+(?:\.\d+)+$/.test(version)) { + throw new Error(`${name} must be a dotted numeric version`); + } } -function _versionExists(version) { - console.log(`check if version exists: ${version}`); - return version !== '' && includes(_readDocsVersionsJson(), version); +function _removeDocsVersion(targetDocsPath, versionsPath, versions, version) { + console.log(`Removing documentation version: ${version}`); + fs.rmSync(path.join(targetDocsPath, 'versioned_docs', `version-${version}`), { + recursive: true, + force: true, + }); + fs.rmSync(path.join(targetDocsPath, 'versioned_sidebars', `version-${version}-sidebars.json`), { + force: true, + }); + _writeDocsVersionsJson( + versionsPath, + versions.filter((existingVersion) => existingVersion !== version) + ); } -function _readDocsVersionsJson() { - return JSON.parse(fs.readFileSync(docsVersionsJsonPath)); +function _readDocsVersionsJson(versionsPath) { + const versions = JSON.parse(fs.readFileSync(versionsPath, 'utf8')); + if (!Array.isArray(versions)) { + throw new Error('Documentation versions must be an array'); + } + return versions; } -function _writeDocsVersionsJson(versionsJson) { - fs.writeFileSync(docsVersionsJsonPath, JSON.stringify(versionsJson, null, 2)); +function _writeDocsVersionsJson(versionsPath, versions) { + fs.writeFileSync(versionsPath, JSON.stringify(versions, null, 2)); } module.exports = { diff --git a/scripts/documentation.test.js b/scripts/documentation.test.js index e861437e843..31c7f36e866 100644 --- a/scripts/documentation.test.js +++ b/scripts/documentation.test.js @@ -1,56 +1,122 @@ -jest.mock('shell-utils'); -const exec = require('shell-utils').exec; - -jest.mock('fs'); +const childProcess = require('child_process'); const fs = require('fs'); +const os = require('os'); +const path = require('path'); const documentation = require('./documentation'); describe('Documentation script', () => { - beforeAll(() => { - process.chdir = jest.fn(); + let docsPath; + let versionsPath; + let execFileSync; + + beforeEach(() => { + docsPath = fs.mkdtempSync(path.join(os.tmpdir(), 'rnn-documentation-')); + versionsPath = path.join(docsPath, 'versions.json'); + fs.mkdirSync(path.join(docsPath, 'versioned_docs'), { recursive: true }); + fs.mkdirSync(path.join(docsPath, 'versioned_sidebars'), { recursive: true }); + fs.writeFileSync(versionsPath, JSON.stringify(['2.0.0', '1.0.0'])); + execFileSync = jest.spyOn(childProcess, 'execFileSync').mockImplementation(() => {}); }); afterEach(() => { - jest.resetAllMocks(); + jest.restoreAllMocks(); + fs.rmSync(docsPath, { recursive: true, force: true }); }); - it('should build new version', () => { - const version = '1.0.0'; - fs.readFileSync.mockReturnValue(JSON.stringify([])); - let initialWorkingDir = process.cwd(); - documentation.release('1.0.0'); - expect(exec.execSync).toHaveBeenCalledTimes(3); - expect(process.chdir).toHaveBeenCalledWith(docsPath()); - - expect(exec.execSync).toHaveBeenCalledWith(`npm install`); - expect(exec.execSync).toHaveBeenCalledWith(`npm run docusaurus docs:version ${version}`); - expect(exec.execSync).toHaveBeenCalledWith(`git add ../website`); - expect(process.chdir).toHaveBeenCalledWith(process.cwd()); + it('generates a new version without changing the caller cwd', () => { + const cwd = process.cwd(); + + documentation.release('3.0.0', undefined, { docsPath }); + + expect(execFileSync).toHaveBeenCalledWith( + 'npm', + ['run', 'docusaurus', '--', 'docs:version', '3.0.0'], + { cwd: docsPath, stdio: 'inherit' } + ); + expect(process.cwd()).toBe(cwd); + expect(readVersions()).toEqual(['2.0.0', '1.0.0']); }); - it('should override version', () => { - fs.readFileSync.mockReturnValue(JSON.stringify(['1.0.0', '2.0.0'])); + it.each(['../escape', '1.0.0; touch /tmp/pwned', '$(touch /tmp/pwned)', ''])( + 'rejects unsafe version %s before mutation', + (version) => { + const before = fs.readFileSync(versionsPath, 'utf8'); + const cwd = process.cwd(); - const version = '2.0.0'; - const removeVersion = '2.0.0'; - documentation.release(version, removeVersion); + expect(() => documentation.release(version, undefined, { docsPath })).toThrow( + /dotted numeric version/ + ); + expect(execFileSync).not.toHaveBeenCalled(); + expect(fs.readFileSync(versionsPath, 'utf8')).toBe(before); + expect(process.cwd()).toBe(cwd); + } + ); - expect(exec.execSync).toHaveBeenCalledTimes(5); + it('rejects removal of an unknown version before mutation', () => { + const before = fs.readFileSync(versionsPath, 'utf8'); + const cwd = process.cwd(); - expect(process.chdir).toHaveBeenCalledWith(docsPath()); - expect(exec.execSync).toHaveBeenCalledWith( - `rm -rf ${docsPath()}/versioned_docs/version-${version}` + expect(() => documentation.release('3.0.0', '4.0.0', { docsPath })).toThrow( + 'Documentation version does not exist: 4.0.0' ); - expect(exec.execSync).toHaveBeenCalledWith( - `rm -f ${docsPath()}/versioned_sidebars/version-${version}-sidebars.json` + expect(execFileSync).not.toHaveBeenCalled(); + expect(fs.readFileSync(versionsPath, 'utf8')).toBe(before); + expect(process.cwd()).toBe(cwd); + }); + + it.each(['../escape', '1.0.0; touch /tmp/pwned', '$(touch /tmp/pwned)'])( + 'rejects unsafe removal version %s before mutation', + (removeVersion) => { + const before = fs.readFileSync(versionsPath, 'utf8'); + const cwd = process.cwd(); + + expect(() => documentation.release('3.0.0', removeVersion, { docsPath })).toThrow( + /dotted numeric version/ + ); + expect(execFileSync).not.toHaveBeenCalled(); + expect(fs.readFileSync(versionsPath, 'utf8')).toBe(before); + expect(process.cwd()).toBe(cwd); + } + ); + + it('rejects regenerating an existing version unless removing that same version', () => { + const before = fs.readFileSync(versionsPath, 'utf8'); + const cwd = process.cwd(); + + expect(() => documentation.release('2.0.0', undefined, { docsPath })).toThrow( + 'Documentation version already exists: 2.0.0' ); - expect(exec.execSync).toHaveBeenCalledWith(`npm run docusaurus docs:version ${version}`); - expect(exec.execSync).toHaveBeenCalledWith(`git add ../website`); - expect(process.chdir).toHaveBeenCalledWith(process.cwd()); + expect(execFileSync).not.toHaveBeenCalled(); + expect(fs.readFileSync(versionsPath, 'utf8')).toBe(before); + expect(process.cwd()).toBe(cwd); + }); + + it('removes an existing version and its generated files before regenerating it', () => { + const versionDocs = path.join(docsPath, 'versioned_docs', 'version-2.0.0'); + const versionSidebar = path.join(docsPath, 'versioned_sidebars', 'version-2.0.0-sidebars.json'); + fs.mkdirSync(versionDocs); + fs.writeFileSync(path.join(versionDocs, 'README.md'), 'old docs'); + fs.writeFileSync(versionSidebar, '{}'); + + documentation.release('2.0.0', '2.0.0', { docsPath }); + + expect(fs.existsSync(versionDocs)).toBe(false); + expect(fs.existsSync(versionSidebar)).toBe(false); + expect(readVersions()).toEqual(['1.0.0']); + expect(execFileSync).toHaveBeenCalledTimes(1); }); -}); -function docsPath() { - return `${process.cwd()}/website`; -} + it('removes the last existing version from the versions list', () => { + fs.writeFileSync(versionsPath, JSON.stringify(['1.0.0'])); + + documentation.release('2.0.0', '1.0.0', { docsPath }); + + expect(readVersions()).toEqual([]); + expect(execFileSync).toHaveBeenCalledTimes(1); + }); + + function readVersions() { + return JSON.parse(fs.readFileSync(versionsPath, 'utf8')); + } +}); diff --git a/scripts/release.js b/scripts/release.js deleted file mode 100644 index 7bb9ee6b08f..00000000000 --- a/scripts/release.js +++ /dev/null @@ -1,173 +0,0 @@ -/* tslint:disable: no-console */ -const exec = require('shell-utils').exec; -const semver = require('semver'); -const fs = require('fs'); -const cp = require('child_process'); -const includes = require('lodash/includes'); -const documentation = require('./documentation'); - -const packageJsonPath = `${process.cwd()}/package.json`; - -// Export buildkite variables for Release build -// We cast toString() because 'buildkite-agent meta-data get' function returns 'object' -const BRANCH = process.env.BUILDKITE_BRANCH; -const isRelease = process.env.BUILDKITE_MESSAGE.match(/^release$/i); -let VERSION, VERSION_TAG, BUILD_DOCUMENTATION_VERSION, REMOVE_DOCUMENTATION_VERSION; -if (isRelease) { - VERSION = cp.execSync(`buildkite-agent meta-data get version`).toString(); - VERSION_TAG = cp.execSync(`buildkite-agent meta-data get npm-tag`).toString(); - BUILD_DOCUMENTATION_VERSION = cp - .execSync(`buildkite-agent meta-data get build-documentation-version`) - .toString(); - REMOVE_DOCUMENTATION_VERSION = cp - .execSync(`buildkite-agent meta-data get remove-documentation-version`) - .toString(); -} - -console.log(typeof VERSION); -// Workaround JS -if (VERSION_TAG == 'null') { - VERSION_TAG = isRelease ? 'latest' : 'snapshot'; -} -const VERSION_INC = 'patch'; - -function run() { - if (!validateEnv()) { - return; - } - setupGit(); - createNpmRc(); - versionTagAndPublish(); -} - -function validateEnv() { - if (!process.env.CI) { - throw new Error(`releasing is only available from CI`); - } - return true; -} - -function setupGit() { - exec.execSyncSilent(`git config --global push.default simple`); - exec.execSyncSilent(`git config --global user.email "${process.env.GIT_EMAIL}"`); - exec.execSyncSilent(`git config --global user.name "${process.env.GIT_USER}"`); - const remoteUrl = new RegExp(`https?://(\\S+)`).exec(exec.execSyncRead(`git remote -v`))[1]; - exec.execSyncSilent( - `git remote add deploy "https://${process.env.GIT_USER}:${process.env.GIT_TOKEN}@${remoteUrl}"` - ); - // exec.execSync(`git checkout ${ONLY_ON_BRANCH}`); -} - -function createNpmRc() { - exec.execSync(`rm -f package-lock.json`); - const content = ` -email=\${NPM_EMAIL} -//registry.npmjs.org/:_authToken=\${NPM_TOKEN} -`; - fs.writeFileSync(`.npmrc`, content); -} - -function versionTagAndPublish() { - const packageVersion = semver.clean(process.env.npm_package_version); - console.log(`package version: ${packageVersion}`); - - const currentPublished = findCurrentPublishedVersion(); - console.log(`current published version: ${currentPublished}`); - - const version = isRelease - ? VERSION - : semver.gt(packageVersion, currentPublished) - ? `${packageVersion}-snapshot.${process.env.BUILDKITE_BUILD_NUMBER}` - : `${currentPublished}-snapshot.${process.env.BUILDKITE_BUILD_NUMBER}`; - - console.log(`Publishing version: ${version}`); - - tryPublishAndTag(version); -} - -function findCurrentPublishedVersion() { - return exec.execSyncRead(`npm view ${process.env.npm_package_name} dist-tags.latest`); -} - -function tryPublishAndTag(version) { - let theCandidate = version; - for (let retry = 0; retry < 5; retry++) { - try { - tagAndPublish(theCandidate); - console.log(`Released ${theCandidate}`); - return; - } catch (err) { - let alreadyPublished = false; - try { - cp.execSync(`npm view ${process.env.npm_package_name}@${theCandidate} version`, {stdio: 'pipe'}); - alreadyPublished = true; - } catch (_) { - alreadyPublished = false; - } - if (!alreadyPublished) { - throw err; - } - console.log(`previously published. retrying with increased ${VERSION_INC}...`); - theCandidate = semver.inc(theCandidate, VERSION_INC); - } - } -} - -function tagAndPublish(newVersion) { - console.log(`trying to publish ${newVersion}...`); - if (BUILD_DOCUMENTATION_VERSION && BUILD_DOCUMENTATION_VERSION !== 'null') - documentation.release(BUILD_DOCUMENTATION_VERSION, REMOVE_DOCUMENTATION_VERSION); - exec.execSync(`npm --no-git-tag-version version ${newVersion}`); - exec.execSync(`npm publish --tag ${VERSION_TAG}`); - if (isRelease) { - exec.execSync(`git tag -a ${newVersion} -m "${newVersion}"`); - exec.execSyncSilent(`git push deploy ${newVersion} || true`); - updatePackageJsonGit(newVersion); - } -} - -function writePackageJson(packageJson) { - fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); -} - -function readPackageJson() { - return JSON.parse(fs.readFileSync(packageJsonPath)); -} - -function updatePackageJsonGit(version) { - const prBranch = `ci/update-version-${version}`; - exec.execSync(`git checkout ${BRANCH}`); - exec.execSync(`git checkout -b ${prBranch}`); - const packageJson = readPackageJson(); - packageJson.version = version; - writePackageJson(packageJson); - exec.execSync(`git add package.json`); - exec.execSync(`git commit -m"Update package.json version to ${version} [buildkite skip]"`); - exec.execSync(`git push deploy ${prBranch}`); - - const prData = JSON.stringify({ - title: `Update package.json version to ${version}`, - head: prBranch, - base: BRANCH, - body: `Automated version bump to ${version} from CI release.`, - }); - - cp.execSync( - `curl -s -X POST ` + - `-H "Authorization: token ${process.env.GIT_TOKEN}" ` + - `-H "Content-Type: application/json" ` + - `-d '${prData}' ` + - `https://api.github.com/repos/wix/react-native-navigation/pulls` - ); - - draftGitRelease(version); -} - -function draftGitRelease(version) { - exec.execSync(`npx gren release --tags=${version}`); - exec.execSync(`sleep 30`); - // For some unknown reason, gren release works well only when calling it twice. - exec.execSync(`npx gren release --tags=${version}`); -} - -run(); diff --git a/scripts/release/artifact.cjs b/scripts/release/artifact.cjs new file mode 100644 index 00000000000..532e08e974b --- /dev/null +++ b/scripts/release/artifact.cjs @@ -0,0 +1,163 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { createHash } = require('node:crypto'); +const { execFileSync } = require('node:child_process'); +const adapter = require('./rnn.cjs'); +const { check, compareVersions, validateManifest } = require('./policy.cjs'); + +function exec(command, args, options = {}) { + return execFileSync(command, args, { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, ...options }); +} + +function integrity(file) { + return `sha512-${createHash('sha512').update(fs.readFileSync(file)).digest('base64')}`; +} + +function validatePackageFiles(files, pkg) { + const names = new Set(files); + for (const file of adapter.requiredFiles) check(names.has(file), `Package is missing ${file}`); + check( + files.some((file) => file.startsWith('ios/') && /\.(m|mm|cpp)$/.test(file)), + 'Package is missing iOS implementation' + ); + check( + files.some((file) => file.startsWith('android/') && /\.(java|kt)$/.test(file)), + 'Package is missing Android implementation' + ); + for (const file of files) { + check( + !file.startsWith('/') && !file.split('/').includes('..') && !file.includes('\\'), + `Unsafe package path: ${file}` + ); + check( + !/(^|\/)(node_modules|\.npmrc|\.git|\.github|\.buildkite|\.gradle|\.yarn|DerivedData)(\/|$)/.test( + file + ), + `Unexpected private/build path: ${file}` + ); + check( + !/^(scripts|playground|website|coverage|artifacts)\//.test(file), + `Unexpected development file: ${file}` + ); + check( + !/\.(apk|aab|ipa|jks|keystore)$/.test(file), + `Unexpected native binary/credential: ${file}` + ); + } + check(pkg.name === adapter.packageName, 'Unexpected packed package name'); + check( + pkg.repository?.url === `https://github.com/${adapter.repository}.git`, + 'Package repository must match npm trust' + ); + const entries = [pkg.main, pkg.types, pkg['react-native'], ...Object.values(pkg.bin || {})]; + for (const value of Object.values(pkg.exports || {})) { + if (typeof value === 'string') entries.push(value); + else entries.push(...Object.values(value)); + } + for (const entry of entries) { + check( + typeof entry === 'string' && names.has(entry.replace(/^\.\//, '')), + `Unresolvable package entry: ${entry}` + ); + } +} + +function pack(directory) { + check( + compareVersions(exec('npm', ['--version']).trim(), '11.17.0') >= 0, + 'Package preparation requires npm >=11.17.0 (Node 24.19.0); older npm can run prepare despite --ignore-scripts' + ); + fs.mkdirSync(directory, { recursive: true }); + const output = exec('npm', [ + 'pack', + '--ignore-scripts', + '--json', + '--pack-destination', + directory, + ]); + const results = JSON.parse(output); + check(results.length === 1, 'Expected one RNN tarball'); + const result = results[0]; + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); + validatePackageFiles( + result.files.map((file) => file.path), + pkg + ); + const actual = integrity(path.join(directory, result.filename)); + check(actual === result.integrity, 'npm pack integrity mismatch'); + return { name: pkg.name, version: pkg.version, filename: result.filename, integrity: actual }; +} + +function readManifest(directory, options) { + const manifest = validateManifest( + JSON.parse(fs.readFileSync(path.join(directory, 'manifest.json'), 'utf8')), + options + ); + for (const pkg of manifest.packages) { + const tarball = path.join(directory, pkg.filename); + check(fs.lstatSync(tarball).isFile(), 'Package artifact must be a regular file'); + check(integrity(tarball) === pkg.integrity, `Artifact integrity mismatch for ${pkg.name}`); + const names = exec('tar', ['-tzf', tarball]) + .trim() + .split('\n') + .map((name) => { + check(name.startsWith('package/'), 'Unexpected tarball root'); + return name.slice('package/'.length); + }) + .filter(Boolean); + const packed = JSON.parse(exec('tar', ['-xOzf', tarball, 'package/package.json'])); + validatePackageFiles(names, packed); + check(packed.version === pkg.version, 'Packed version differs from release manifest'); + } + for (const [filename, expected] of Object.entries(manifest.files || {})) { + check(['release.bundle', 'notes.md'].includes(filename), 'Unexpected manifest sidecar'); + check( + integrity(path.join(directory, filename)) === expected, + `Artifact integrity mismatch for ${filename}` + ); + } + check( + manifest.files?.['release.bundle'] && manifest.files?.['notes.md'], + 'Missing release artifact sidecars' + ); + return manifest; +} + +function verifyReleaseCommit(manifest, directory) { + exec('git', ['bundle', 'verify', path.join(directory, 'release.bundle')], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + exec('git', ['fetch', '--no-tags', path.join(directory, 'release.bundle'), 'HEAD']); + check( + exec('git', ['rev-parse', 'FETCH_HEAD']).trim() === manifest.releaseSha, + 'Bundle does not contain the release commit' + ); + check( + exec('git', ['rev-parse', `${manifest.releaseSha}^`]).trim() === manifest.sourceSha, + 'Release commit must have the validated source as its parent' + ); + const changed = exec('git', ['diff', '--name-only', manifest.sourceSha, manifest.releaseSha]) + .trim() + .split('\n') + .filter(Boolean); + for (const file of changed) { + check( + file === 'package.json' || + (manifest.documentationVersion && + (file === 'website/versions.json' || + file.startsWith('website/versioned_docs/') || + file.startsWith('website/versioned_sidebars/'))), + `Unexpected release source change: ${file}` + ); + } + const before = JSON.parse(exec('git', ['show', `${manifest.sourceSha}:package.json`])); + const after = JSON.parse(exec('git', ['show', `${manifest.releaseSha}:package.json`])); + check(after.version === manifest.packages[0].version, 'Release commit version mismatch'); + after.version = before.version; + check( + JSON.stringify(after) === JSON.stringify(before), + 'Release may only change package.json version' + ); +} + +module.exports = { exec, integrity, validatePackageFiles, pack, readManifest, verifyReleaseCommit }; diff --git a/scripts/release/artifact.test.cjs b/scripts/release/artifact.test.cjs new file mode 100644 index 00000000000..50306352cd6 --- /dev/null +++ b/scripts/release/artifact.test.cjs @@ -0,0 +1,100 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const adapter = require('./rnn.cjs'); +const { exec, integrity, validatePackageFiles, verifyReleaseCommit } = require('./artifact.cjs'); + +const files = [...adapter.requiredFiles, 'ios/Navigation.mm', 'android/src/Navigation.java']; +const pkg = { + name: adapter.packageName, + repository: { url: `https://github.com/${adapter.repository}.git` }, + main: './lib/module/index.js', + types: './lib/typescript/index.d.ts', + 'react-native': './src/index.ts', + bin: { 'rnn-link': './autolink/postlink/run.js' }, + exports: { + './Mock': { default: './lib/module/Mock/index.js', types: './lib/typescript/Mock/index.d.ts' }, + }, +}; + +test('package checks include exported paths, native sources, and accidental credentials', () => { + validatePackageFiles(files, pkg); + assert.throws( + () => + validatePackageFiles( + files.filter((f) => !f.startsWith('ios/')), + pkg + ), + /iOS/ + ); + assert.throws( + () => validatePackageFiles(files, { ...pkg, types: './missing.d.ts' }), + /Unresolvable/ + ); + for (const bad of [ + 'android/.gradle/cache', + '.npmrc', + 'ios/key.keystore', + 'playground/index.js', + '../escape', + ]) { + assert.throws(() => validatePackageFiles([...files, bad], pkg)); + } +}); + +test('integrity detects changes to saved package bytes', () => { + const folder = fs.mkdtempSync(path.join(os.tmpdir(), 'rnn-integrity-')); + try { + const file = path.join(folder, 'package.tgz'); + fs.writeFileSync(file, 'original'); + const first = integrity(file); + fs.writeFileSync(file, 'changed'); + assert.notEqual(integrity(file), first); + } finally { + fs.rmSync(folder, { recursive: true, force: true }); + } +}); + +test('real git bundles permit only the recorded version change on the validated source', () => { + const previousCwd = process.cwd(); + const folder = fs.mkdtempSync(path.join(os.tmpdir(), 'rnn-release-git-')); + try { + process.chdir(folder); + exec('git', ['init', '-q']); + exec('git', ['config', 'user.name', 'Release test']); + exec('git', ['config', 'user.email', 'release@example.test']); + fs.writeFileSync( + 'package.json', + JSON.stringify({ name: adapter.packageName, version: '1.0.0' }) + ); + exec('git', ['add', 'package.json']); + exec('git', ['commit', '-qm', 'source']); + const sourceSha = exec('git', ['rev-parse', 'HEAD']).trim(); + fs.writeFileSync( + 'package.json', + JSON.stringify({ name: adapter.packageName, version: '1.0.1' }) + ); + exec('git', ['commit', '-qam', 'version']); + const releaseSha = exec('git', ['rev-parse', 'HEAD']).trim(); + exec('git', ['bundle', 'create', 'release.bundle', 'HEAD', `^${sourceSha}`]); + verifyReleaseCommit({ sourceSha, releaseSha, packages: [{ version: '1.0.1' }] }, folder); + fs.writeFileSync('runtime.js', 'unexpected code'); + exec('git', ['add', 'runtime.js']); + exec('git', ['commit', '--amend', '-qm', 'unexpected mutation']); + const changedSha = exec('git', ['rev-parse', 'HEAD']).trim(); + exec('git', ['bundle', 'create', 'release.bundle', 'HEAD', `^${sourceSha}`]); + assert.throws( + () => + verifyReleaseCommit( + { sourceSha, releaseSha: changedSha, packages: [{ version: '1.0.1' }] }, + folder + ), + /Unexpected release source change/ + ); + } finally { + process.chdir(previousCwd); + fs.rmSync(folder, { recursive: true, force: true }); + } +}); diff --git a/scripts/release/github.cjs b/scripts/release/github.cjs new file mode 100644 index 00000000000..83a4d9053c0 --- /dev/null +++ b/scripts/release/github.cjs @@ -0,0 +1,183 @@ +const adapter = require('./rnn.cjs'); +const { + check, + assertReleaseContext, + isReleaseRunPath, + assertNativeCI, + compareVersions, +} = require('./policy.cjs'); +const prefix = `/repos/${adapter.repository}`; + +async function authorize(github, context) { + assertReleaseContext(context); + for (const actor of new Set([context.actor, context.triggeringActor])) { + check(/^[a-zA-Z0-9-]+(?:\[bot\])?$/.test(actor), 'Invalid GitHub actor'); + const user = await github.request( + 'GET', + `${prefix}/collaborators/${encodeURIComponent(actor)}/permission` + ); + check(user.permission === 'admin', `${actor} must have repository admin permission`); + } +} + +async function nativeCI(github, sha) { + check(/^[a-f0-9]{40}$/.test(sha), 'Native CI requires an exact source SHA'); + const statuses = await github.request('GET', `${prefix}/commits/${sha}/statuses?per_page=100`); + return assertNativeCI(statuses, sha); +} + +async function checkSourceRun(github, runId) { + check(/^[1-9]\d*$/.test(runId), 'Resume requires a numeric source_run_id'); + const run = await github.request('GET', `${prefix}/actions/runs/${runId}`); + check( + run.event === 'workflow_dispatch' && + run.head_branch === adapter.branch && + isReleaseRunPath(run.path) && + run.repository.full_name === adapter.repository && + run.head_repository.full_name === adapter.repository && + run.status === 'completed', + 'Resume source must be a completed upstream master release workflow' + ); + const artifacts = []; + for (let page = 1; ; page++) { + const batch = await github.request( + 'GET', + `${prefix}/actions/runs/${runId}/artifacts?per_page=100&page=${page}` + ); + artifacts.push(...batch.artifacts); + if (batch.artifacts.length < 100) break; + } + const candidates = artifacts + .filter((item) => new RegExp(`^release-${runId}-[1-9]\\d*$`).test(item.name) && !item.expired) + .sort((a, b) => Number(b.id) - Number(a.id)); + check(candidates.length, 'No retained release artifact; automatic resume is unavailable'); + return { run, artifact: candidates[0] }; +} + +async function ensureBranch(github, name, sha) { + const endpoint = `${prefix}/git/ref/heads/${name}`; + const existing = await github.request('GET', endpoint, undefined, { allow404: true }); + if (existing) { + check( + existing.object.sha === sha, + `Release branch ${name} already points to a different commit` + ); + return; + } + await github.request('POST', `${prefix}/git/refs`, { ref: `refs/heads/${name}`, sha }); +} + +async function ensureTag(github, tag, sha) { + const existing = await github.request('GET', `${prefix}/git/ref/tags/${tag}`, undefined, { + allow404: true, + }); + if (existing) { + const target = + existing.object.type === 'tag' + ? await github.request('GET', `${prefix}/git/tags/${existing.object.sha}`) + : existing; + check(target.object.sha === sha, `Tag ${tag} already points to a different commit`); + return; + } + const object = await github.request('POST', `${prefix}/git/tags`, { + tag, + message: tag, + object: sha, + type: 'commit', + }); + await github.request('POST', `${prefix}/git/refs`, { ref: `refs/tags/${tag}`, sha: object.sha }); +} + +async function ensureRelease(github, manifest, notes) { + const pkg = manifest.packages[0]; + const existing = await github.request( + 'GET', + `${prefix}/releases/tags/${pkg.version}`, + undefined, + { allow404: true } + ); + if (existing) { + check( + !existing.draft && existing.prerelease === (pkg.npmTag !== 'latest'), + 'Existing GitHub release has conflicting state' + ); + return existing; + } + // Resuming an old release must never replace a newer GitHub "latest" release. + const latest = await github.request('GET', `${prefix}/releases/latest`, undefined, { + allow404: true, + }); + let makeLatest = pkg.npmTag === 'latest'; + if (makeLatest && latest) { + try { + makeLatest = compareVersions(pkg.version, latest.tag_name) > 0; + } catch { + makeLatest = false; + } + } + return github.request('POST', `${prefix}/releases`, { + tag_name: pkg.version, + target_commitish: manifest.sourceSha, + name: pkg.version, + body: notes, + draft: false, + prerelease: pkg.npmTag !== 'latest', + make_latest: makeLatest ? 'true' : 'false', + }); +} + +async function ensurePullRequest(github, manifest, template = '') { + const pkg = manifest.packages[0]; + const branch = `ci/update-version-${pkg.version}`; + const results = await github.request( + 'GET', + `${prefix}/pulls?state=all&head=wix:${branch}&base=${adapter.branch}&per_page=100` + ); + const existing = results.find((pr) => pr.head.ref === branch && pr.base.ref === adapter.branch); + let pr = existing; + if (existing) { + check( + existing.head.sha === manifest.releaseSha, + 'Version PR no longer matches the release commit' + ); + check( + existing.state === 'open' || existing.merged_at, + 'Version PR was closed without merging; resolve it manually' + ); + } else { + pr = await github.request('POST', `${prefix}/pulls`, { + title: `Release: update version to ${pkg.version}`, + head: branch, + base: adapter.branch, + body: `${template ? `${template.trim()}\n\n` : ''}Updates release metadata${ + manifest.documentationVersion ? ' and versioned documentation' : '' + } for react-native-navigation ${pkg.version}.\n\nPublished from ${ + manifest.sourceSha + }.\n\nRelease run: https://github.com/${adapter.repository}/actions/runs/${ + manifest.runId + }\n\nThe workflow verified the package tarball and registry integrity. Review and merge this version update; no native source changes are included.`, + }); + } + const label = await github.request('GET', `${prefix}/labels/${adapter.label}`, undefined, { + allow404: true, + }); + if (!label) + await github.request('POST', `${prefix}/labels`, { + name: adapter.label, + color: '0e8a16', + description: 'Release metadata and publishing', + }); + await github.request('POST', `${prefix}/issues/${pr.number}/labels`, { labels: [adapter.label] }); + return pr; +} + +module.exports = { + prefix, + authorize, + nativeCI, + checkSourceRun, + ensureBranch, + ensureTag, + ensureRelease, + ensurePullRequest, +}; diff --git a/scripts/release/github.test.cjs b/scripts/release/github.test.cjs new file mode 100644 index 00000000000..64b940018d3 --- /dev/null +++ b/scripts/release/github.test.cjs @@ -0,0 +1,241 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const adapter = require('./rnn.cjs'); +const { + authorize, + nativeCI, + checkSourceRun, + ensureTag, + ensurePullRequest, + ensureRelease, +} = require('./github.cjs'); + +const sourceSha = 'a'.repeat(40); +const manifest = (version = '8.9.0', changes = {}) => ({ + sourceSha, + releaseSha: sourceSha, + runId: '12345', + documentationVersion: '', + packages: [{ name: adapter.packageName, version, npmTag: 'latest' }], + ...changes, +}); +const context = { + repository: adapter.repository, + ref: `refs/heads/${adapter.branch}`, + event: 'workflow_dispatch', + sha: sourceSha, + runId: '12345', + runAttempt: '1', + actor: 'release-admin', + triggeringActor: 'approver-admin', +}; + +function mockGitHub(handler) { + const calls = []; + return { + calls, + request: async (...args) => { + calls.push(args); + return handler(...args); + }, + }; +} + +test('authorize requires admin permission for both workflow actors and rejects permission request failures', async () => { + const github = mockGitHub((_method, path) => { + if (path.endsWith('/release-admin/permission')) return { permission: 'admin' }; + if (path.endsWith('/approver-admin/permission')) return { permission: 'admin' }; + throw new Error('unexpected request'); + }); + await authorize(github, context); + assert.equal(github.calls.length, 2); + assert.ok(github.calls.every(([method]) => method === 'GET')); + + for (const deniedActor of ['release-admin', 'approver-admin']) { + const deniedGitHub = mockGitHub((_method, path) => + path.endsWith(`/${deniedActor}/permission`) + ? { permission: 'write' } + : { permission: 'admin' } + ); + await assert.rejects(authorize(deniedGitHub, context), /must have repository admin permission/); + assert.equal(deniedGitHub.calls.length, deniedActor === context.actor ? 1 : 2); + } + const failed = mockGitHub(() => { + throw new Error('GitHub unavailable'); + }); + await assert.rejects(authorize(failed, context), /GitHub unavailable/); +}); + +test('native CI reads the exact commit status history and returns the newest Buildkite status', async () => { + const expected = { + context: adapter.nativeStatus, + state: 'success', + target_url: `${adapter.nativeStatusUrl}2671`, + creator: adapter.nativeStatusCreator, + }; + const github = mockGitHub((_method, path) => { + assert.equal(path, `/repos/${adapter.repository}/commits/${sourceSha}/statuses?per_page=100`); + return [{ context: 'other', state: 'failure' }, expected]; + }); + assert.equal(await nativeCI(github, sourceSha), expected); + assert.equal(github.calls.length, 1); + await assert.rejects(nativeCI(github, 'master'), /exact source SHA/); + assert.equal(github.calls.length, 1, 'invalid refs must not reach GitHub'); +}); + +test('checkSourceRun validates workflow identity and selects the newest retained matching artifact', async () => { + const validRun = { + event: 'workflow_dispatch', + head_branch: adapter.branch, + path: `${adapter.workflow}@master`, + repository: { full_name: adapter.repository }, + head_repository: { full_name: adapter.repository }, + status: 'completed', + }; + const github = mockGitHub((_method, path) => { + if (path.endsWith('/actions/runs/12345')) return validRun; + if (path.endsWith('/artifacts?per_page=100&page=1')) + return { + artifacts: [ + { id: 4, name: 'release-12345-1', expired: false }, + { id: 10, name: 'release-12345-2', expired: true }, + { id: 12, name: 'unrelated', expired: false }, + { id: 8, name: 'release-12345-2', expired: false }, + ], + }; + throw new Error(`unexpected request ${path}`); + }); + const result = await checkSourceRun(github, '12345'); + assert.equal(result.run, validRun); + assert.equal(result.artifact.id, 8); + + for (const patch of [ + { path: 'elsewhere.yml' }, + { path: `${adapter.workflow}@feature` }, + { path: `${adapter.workflow}@refs/heads/feature` }, + { head_branch: 'feature' }, + { repository: { full_name: 'fork/project' }, head_repository: { full_name: 'fork/project' } }, + { status: 'in_progress' }, + ]) { + const invalid = mockGitHub((_method, path) => + path.endsWith('/actions/runs/12345') ? { ...validRun, ...patch } : { artifacts: [] } + ); + await assert.rejects( + checkSourceRun(invalid, '12345'), + /completed upstream master release workflow/ + ); + assert.equal(invalid.calls.length, 1, 'invalid run should not trigger artifact lookup'); + } + const expired = mockGitHub((_method, path) => + path.endsWith('/actions/runs/12345') + ? validRun + : { artifacts: [{ id: 9, name: 'release-12345-1', expired: true }] } + ); + await assert.rejects(checkSourceRun(expired, '12345'), /No retained release artifact/); +}); + +test('ensureTag reuses matching annotated and lightweight tags and rejects conflicts', async () => { + const annotatedSha = 'b'.repeat(40); + const annotated = mockGitHub((_method, path) => { + if (path.endsWith('/git/ref/tags/v8.9.0')) + return { object: { type: 'tag', sha: 'tag-object' } }; + if (path.endsWith('/git/tags/tag-object')) return { object: { sha: sourceSha } }; + throw new Error(`unexpected request ${path}`); + }); + await ensureTag(annotated, 'v8.9.0', sourceSha); + assert.equal(annotated.calls.length, 2); + + const lightweight = mockGitHub(() => ({ object: { type: 'commit', sha: sourceSha } })); + await ensureTag(lightweight, 'v8.9.0', sourceSha); + assert.equal(lightweight.calls.length, 1); + const conflict = mockGitHub((_method, path) => + path.endsWith('/git/ref/tags/v8.9.0') + ? { object: { type: 'commit', sha: annotatedSha } } + : undefined + ); + await assert.rejects(ensureTag(conflict, 'v8.9.0', sourceSha), /different commit/); + assert.equal(conflict.calls.length, 1); +}); + +test('ensurePullRequest reuses matching open or merged PRs, applies labels, and rejects unsafe PRs', async () => { + for (const existing of [ + { number: 51, state: 'open', merged_at: null }, + { number: 52, state: 'closed', merged_at: '2026-01-01T00:00:00Z' }, + ]) { + const pr = { + ...existing, + head: { ref: 'ci/update-version-8.9.0', sha: sourceSha }, + base: { ref: adapter.branch }, + }; + const github = mockGitHub((_method, path) => { + if (path.includes('/pulls?')) return [pr]; + if (path.endsWith(`/labels/${adapter.label}`)) return { name: adapter.label }; + if (path.endsWith(`/issues/${existing.number}/labels`)) return undefined; + throw new Error(`unexpected request ${path}`); + }); + assert.equal(await ensurePullRequest(github, manifest()), pr); + const labelCall = github.calls.find( + ([method, path]) => method === 'POST' && path.endsWith(`/issues/${existing.number}/labels`) + ); + assert.deepEqual(labelCall[2], { labels: [adapter.label] }); + assert.equal( + github.calls.filter(([method]) => method === 'POST' && method.includes('/pulls')).length, + 0 + ); + } + + for (const pr of [ + { + number: 53, + state: 'closed', + merged_at: null, + head: { ref: 'ci/update-version-8.9.0', sha: sourceSha }, + base: { ref: adapter.branch }, + }, + { + number: 54, + state: 'open', + merged_at: null, + head: { ref: 'ci/update-version-8.9.0', sha: 'c'.repeat(40) }, + base: { ref: adapter.branch }, + }, + ]) { + const github = mockGitHub((_method, path) => (path.includes('/pulls?') ? [pr] : undefined)); + await assert.rejects( + ensurePullRequest(github, manifest()), + pr.number === 53 ? /closed without merging/ : /no longer matches/ + ); + assert.equal(github.calls.length, 1, 'invalid PR should not be labeled or changed'); + } +}); + +test('ensureRelease does not move latest backward when recovering an older stable release', async () => { + const github = mockGitHub((method, path, body) => { + if (path.endsWith('/releases/tags/8.9.0')) return undefined; + if (path.endsWith('/releases/latest')) return { tag_name: '8.10.0' }; + if (method === 'POST' && path.endsWith('/releases')) return body; + throw new Error(`unexpected request ${path}`); + }); + const release = await ensureRelease(github, manifest(), 'notes'); + assert.equal(release.make_latest, 'false'); + + const newerGitHub = mockGitHub((method, path, body) => { + if (path.endsWith('/releases/tags/8.11.0')) return undefined; + if (path.endsWith('/releases/latest')) return { tag_name: '8.10.0' }; + if (method === 'POST' && path.endsWith('/releases')) return body; + throw new Error(`unexpected request ${path}`); + }); + const newer = await ensureRelease(newerGitHub, manifest('8.11.0'), 'notes'); + assert.equal(newer.make_latest, 'true'); +}); + +test('ensureRelease targets the source commit, not the release commit that only the version PR contains', async () => { + const github = mockGitHub((method, path, body) => { + if (path.endsWith('/releases/tags/8.9.0')) return undefined; + if (path.endsWith('/releases/latest')) return undefined; + if (method === 'POST' && path.endsWith('/releases')) return body; + throw new Error(`unexpected request ${path}`); + }); + const release = await ensureRelease(github, manifest('8.9.0', { releaseSha: 'b'.repeat(40) }), 'notes'); + assert.equal(release.target_commitish, sourceSha); +}); diff --git a/scripts/release/http.cjs b/scripts/release/http.cjs new file mode 100644 index 00000000000..7774d1531ca --- /dev/null +++ b/scripts/release/http.cjs @@ -0,0 +1,48 @@ +const { setTimeout: delay } = require('node:timers/promises'); +const { check } = require('./policy.cjs'); + +async function jsonRequest(url, options = {}) { + const { allow404 = false, fetchImpl = fetch, sleep = delay, ...init } = options; + for (let attempt = 0; attempt < 3; attempt++) { + const response = await fetchImpl(url, { ...init, signal: AbortSignal.timeout(30_000) }); + if (response.status === 404 && allow404) return null; + if ((response.status === 429 || response.status >= 500) && attempt < 2) { + await sleep(1000 * 2 ** attempt); + continue; + } + // Never echo request headers or arbitrary server response bodies (credentials). + check(response.ok, `HTTP ${response.status} from ${new URL(url).host}${new URL(url).pathname}`); + if (response.status === 204) return null; + return response.json(); + } + throw new Error('HTTP retry limit reached'); +} + +function githubClient(token, request = jsonRequest) { + check(token, 'GH_TOKEN is required'); + return { + request(method, endpoint, body, options = {}) { + check(endpoint.startsWith('/repos/'), 'Only repository GitHub API endpoints are allowed'); + return request(`https://api.github.com${endpoint}`, { + ...options, + method, + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'Content-Type': 'application/json', + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + }, + }; +} + +function registryVersion(name, selected, options = {}) { + return jsonRequest( + `https://registry.npmjs.org/${encodeURIComponent(name)}/${encodeURIComponent(selected)}`, + options + ); +} + +module.exports = { jsonRequest, githubClient, registryVersion }; diff --git a/scripts/release/http.test.cjs b/scripts/release/http.test.cjs new file mode 100644 index 00000000000..ab7c68ec5c6 --- /dev/null +++ b/scripts/release/http.test.cjs @@ -0,0 +1,50 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { jsonRequest } = require('./http.cjs'); + +test('only an explicitly allowed 404 means a package is absent', async () => { + const fetchImpl = async () => ({ status: 404, ok: false }); + assert.equal( + await jsonRequest('https://registry.npmjs.org/p/1', { fetchImpl, allow404: true }), + null + ); + await assert.rejects( + jsonRequest('https://registry.npmjs.org/p/latest', { fetchImpl }), + /HTTP 404/ + ); + await assert.rejects( + jsonRequest('https://registry.npmjs.org/p/1', { + fetchImpl: async () => ({ status: 401, ok: false }), + allow404: true, + }), + /HTTP 401/ + ); +}); + +test('registry outages retry and fail rather than selecting a new version', async () => { + let attempts = 0; + await assert.rejects( + jsonRequest('https://registry.npmjs.org/p/1', { + fetchImpl: async () => { + attempts++; + return { status: 503, ok: false }; + }, + sleep: async () => {}, + allow404: true, + }), + /HTTP 503/ + ); + assert.equal(attempts, 3); +}); + +test('network errors cannot be mistaken for a missing version', async () => { + await assert.rejects( + jsonRequest('https://registry.npmjs.org/p/1', { + fetchImpl: async () => { + throw new Error('offline'); + }, + allow404: true, + }), + /offline/ + ); +}); diff --git a/scripts/release/index.cjs b/scripts/release/index.cjs new file mode 100644 index 00000000000..55612046da4 --- /dev/null +++ b/scripts/release/index.cjs @@ -0,0 +1,395 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const adapter = require('./rnn.cjs'); +const { + check, + version, + compareVersions, + createPlan, + isReleaseRunPath, + publicationState, +} = require('./policy.cjs'); +const { githubClient, registryVersion } = require('./http.cjs'); +const { + prefix, + authorize, + nativeCI, + checkSourceRun, + ensureTag, + ensureRelease, + ensurePullRequest, +} = require('./github.cjs'); +const { exec, integrity, pack, readManifest, verifyReleaseCommit } = require('./artifact.cjs'); +const { renderNotes } = require('./notes.cjs'); +const { publishPackages } = require('./publication.cjs'); + +const directory = path.resolve(process.env.RELEASE_DIR || '.release-artifacts'); +const context = { + repository: process.env.GITHUB_REPOSITORY, + ref: process.env.GITHUB_REF, + event: process.env.GITHUB_EVENT_NAME, + sha: process.env.GITHUB_SHA, + runId: process.env.GITHUB_RUN_ID, + runAttempt: process.env.GITHUB_RUN_ATTEMPT, + actor: process.env.GITHUB_ACTOR, + triggeringActor: process.env.GITHUB_TRIGGERING_ACTOR, +}; + +function output(name, value) { + if (process.env.GITHUB_OUTPUT) fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`); +} + +function summary(text) { + console.log(text); + if (process.env.GITHUB_STEP_SUMMARY) + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${text}\n`); +} + +function readPlan() { + return JSON.parse(fs.readFileSync(path.join(directory, 'plan.json'), 'utf8')); +} + +function writeJSON(file, value) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); +} + +async function guard(github, publishing = false) { + await authorize(github, context); + check( + ['dry-run', 'publish', 'resume'].includes(process.env.RELEASE_OPERATION), + 'Invalid operation' + ); + if (publishing || process.env.RELEASE_OPERATION !== 'dry-run') { + check( + process.env.RELEASE_PUBLISH_ENABLED === 'true', + 'Set repository variable RELEASE_PUBLISH_ENABLED=true after configuring npm trust and the release environment' + ); + } +} + +async function verifyOrigin(github, manifest) { + const run = await github.request('GET', `${prefix}/actions/runs/${manifest.runId}`); + check( + run.event === 'workflow_dispatch' && + isReleaseRunPath(run.path) && + run.head_branch === adapter.branch && + run.repository.full_name === adapter.repository && + run.head_repository.full_name === adapter.repository && + run.head_sha === manifest.sourceSha && + run.actor.login === manifest.actor && + Number(run.run_attempt) >= Number(manifest.runAttempt), + 'Artifact does not match its originating release workflow' + ); + exec('git', ['merge-base', '--is-ancestor', manifest.sourceSha, context.sha]); +} + +async function verifiedArtifact(github) { + await guard(github, true); + const manifest = readManifest(directory, { publishing: true }); + await verifyOrigin(github, manifest); + await nativeCI(github, manifest.sourceSha); + verifyReleaseCommit(manifest, directory); + return manifest; +} + +async function plan(github) { + await guard(github); + check( + exec('git', ['rev-parse', 'HEAD']).trim() === context.sha, + 'Checkout differs from workflow source SHA' + ); + const current = JSON.parse(fs.readFileSync('package.json', 'utf8')); + const latest = await registryVersion(adapter.packageName, 'latest'); + const manifest = createPlan( + { + operation: process.env.RELEASE_OPERATION, + kind: process.env.RELEASE_KIND, + version: process.env.RELEASE_VERSION, + npmTag: process.env.RELEASE_NPM_TAG, + documentationVersion: process.env.RELEASE_DOCUMENTATION_VERSION, + removeDocumentationVersion: process.env.RELEASE_REMOVE_DOCUMENTATION_VERSION, + }, + context, + current.version, + latest.version + ); + const pkg = manifest.packages[0]; + const existing = await registryVersion(pkg.name, pkg.version, { allow404: true }); + check( + !existing, + `${pkg.name}@${pkg.version} already exists. Use resume with the original publishing run.` + ); + try { + manifest.nativeCI = await nativeCI(github, context.sha); + } catch (error) { + if (manifest.operation !== 'dry-run') throw error; + summary( + `Native CI is not ready for publication: ${error.message}. This dry run only verifies package preparation.` + ); + } + const tags = exec('git', ['tag', '--merged', context.sha]) + .trim() + .split('\n') + .filter((tag) => { + try { + return !version(tag).pre.length && compareVersions(tag, pkg.version) < 0; + } catch { + return false; + } + }) + .sort(compareVersions); + manifest.previousTag = tags.at(-1) || ''; + check( + manifest.previousTag, + 'No preceding reachable stable tag; define the initial release baseline before publishing' + ); + writeJSON(path.join(directory, 'plan.json'), manifest); + output('documentation_version', manifest.documentationVersion); + summary( + `Planned ${pkg.name}@${pkg.version} → ${pkg.npmTag}\n\nSource: ${manifest.sourceSha}\n\nOperation: ${manifest.operation}` + ); +} + +function setVersion() { + const manifest = readPlan(); + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); + pkg.version = manifest.packages[0].version; + writeJSON('package.json', pkg); + if (manifest.documentationVersion) { + require('../documentation').release( + manifest.documentationVersion, + manifest.removeDocumentationVersion + ); + } +} + +async function prepareArtifact(github) { + const manifest = readPlan(); + await guard(github); + const pkg = manifest.packages[0]; + // Exclude untracked build output; only explicit version/documentation paths may + // enter the release commit. The bundle verifier checks the resulting diff again. + exec('git', ['add', '--', 'package.json']); + if (manifest.documentationVersion) { + exec('git', [ + 'add', + '--', + 'website/versions.json', + 'website/versioned_docs', + 'website/versioned_sidebars', + ]); + } + const date = exec('git', ['show', '-s', '--format=%cI', manifest.sourceSha]).trim(); + exec( + 'git', + [ + '-c', + 'user.name=github-actions[bot]', + '-c', + 'user.email=41898282+github-actions[bot]@users.noreply.github.com', + 'commit', + '--allow-empty', + '--no-verify', + '-m', + `Release ${pkg.version}`, + ], + { + env: { ...process.env, GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date }, + } + ); + manifest.releaseSha = exec('git', ['rev-parse', 'HEAD']).trim(); + check( + !exec('git', ['status', '--porcelain', '--untracked-files=all']).trim(), + 'Build/install changed files outside the permitted release commit' + ); + Object.assign(pkg, pack(directory)); + check( + !exec('git', ['status', '--porcelain', '--untracked-files=all']).trim(), + 'Packing changed source files; refusing to release an uncommitted tree' + ); + exec('git', [ + 'bundle', + 'create', + path.join(directory, 'release.bundle'), + 'HEAD', + `^${manifest.sourceSha}`, + ]); + const commits = exec('git', ['rev-list', `${manifest.previousTag}..${manifest.sourceSha}`]) + .trim() + .split('\n') + .filter(Boolean); + const prs = []; + for (const commit of commits) { + for (let page = 1; ; page++) { + const batch = await github.request( + 'GET', + `${prefix}/commits/${commit}/pulls?per_page=100&page=${page}` + ); + prs.push(...batch.filter((pr) => pr.base.repo.full_name === adapter.repository)); + if (batch.length < 100) break; + } + } + const notes = renderNotes({ + version: pkg.version, + previousTag: manifest.previousTag, + sourceSha: manifest.sourceSha, + pullRequests: prs, + }); + fs.writeFileSync(path.join(directory, 'notes.md'), notes); + manifest.files = Object.fromEntries( + ['notes.md', 'release.bundle'].map((file) => [file, integrity(path.join(directory, file))]) + ); + writeJSON(path.join(directory, 'manifest.json'), manifest); + fs.unlinkSync(path.join(directory, 'plan.json')); + readManifest(directory); + verifyReleaseCommit(manifest, directory); + summary( + `Prepared ${pkg.filename}\n\nIntegrity: ${pkg.integrity}\n\nRelease commit: ${manifest.releaseSha}\n\n${notes}` + ); +} + +async function record(github) { + const manifest = await verifiedArtifact(github); + if (manifest.kind === 'snapshot') return; + const branch = `ci/update-version-${manifest.packages[0].version}`; + const existing = await github.request('GET', `${prefix}/git/ref/heads/${branch}`, undefined, { + allow404: true, + }); + if (existing) { + check( + existing.object.sha === manifest.releaseSha, + `Release branch ${branch} conflicts with this artifact` + ); + return; + } + // Supply Git authentication only to this process, never in the remote URL or + // persistent git config. No force push and no branch-protection bypass. + exec('git', ['push', 'origin', `${manifest.releaseSha}:refs/heads/${branch}`], { + env: { + ...process.env, + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'http.https://github.com/.extraheader', + GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${Buffer.from( + `x-access-token:${process.env.GH_TOKEN}` + ).toString('base64')}`, + }, + }); +} + +async function publish(github) { + const manifest = await verifiedArtifact(github); + check( + process.env.ACTIONS_ID_TOKEN_REQUEST_URL && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN, + 'Publishing requires GitHub OIDC credentials (id-token: write)' + ); + check( + compareVersions(exec('npm', ['--version']).trim(), '11.5.1') >= 0, + 'Trusted publishing requires npm >=11.5.1' + ); + await publishPackages(manifest.packages, { + lookup: (name, selected) => registryVersion(name, selected, { allow404: true }), + report: summary, + publish: async (pkg) => { + check( + context.sha === manifest.sourceSha, + 'An unpublished version must run at its original source SHA for npm provenance. Re-run failed jobs of the original publishing run.' + ); + const npmrc = path.join(directory, 'oidc.npmrc'); + fs.writeFileSync(npmrc, 'registry=https://registry.npmjs.org/\n'); + const env = { ...process.env, NPM_CONFIG_USERCONFIG: npmrc }; + delete env.NPM_TOKEN; + delete env.NODE_AUTH_TOKEN; + exec( + 'npm', + [ + 'publish', + path.join(directory, pkg.filename), + '--ignore-scripts', + '--access', + 'public', + '--tag', + pkg.npmTag, + ], + { cwd: directory, env, stdio: 'inherit' } + ); + }, + }); +} + +async function finalize(github) { + const manifest = await verifiedArtifact(github); + for (const pkg of manifest.packages) { + check( + publicationState(pkg, await registryVersion(pkg.name, pkg.version, { allow404: true })) === + 'complete', + 'Cannot finalize an unpublished package' + ); + } + if (manifest.kind === 'snapshot') { + summary('Snapshot publication verified; no Git tag or version PR is created.'); + return; + } + const pkg = manifest.packages[0]; + // The release commit exists only on the squash-merged version PR branch; tag the master commit that was built and published. + await ensureTag(github, pkg.version, manifest.sourceSha); + const release = await ensureRelease( + github, + manifest, + fs.readFileSync(path.join(directory, 'notes.md'), 'utf8') + ); + const templates = ['.github/pull_request_template.md', '.github/PULL_REQUEST_TEMPLATE.md']; + const template = templates.find((file) => fs.existsSync(file)); + const pr = await ensurePullRequest( + github, + manifest, + template ? fs.readFileSync(template, 'utf8') : '' + ); + summary(`Release: ${release.html_url}\n\nVersion/documentation PR: ${pr.html_url}`); +} + +async function main() { + const command = process.argv[2]; + if (command === 'pack-check') { + const pkg = pack(directory); + summary(`Verified package ${pkg.filename} (${pkg.integrity})`); + return; + } + if (command === 'set-version') { + setVersion(); + return; + } + const github = githubClient(process.env.GH_TOKEN); + if (command === 'authorize') return guard(github); + if (command === 'plan') return plan(github); + if (command === 'prepare-artifact') return prepareArtifact(github); + if (command === 'record') return record(github); + if (command === 'publish') return publish(github); + if (command === 'finalize') return finalize(github); + if (command === 'resume-info') { + await guard(github, true); + const { artifact } = await checkSourceRun(github, process.env.RELEASE_SOURCE_RUN_ID); + output('artifact_id', artifact.id); + output('source_run_id', process.env.RELEASE_SOURCE_RUN_ID); + return; + } + if (command === 'verify-resume') { + const manifest = await verifiedArtifact(github); + check( + manifest.runId === process.env.RELEASE_SOURCE_RUN_ID, + 'Resume artifact belongs to a different run' + ); + summary( + `Resuming ${manifest.packages[0].version} from run ${manifest.runId}; new version/docs inputs are ignored.` + ); + return; + } + throw new Error(`Unknown release command: ${command}`); +} + +main().catch((error) => { + // Child-process errors may embed command output. Print the message only; never + // serialize environment or the entire error object. + console.error(error.message); + process.exitCode = 1; +}); diff --git a/scripts/release/notes.cjs b/scripts/release/notes.cjs new file mode 100644 index 00000000000..df3332046b1 --- /dev/null +++ b/scripts/release/notes.cjs @@ -0,0 +1,105 @@ +const CATEGORIES = [ + { title: 'Features', labels: ['feature'] }, + { title: 'Enhancements', labels: ['type: accepted/enhancement', 'internal'] }, + { title: 'Fixed', labels: ['type: accepted/bug'] }, + { title: 'Other', labels: [] }, +]; + +function escapeMarkdown(value) { + return String(value ?? '') + .replace(/[<>]/g, (char) => (char === '<' ? '<' : '>')) + .replace(/[\\`*_{}\[\]()#+!|~]/g, '\\$&'); +} + +function labelsOf(pr) { + return (Array.isArray(pr.labels) ? pr.labels : []) + .map((label) => (typeof label === 'string' ? label : label?.name)) + .filter((label) => typeof label === 'string'); +} + +function comparePRs(a, b) { + return ( + a.number - b.number || + String(a.title).localeCompare(String(b.title)) || + String(a.user?.login).localeCompare(String(b.user?.login)) + ); +} + +function categoryOf(labels) { + for (const category of CATEGORIES.slice(0, -1)) { + if (category.labels.some((label) => labels.includes(label))) return category.title; + } + return 'Other'; +} + +function platformOf(labels) { + const ios = labels.includes('platform: iOS'); + const android = labels.includes('platform: Android'); + if (ios === android) return 'general'; + return ios ? 'iOS only' : 'Android only'; +} + +function renderPR(pr, repository) { + const title = escapeMarkdown(pr.title); + const author = pr.user?.login ? ` — @${escapeMarkdown(pr.user.login)}` : ''; + return `- [${title}](https://github.com/${repository}/pull/${pr.number})${author}`; +} + +function renderNotes({ + version, + previousTag, + sourceSha, + pullRequests, + repository = 'wix/react-native-navigation', +}) { + if (!/^[\w.-]+\/[\w.-]+$/.test(repository)) throw new TypeError('Invalid GitHub repository'); + if (typeof version !== 'string' || !version) throw new TypeError('A release version is required'); + if (!/^[a-f0-9]{40}$/i.test(sourceSha)) + throw new TypeError('A full source commit SHA is required'); + + const byNumber = new Map(); + for (const pr of pullRequests || []) { + if (!Number.isSafeInteger(pr.number) || pr.number < 1 || !pr.merged_at) continue; + const labels = labelsOf(pr); + if (labels.includes('skip-changelog')) continue; + const candidate = { ...pr, _labels: labels }; + const existing = byNumber.get(pr.number); + if (!existing || comparePRs(candidate, existing) < 0) byNumber.set(pr.number, candidate); + } + + const prs = [...byNumber.values()].sort((a, b) => a.number - b.number); + const lines = [ + `# ${escapeMarkdown(version)}`, + '', + `Source commit: [${sourceSha}](https://github.com/${repository}/commit/${sourceSha})`, + ]; + if (previousTag) { + lines.push( + `Full changes: [${escapeMarkdown( + previousTag + )}...${sourceSha}](https://github.com/${repository}/compare/${encodeURIComponent( + previousTag + )}...${sourceSha})` + ); + } + + for (const category of CATEGORIES) { + const categoryPRs = prs.filter((pr) => categoryOf(pr._labels) === category.title); + if (!categoryPRs.length) continue; + lines.push('', `## ${category.title}`); + const groups = new Map([ + ['general', []], + ['iOS only', []], + ['Android only', []], + ]); + for (const pr of categoryPRs) groups.get(platformOf(pr._labels)).push(pr); + for (const [platform, groupPRs] of groups) { + if (!groupPRs.length) continue; + if (platform !== 'general') lines.push('', `### ${platform}`); + for (const pr of groupPRs) lines.push(renderPR(pr, repository)); + } + } + return `${lines.join('\n')}\n`; +} + +module.exports = { renderNotes }; diff --git a/scripts/release/notes.test.cjs b/scripts/release/notes.test.cjs new file mode 100644 index 00000000000..7b90a0fe058 --- /dev/null +++ b/scripts/release/notes.test.cjs @@ -0,0 +1,81 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { renderNotes } = require('./notes.cjs'); + +const sha = 'a'.repeat(40); +function pr(number, title, labels = [], extra = {}) { + return { + number, + title, + html_url: 'https://example.com/not-canonical', + user: { login: 'contributor' }, + labels: labels.map((name) => ({ name })), + merged_at: '2026-01-02T03:04:05Z', + ...extra, + }; +} + +test('renders categories, platform subgroups, canonical links, source and compare commits', () => { + const markdown = renderNotes({ + version: '8.9.0', + previousTag: 'v8.8.9', + sourceSha: sha, + pullRequests: [ + pr(12, 'Fix bug', ['type: accepted/bug']), + pr(4, 'New feature', ['feature']), + pr(7, 'iOS improvement', ['internal', 'platform: iOS']), + pr(9, 'Android improvement', ['type: accepted/enhancement', 'platform: Android']), + pr(10, 'Cross platform', ['feature', 'platform: iOS', 'platform: Android']), + pr(15, 'Uncategorized'), + ], + }); + assert.match(markdown, /# 8\.9\.0/); + assert.match(markdown, new RegExp(`Source commit: \\[${sha}\\]`)); + assert.match(markdown, /compare\/v8\.8\.9\.\.\.[a-f0-9]{40}/); + assert.ok( + markdown.indexOf('[New feature](https://github.com/wix/react-native-navigation/pull/4)') < + markdown.indexOf('/pull/10') + ); + assert.match(markdown, /## Features[\s\S]*### iOS only[\s\S]*### Android only/); + assert.match(markdown, /## Enhancements[\s\S]*### iOS only[\s\S]*### Android only/); + assert.match(markdown, /## Fixed[\s\S]*Fix bug/); + assert.match(markdown, /## Other[\s\S]*Uncategorized/); + assert.doesNotMatch(markdown, /example\.com/); +}); + +test('omits skipped and unmerged PRs and deduplicates by number deterministically', () => { + const markdown = renderNotes({ + version: '8.9.0', + previousTag: '', + sourceSha: sha, + pullRequests: [ + pr(8, 'z duplicate'), + pr(3, 'skip this', ['skip-changelog']), + pr(6, 'not merged', [], { merged_at: null }), + pr(8, 'a duplicate'), + ], + }); + assert.equal((markdown.match(/\/pull\/8/g) || []).length, 1); + assert.match(markdown, /\[a duplicate\]/); + assert.doesNotMatch(markdown, /skip this|not merged|z duplicate|Full changes/); +}); + +test('escapes title, author, and version content and uses the requested canonical repository', () => { + const markdown = renderNotes({ + version: '8.9.0