From 4c34795c8a1c29d77b87e160ea4cb200084e3557 Mon Sep 17 00:00:00 2001 From: Melissa Vallee Date: Fri, 25 Sep 2026 14:46:18 -0400 Subject: [PATCH] chore: automate npm publishing --- .github/workflows/ci.yml | 12 ++- .github/workflows/release.yml | 150 ++++++++++++++++++++++++++++++++++ RELEASE.md | 107 ++++++++++++++++++++++++ package.json | 3 +- scripts/check-package.mjs | 112 +++++++++++++++++++++++++ scripts/check-release.mjs | 19 +++++ tsconfig.build.json | 1 + 7 files changed, 399 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 RELEASE.md create mode 100644 scripts/check-package.mjs create mode 100644 scripts/check-release.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65c2e59..3a00959 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,6 @@ name: CI on: + workflow_call: push: branches: - main @@ -54,8 +55,12 @@ jobs: - name: Setup uses: ./.github/actions/setup - - name: Build package - run: yarn prepare + - name: Build and inspect npm package + run: | + yarn build + mkdir artifacts + npm pack --ignore-scripts --pack-destination artifacts + node scripts/check-package.mjs artifacts/*.tgz build-android: runs-on: ubuntu-latest @@ -98,7 +103,6 @@ jobs: run: | /bin/bash -c "yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null" - - name: Cache Gradle if: env.turbo_cache_hit != 1 uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 @@ -112,7 +116,7 @@ jobs: - name: Build example for Android env: - JAVA_OPTS: "-XX:MaxHeapSize=6g" + JAVA_OPTS: '-XX:MaxHeapSize=6g' run: | yarn turbo run build:android --cache-dir="${{ env.TURBO_CACHE_DIR }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..eeeff22 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,150 @@ +name: Release + +on: + workflow_dispatch: + pull_request: + branches: + - main + paths: + - '.github/actions/setup/**' + - '.github/workflows/ci.yml' + - '.github/workflows/release.yml' + - 'package.json' + - 'scripts/check-package.mjs' + - 'scripts/check-release.mjs' + - 'tsconfig.build.json' + push: + tags: + - 'v*' + +permissions: + contents: read + +concurrency: + group: npm-release + cancel-in-progress: false + +jobs: + verify: + if: github.event_name != 'pull_request' + uses: ./.github/workflows/ci.yml + + package: + needs: verify + if: >- + always() && + (github.event_name == 'pull_request' || needs.verify.result == 'success') + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.0.0 + with: + fetch-depth: 0 + + - name: Setup + uses: ./.github/actions/setup + + - name: Validate release tag + if: github.event_name == 'push' + run: | + git merge-base --is-ancestor HEAD origin/main + node scripts/check-release.mjs "$GITHUB_REF_NAME" + + - name: Build package + run: yarn build + + - name: Pack and inspect + run: | + mkdir artifacts + npm pack --ignore-scripts --pack-destination artifacts + node scripts/check-package.mjs artifacts/*.tgz + + - name: Preserve verified package + uses: actions/upload-artifact@v4 + with: + name: npm-package + path: artifacts/*.tgz + if-no-files-found: error + retention-days: 1 + + publish: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + needs: package + runs-on: ubuntu-latest + environment: release + permissions: + contents: read + id-token: write + steps: + - name: Use Node.js 24 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + package-manager-cache: false + + - name: Download verified package + uses: actions/download-artifact@v4 + with: + name: npm-package + path: artifacts + + - name: Publish with npm trusted publishing + shell: bash + run: | + set -euo pipefail + + package_name=react-native-ease + version="${GITHUB_REF_NAME#v}" + tarball="artifacts/$package_name-$version.tgz" + dist_tag=latest + if [[ "$version" == *-* ]]; then dist_tag=next; fi + + local_integrity="sha512-$(openssl dgst -sha512 -binary "$tarball" | openssl base64 -A)" + registry_file="$RUNNER_TEMP/registry.json" + registry_error="$RUNNER_TEMP/registry-error.txt" + + if npm view "$package_name@$version" version dist.integrity --json > "$registry_file" 2> "$registry_error"; then + node -e ' + const fs = require("node:fs"); + const [file, version, integrity] = process.argv.slice(1); + const published = JSON.parse(fs.readFileSync(file, "utf8")); + if (published.version !== version || published["dist.integrity"] !== integrity) { + throw new Error("The existing registry version does not match this release tarball."); + } + ' "$registry_file" "$version" "$local_integrity" + echo "$package_name@$version already exists with matching integrity; skipping publish." + exit 0 + fi + + if ! grep -qE '(^|[^A-Z])E404([^0-9]|$)' "$registry_error"; then + cat "$registry_error" >&2 + echo "Registry lookup failed with an error other than E404; refusing to publish." >&2 + exit 1 + fi + + npm publish "$tarball" --provenance --access public --tag "$dist_tag" + + for attempt in {1..12}; do + if npm view "$package_name@$version" version dist.integrity --json > "$registry_file" && + npm view "$package_name" "dist-tags.$dist_tag" --json > "$RUNNER_TEMP/dist-tag.json" && + node -e ' + const fs = require("node:fs"); + const [releaseFile, tagFile, version, integrity] = process.argv.slice(1); + const published = JSON.parse(fs.readFileSync(releaseFile, "utf8")); + const tagged = JSON.parse(fs.readFileSync(tagFile, "utf8")); + process.exit( + published.version === version && + published["dist.integrity"] === integrity && + tagged === version + ? 0 + : 1, + ); + ' "$registry_file" "$RUNNER_TEMP/dist-tag.json" "$version" "$local_integrity"; then + exit 0 + fi + sleep 10 + done + + echo "Registry verification failed." >&2 + exit 1 diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..d200465 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,107 @@ +# Release process + +This repository publishes one npm package, `react-native-ease`. The example and +documentation site are not published to npm. Keep this document aligned with +[the Release workflow](.github/workflows/release.yml). + +## One-time trusted-publisher setup + +Configure a GitHub Actions trusted publisher in the npm package settings: + +| Field | Value | +| ----------------- | ------------------------------------ | +| Organization | `appandflow` | +| Repository | `react-native-ease` | +| Workflow filename | `release.yml` | +| Environment | `release` | +| Allowed action | Direct publishing with `npm publish` | + +The workflow uses npm's OpenID Connect integration and does not need an npm +write token. It publishes with Node 24 and npm provenance. See +[npm's trusted publishing guide](https://docs.npmjs.com/trusted-publishers/). + +Create the GitHub `release` environment, require a maintainer review, and limit +deployment tags to `v*`. Declaring the environment in the workflow does not +create its protection rules, and the npm trust relationship must also be +configured separately. + +## Prepare and verify a candidate + +Start from reviewed, current `main`. Check npm before choosing a version; a Git +tag or GitHub release does not prove that a package was published. + +```sh +git fetch origin --tags +npm view react-native-ease dist-tags --json +npm view react-native-ease versions --json +``` + +Use semantic versions. Prereleases such as `X.Y.Z-beta.N` publish to `next`; +stable versions publish to `latest`. + +Run the package checks before starting `release-it`: + +```sh +yarn install --immutable +yarn format:write +yarn lint +yarn test +yarn build +rm -rf artifacts +mkdir artifacts +npm pack --pack-destination artifacts +node scripts/check-package.mjs artifacts/react-native-ease-X.Y.Z.tgz +node scripts/check-release.mjs vX.Y.Z +``` + +CI also builds the Android, iOS, and tvOS examples. Device behavior affected by +the release still needs device validation. + +After this workflow has been merged into the default branch, use **Actions → +Release → Run workflow** for a publication-free integration test. The manual run +executes the full reusable CI workflow, builds and validates the npm tarball, +and uploads it as a one-day workflow artifact. The publish job only runs for a +matching pushed tag, so a manual run cannot enter the `release` environment or +publish to npm. + +The dry run cannot test npm's OIDC trust relationship because npm authenticates +the workflow only when `npm publish` runs. Use the first intentional prerelease +to validate trusted publishing end to end. + +## Tag and publish + +1. Run `yarn release X.Y.Z` from current `main`. `release-it` creates the release + commit and immutable `vX.Y.Z` tag, pushes them, and creates the GitHub release. + It does not publish to npm locally. +2. The tag starts the Release workflow. It reruns the complete CI suite for the + exact tag, builds and validates the npm tarball, and rejects a tag that differs + from `package.json` or is not contained in `origin/main`. +3. Review the checks and approve the protected `release` environment. The + publish job downloads and publishes the exact tarball verified by the package + job with npm provenance. +4. Confirm the package version, integrity, and dist-tag: + + ```sh + npm view react-native-ease@X.Y.Z version dist.integrity --json + npm view react-native-ease dist-tags --json + ``` + +The workflow safely skips an already-published version only when the registry's +integrity matches the candidate tarball. A rerun for an older version does not +move a newer dist-tag backward. + +## Recovery + +- If authentication fails, verify the npm publisher's organization, repository, + workflow filename, and environment. Do not add a long-lived npm token as a + workaround. +- If validation fails before publication, fix the issue on `main` and prepare a + new version. Never move a pushed release tag. +- If a publish is interrupted, query the exact npm version before retrying. The + workflow refuses registry errors other than a real missing-version response. +- npm versions cannot be overwritten. Publish a new version for any correction. +- Do not rerun an old release to change `latest` or `next` after a newer release + has advanced that dist-tag. + +Adding this workflow does not publish anything. npm publication begins only when +a matching release tag is pushed and the protected environment is approved. diff --git a/package.json b/package.json index 96fd820..17536bc 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "docs:build": "yarn --cwd docs build", "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib", "prepare": "bob build", + "build": "bob build", "test": "jest", "lint": "yarn lint:eslint && yarn lint:ts && yarn lint:ts:example", "lint:ts": "tsc", @@ -187,7 +188,7 @@ "tagName": "v${version}" }, "npm": { - "publish": true + "publish": false }, "github": { "release": true diff --git a/scripts/check-package.mjs b/scripts/check-package.mjs new file mode 100644 index 0000000..b4c72be --- /dev/null +++ b/scripts/check-package.mjs @@ -0,0 +1,112 @@ +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; + +const tarball = process.argv[2]; +if (!tarball || process.argv.length !== 3) { + throw new Error('Provide exactly one package tarball.'); +} + +const entries = execFileSync('tar', ['-tzf', tarball], { encoding: 'utf8' }) + .trim() + .split('\n'); +const entrySet = new Set(entries); +const manifest = JSON.parse( + execFileSync('tar', ['-xOf', tarball, 'package/package.json'], { + encoding: 'utf8', + }), +); +const source = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), +); + +if (manifest.name !== source.name || manifest.version !== source.version) { + throw new Error('Packed manifest name or version differs from the source.'); +} + +if (manifest.publishConfig?.registry !== 'https://registry.npmjs.org/') { + throw new Error('Package registry must be the public npm registry.'); +} + +for (const required of [ + 'README.md', + 'LICENSE', + 'src/index.tsx', + 'src/nativewind.ts', + 'src/uniwind.ts', + 'lib/module/index.js', + 'lib/module/nativewind.js', + 'lib/module/uniwind.js', + 'lib/typescript/src/index.d.ts', + 'lib/typescript/src/nativewind.d.ts', + 'lib/typescript/src/uniwind.d.ts', + 'Ease.podspec', + 'ios/EaseView.h', + 'ios/EaseView.mm', + 'android/src/main/AndroidManifest.xml', + 'android/src/main/java/com/ease/EasePackage.kt', + 'android/src/main/java/com/ease/EaseView.kt', + 'android/src/main/java/com/ease/EaseViewManager.kt', + 'skills/react-native-ease-refactor/SKILL.md', + '.claude-plugin/plugin.json', + '.claude-plugin/marketplace.json', +]) { + if (!entrySet.has(`package/${required}`)) { + throw new Error(`Missing package file: ${required}`); + } +} + +const packageTargets = new Set(); +const collectTargets = (value) => { + if (typeof value === 'string' && value.startsWith('./')) { + packageTargets.add(value.slice(2)); + return; + } + if (value && typeof value === 'object') { + Object.values(value).forEach(collectTargets); + } +}; + +collectTargets(manifest.main); +collectTargets(manifest.module); +collectTargets(manifest.types); +collectTargets(manifest.exports); + +for (const target of packageTargets) { + if (!entrySet.has(`package/${target}`)) { + throw new Error(`Missing package entry-point target: ${target}`); + } +} + +for (const entry of entries) { + if (/^package\/(?:android|ios)\/(?:.*\/)?build\//.test(entry)) { + throw new Error(`Unexpected native build output: ${entry}`); + } + if ( + /^package\/(?:example|docs|node_modules|scripts|artifacts|\.github)(?:\/|$)/.test( + entry, + ) || + /^package\/(?:src|lib\/(?:commonjs|module|typescript)\/src)\/__tests__(?:\/|$)/.test( + entry, + ) || + /^package\/lib\/typescript\/(?:docs|example)(?:\/|$)/.test(entry) + ) { + throw new Error(`Unexpected repository-only file: ${entry}`); + } +} + +for (const group of [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies', +]) { + for (const range of Object.values(manifest[group] ?? {})) { + if (typeof range === 'string' && range.startsWith('workspace:')) { + throw new Error('Unresolved workspace range in tarball.'); + } + } +} + +console.log( + `${manifest.name}@${manifest.version}: ${entries.length} package files verified`, +); diff --git a/scripts/check-release.mjs b/scripts/check-release.mjs new file mode 100644 index 0000000..26de466 --- /dev/null +++ b/scripts/check-release.mjs @@ -0,0 +1,19 @@ +import { readFileSync } from 'node:fs'; + +const tag = process.argv[2]; +const manifest = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), +); + +if (process.argv.length !== 3) { + throw new Error('Provide exactly one release tag.'); +} + +if ( + tag !== `v${manifest.version}` || + !/^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(tag) +) { + throw new Error('Release tag must exactly match the package version.'); +} + +console.log(`${tag}: package version verified`); diff --git a/tsconfig.build.json b/tsconfig.build.json index 3c0636a..8d5f707 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,4 +1,5 @@ { "extends": "./tsconfig", + "include": ["src"], "exclude": ["example", "lib"] }