From 50f4a863d875b78f17f57444d126d241e544e587 Mon Sep 17 00:00:00 2001 From: Melissa Vallee Date: Mon, 28 Sep 2026 16:29:51 -0400 Subject: [PATCH 1/6] chore: automate npm publishing with trusted provenance --- .github/workflows/android.yml | 27 ++++-- .github/workflows/ios.yml | 19 +++- .github/workflows/js.yml | 24 ++++- .github/workflows/release.yml | 139 +++++++++++++++++++++++++++ RELEASE.md | 128 +++++++++++++++++++++++++ package.json | 3 +- scripts/check-package.mjs | 88 +++++++++++++++++ scripts/check-release.mjs | 43 +++++++++ scripts/publish-package.mjs | 164 ++++++++++++++++++++++++++++++++ scripts/release.test.mjs | 173 ++++++++++++++++++++++++++++++++++ 10 files changed, 790 insertions(+), 18 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 create mode 100644 scripts/publish-package.mjs create mode 100644 scripts/release.test.mjs diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 76005f25..af6a2e80 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -1,6 +1,11 @@ name: Android build on: + workflow_call: + inputs: + ref: + type: string + required: false push: branches: - main @@ -28,26 +33,28 @@ jobs: shell: bash steps: - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} - name: Use Node.js 22.13.0 uses: actions/setup-node@v4 with: node-version: 22.13.0 - - uses: actions/setup-java@v2 + - uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: '17' - name: Restore yarn workspaces id: yarn-cache - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: | node_modules */*/node_modules key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} - name: Install dependencies - run: yarn install + run: yarn install --frozen-lockfile - name: Install example app dependencies - run: yarn install + run: yarn install --frozen-lockfile working-directory: example - name: Build android example app with new arch disabled run: ./.github/scripts/build-android.sh false @@ -62,26 +69,28 @@ jobs: shell: bash steps: - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} - name: Use Node.js 22.13.0 - uses: actions/setup-node@v1 + uses: actions/setup-node@v4 with: node-version: 22.13.0 - - uses: actions/setup-java@v2 + - uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: '17' - name: Restore yarn workspaces id: yarn-cache - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: | node_modules */*/node_modules key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} - name: Install dependencies - run: yarn install + run: yarn install --frozen-lockfile - name: Install example app dependencies - run: yarn install + run: yarn install --frozen-lockfile working-directory: example - name: Build android example app with new arch enabled run: ./.github/scripts/build-android.sh true diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 957e0e9b..627649c4 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -1,6 +1,11 @@ name: iOS build on: + workflow_call: + inputs: + ref: + type: string + required: false push: branches: - main @@ -24,6 +29,8 @@ jobs: runs-on: macos-latest steps: - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} - name: Use Node.js 22.13.0 uses: actions/setup-node@v4 with: @@ -34,9 +41,9 @@ jobs: working-directory: example bundler-cache: true - name: Install dependencies - run: yarn install + run: yarn install --frozen-lockfile - name: Install example app dependencies - run: yarn install + run: yarn install --frozen-lockfile working-directory: example - name: Install pods run: RCT_NEW_ARCH_ENABLED=0 npx pod-install @@ -48,8 +55,10 @@ jobs: runs-on: macos-latest steps: - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} - name: Use Node.js 22.13.0 - uses: actions/setup-node@v1 + uses: actions/setup-node@v4 with: node-version: 22.13.0 - uses: ruby/setup-ruby@v1 @@ -58,11 +67,11 @@ jobs: working-directory: example bundler-cache: true - name: Install dependencies - run: yarn install + run: yarn install --frozen-lockfile - name: Verify SwiftPM manifest run: yarn jest src/__tests__/swiftpm-test.ts - name: Install example app dependencies - run: yarn install + run: yarn install --frozen-lockfile working-directory: example - name: Install pods for new arch run: RCT_NEW_ARCH_ENABLED=1 npx pod-install diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index 73057b46..2d751b2c 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -1,25 +1,43 @@ name: JavaScript tests -on: [push, pull_request] +on: + workflow_call: + inputs: + ref: + type: string + required: false + push: + branches: + - '**' + pull_request: jobs: js-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} - name: Use Node.js 22.13.0 uses: actions/setup-node@v4 with: node-version: 22.13.0 - name: Restore yarn workspaces id: yarn-cache - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: | node_modules */*/node_modules key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} - name: Install dependencies - run: yarn install + run: yarn install --frozen-lockfile - name: Run tests run: yarn test + - name: Test release safeguards + run: node --test scripts/*.test.mjs + - name: Build and inspect npm package + run: | + mkdir artifacts + npm pack --pack-destination artifacts + node scripts/check-package.mjs artifacts/*.tgz diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..37275782 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,139 @@ +name: Release + +on: + workflow_dispatch: + inputs: + publish: + description: 'Publish the selected v* tag (false runs checks only)' + type: boolean + default: false + pull_request: + paths: + - '.github/workflows/release.yml' + - '.github/workflows/js.yml' + - '.github/workflows/android.yml' + - '.github/workflows/ios.yml' + - 'scripts/**' + - 'package.json' + push: + tags: + - 'v*' + +permissions: + contents: read + +concurrency: + group: ${{ (github.event_name == 'push' || inputs.publish) && 'npm-release' || format('release-check-{0}', github.ref) }} + cancel-in-progress: false + +jobs: + package: + runs-on: ubuntu-latest + outputs: + sha: ${{ steps.candidate.outputs.sha }} + version: ${{ steps.candidate.outputs.version }} + publish: ${{ steps.candidate.outputs.publish }} + filename: ${{ steps.pack.outputs.filename }} + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + fetch-depth: 0 + - name: Use Node.js 24 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24 + package-manager-cache: false + - name: Validate candidate before native CI + id: candidate + env: + PUBLISH: ${{ github.event_name == 'push' || inputs.publish == true }} + run: | + set -euo pipefail + if [[ "$PUBLISH" == true ]]; then + [[ "$GITHUB_REF" == refs/tags/v* ]] || { echo 'Select a v* tag to publish, not a branch.' >&2; exit 1; } + git merge-base --is-ancestor HEAD origin/main + node scripts/check-release.mjs "$GITHUB_REF_NAME" + else + node scripts/check-release.mjs + fi + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + echo "version=$(node -p 'require("./package.json").version')" >> "$GITHUB_OUTPUT" + echo "publish=$PUBLISH" >> "$GITHUB_OUTPUT" + - name: Enable the pinned Yarn version + run: | + corepack enable + corepack prepare yarn@1.22.22 --activate + - name: Install dependencies + run: yarn install --frozen-lockfile + - name: Test release safeguards + run: node --test scripts/*.test.mjs + - name: Build package + run: yarn build + - name: Pack and inspect + id: pack + run: | + mkdir artifacts + npm pack --ignore-scripts --pack-destination artifacts --json > "$RUNNER_TEMP/pack.json" + filename=$(node -e 'const fs=require("node:fs"); const [p]=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); console.log(p.filename)' "$RUNNER_TEMP/pack.json") + node scripts/check-package.mjs "./artifacts/$filename" + echo "filename=$filename" >> "$GITHUB_OUTPUT" + - name: Preserve verified package + uses: actions/upload-artifact@v4 + with: + name: npm-package + path: artifacts/*.tgz + if-no-files-found: error + retention-days: 7 + + verify-javascript: + if: github.event_name != 'pull_request' + needs: package + uses: ./.github/workflows/js.yml + with: + ref: ${{ needs.package.outputs.sha }} + + verify-android: + if: github.event_name != 'pull_request' + needs: package + uses: ./.github/workflows/android.yml + with: + ref: ${{ needs.package.outputs.sha }} + + verify-ios: + if: github.event_name != 'pull_request' + needs: package + uses: ./.github/workflows/ios.yml + with: + ref: ${{ needs.package.outputs.sha }} + + publish: + if: needs.package.outputs.publish == 'true' + needs: [package, verify-javascript, verify-android, verify-ios] + runs-on: ubuntu-latest + environment: release + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + ref: ${{ needs.package.outputs.sha }} + - 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 and verify with npm trusted publishing + env: + PACKAGE_FILENAME: ${{ needs.package.outputs.filename }} + RELEASE_VERSION: ${{ needs.package.outputs.version }} + run: | + node --version + npm --version + node scripts/publish-package.mjs "./artifacts/$PACKAGE_FILENAME" "$RELEASE_VERSION" diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 00000000..644527af --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,128 @@ +# Release process + +This repository publishes `react-native-safe-area-context` to npm through +[Release](.github/workflows/release.yml). The example and docs are not published. +Existing Android release-APK generation remains a separate GitHub release job. + +## One-time setup + +In npm package Settings → Trusted Publisher, configure GitHub Actions: + +| Field | Value | +| ----------------- | -------------------------------- | +| Organization | `appandflow` | +| Repository | `react-native-safe-area-context` | +| Workflow filename | `release.yml` | +| Environment | `release` | +| Allowed action | Direct `npm publish` | + +Save and verify the connection; npm may require security-key authentication. +The publisher is the workflow identity, not a human maintainer. No npm write +token is needed. See [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/). + +Create GitHub's `release` environment with an appropriate maintainer reviewer +and deployment **tag** rule `v*`. Confirm the saved configuration before a +release. Workflow YAML alone does not create these protection rules. + +`package.json` uses `git+https://github.com/appandflow/react-native-safe-area-context.git`. +Keep this exact casing: npm provenance compares it with GitHub's repository +identity. The release preflight checks this before expensive native builds. + +## Prepare a candidate + +Start from current `main` and check npm before choosing the version: + +```sh +git fetch origin --tags +npm view react-native-safe-area-context dist-tags --json --registry=https://registry.npmjs.org/ +``` + +Stable versions publish to `latest`; prereleases publish to `next`. Follow the +repository's review requirements for both code and version changes. `main` +requires an approved PR; do not bypass that requirement to make a release. + +Run the existing checks plus release safeguards: + +```sh +yarn install --frozen-lockfile +yarn test +node --test scripts/*.test.mjs +yarn build +mkdir -p artifacts +npm pack --ignore-scripts --pack-destination artifacts +node scripts/check-package.mjs artifacts/react-native-safe-area-context-X.Y.Z.tgz +node scripts/check-release.mjs vX.Y.Z +``` + +The final two commands use the candidate's actual version. The package check +verifies CommonJS/ESM/declarations, native sources, codegen sources, Swift package +manifest, podspec, Jest mock, entrypoints, registry, and repository identity. +CI retains Android legacy/Fabric builds on Linux/Windows and both iOS builds. +Device behavior affected by the release still needs device validation. + +After the workflow is merged, run Actions → Release → Run workflow with +`publish` **false** for a dry run. PR changes to the release infrastructure also +run package safeguards; the existing JavaScript/native workflows keep their PR +triggers, avoiding duplicate native builds in Release. These runs never enter the release +environment or publish. They cannot prove npm's OIDC trust relationship; the +next intentional release provides that verification. + +## Release after review + +Merge the reviewed version change before tagging. The existing `release-it` +configuration no longer publishes locally. If its direct push would bypass +branch review, prepare the version through a PR instead and create the tag on +that merged commit: + +```sh +git switch main +git pull --ff-only +node scripts/check-release.mjs vX.Y.Z +git tag -a vX.Y.Z -m 'Release X.Y.Z' +git push origin vX.Y.Z +gh release create vX.Y.Z --verify-tag --generate-notes --title 'Release X.Y.Z' +``` + +A tag starts Release. Cheap metadata and tarball checks run first; all reusable +CI workflows then check the same resolved commit. Only after every check passes +can a reviewer approve the `release` environment. Publish downloads that exact +artifact and uses npm OIDC with provenance; it does not rebuild. Artifacts are +kept for seven days. + +Confirm the exact version, integrity, provenance, and dist-tag: + +```sh +npm view react-native-safe-area-context@X.Y.Z version dist.integrity dist.attestations --json --registry=https://registry.npmjs.org/ +npm view react-native-safe-area-context dist-tags --json --registry=https://registry.npmjs.org/ +``` + +## Retry and recovery + +- If the package and workflow are correct and the artifact still exists, use + `gh run rerun RUN_ID --failed`. This avoids repeating native CI. Approve the + environment again when required. An existing matching version is verified + and skipped; a different integrity fails. Retrying an old version never moves + a newer dist-tag backward. +- To run manually, select the existing `vX.Y.Z` **tag as the workflow ref**, then + set `publish` to true. CLI equivalent: + `gh workflow run release.yml --ref vX.Y.Z -F publish=true`. + A manual publish on a branch fails preflight before native builds. Checking + out a tag inside a run on `main` would not satisfy the `v*` environment rule. +- An expired artifact requires a fresh run of the tag. A rerun uses its original + workflow revision: merging a workflow fix does not update an old run. Never + move an already-published tag. If an unpublished version must be preserved + while correcting its source/workflow, obtain explicit maintainer agreement + for the recovery rather than silently retagging or changing deployment rules. +- npm may accept a package but take several minutes to expose it. The workflow + polls for ten minutes. If it reports publication accepted but verification + pending, inspect the exact registry version first. Once it appears, rerun + failed jobs to verify the matching artifact; do not blindly republish or bump. +- For OIDC permission errors, verify the saved npm owner/repo/workflow/environment + and direct-publish permission. Do not introduce a long-lived npm token. +- For E422 provenance failures, check repository URL casing in the tarball. + For a failed job with no logs/steps, inspect check annotations and environment + rules before diagnosing npm. + +Adding this workflow does not publish a version. Publication requires a pushed +release tag or an explicit manual publish on an existing release tag, successful +checks, and release-environment approval. diff --git a/package.json b/package.json index d5537ce7..192a753a 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "format:spotless:write": "cd android && ./gradlew spotlessApply", "format:check": "yarn format:prettier:check && yarn format:clang:check && yarn format:spotless:check", "format:write": "yarn format:prettier:write && yarn format:clang:write && yarn format:spotless:write", + "build": "bob build", "release": "release-it", "prepare": "bob build" }, @@ -95,7 +96,7 @@ }, "repository": { "type": "git", - "url": "https://github.com/AppAndFlow/react-native-safe-area-context.git" + "url": "git+https://github.com/appandflow/react-native-safe-area-context.git" }, "jest": { "preset": "@react-native/jest-preset", diff --git a/scripts/check-package.mjs b/scripts/check-package.mjs new file mode 100644 index 00000000..d9e6f99b --- /dev/null +++ b/scripts/check-package.mjs @@ -0,0 +1,88 @@ +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { checkRelease } from './check-release.mjs'; + +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 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.'); +} + +checkRelease(manifest); + +for (const required of [ + 'README.md', + 'LICENSE', + 'src/index.tsx', + 'src/specs/NativeSafeAreaProvider.ts', + 'src/specs/NativeSafeAreaView.ts', + 'lib/commonjs/index.js', + 'lib/module/index.js', + 'lib/typescript/src/index.d.ts', + 'react-native-safe-area-context.podspec', + 'Package.swift', + 'react-native.config.js', + 'ios/RNCSafeAreaContext.mm', + 'android/build.gradle', + 'common/cpp/react/renderer/components/safeareacontext/RNCSafeAreaViewShadowNode.cpp', + 'jest/mock.js', +]) { + if (!entries.includes(`package/${required}`)) { + throw new Error(`Missing package file: ${required}`); + } +} + +for (const field of ['main', 'module', 'types', 'react-native', 'source']) { + const target = manifest[field]; + if (!target || !entries.includes(`package/${target.replace(/^\.\//, '')}`)) { + throw new Error(`Missing ${field} 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, + ) + ) { + 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 00000000..e86da29f --- /dev/null +++ b/scripts/check-release.mjs @@ -0,0 +1,43 @@ +import { readFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; + +export const packageName = 'react-native-safe-area-context'; +export const repositoryUrl = + 'git+https://github.com/appandflow/react-native-safe-area-context.git'; + +export function checkRelease(manifest, tag) { + if (manifest.name !== packageName || manifest.private) { + throw new Error( + 'Expected the public react-native-safe-area-context package.', + ); + } + if (manifest.repository?.url !== repositoryUrl) { + throw new Error( + 'Repository URL must match GitHub casing for npm provenance.', + ); + } + if (manifest.publishConfig?.registry !== 'https://registry.npmjs.org/') { + throw new Error('Package registry must be the public npm registry.'); + } + if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(manifest.version)) { + throw new Error('Expected a release version without build metadata.'); + } + if (tag !== undefined && tag !== `v${manifest.version}`) { + throw new Error('Release tag must exactly match the package version.'); + } +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + if (process.argv.length > 3) + throw new Error('Provide at most one release tag.'); + const manifest = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), + ); + checkRelease(manifest, process.argv[2]); + console.log( + `${manifest.name}@${manifest.version}: release metadata verified`, + ); +} diff --git a/scripts/publish-package.mjs b/scripts/publish-package.mjs new file mode 100644 index 00000000..e23e6a01 --- /dev/null +++ b/scripts/publish-package.mjs @@ -0,0 +1,164 @@ +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { checkRelease } from './check-release.mjs'; + +const registry = 'https://registry.npmjs.org/'; + +export function parseLookup(stdout, failed = false) { + const result = JSON.parse(stdout); + if (failed) { + if (result?.error?.code === 'E404') return null; + throw new Error( + `Registry lookup failed: ${result?.error?.code ?? 'unknown'}`, + ); + } + if ( + !result || + typeof result !== 'object' || + Array.isArray(result) || + result.error + ) { + throw new Error('Unexpected registry response; refusing to infer absence.'); + } + return result; +} + +function lookup(args) { + try { + return parseLookup( + execFileSync( + 'npm', + ['view', ...args, '--json', `--registry=${registry}`], + { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30000, + }, + ), + ); + } catch (error) { + // Only structured npm E404 is absence; timeouts, malformed responses, + // authorization errors and registry outages must not trigger publication. + if (typeof error.stdout === 'string' && error.stdout) { + return parseLookup(error.stdout, true); + } + throw error; + } +} + +export function assertMatching(record, version, integrity) { + if (record?.version !== version || record?.['dist.integrity'] !== integrity) { + throw new Error( + 'Existing registry version differs from the verified tarball.', + ); + } +} + +export async function publishVerified({ + version, + integrity, + readVersion, + readTags, + publish, + sleep = (ms) => new Promise((done) => setTimeout(done, ms)), + log = console.log, + attempts = 40, +}) { + const distTag = version.includes('-') ? 'next' : 'latest'; + const existing = await readVersion(); + if (existing) { + assertMatching(existing, version, integrity); + if (!existing['dist.attestations']?.provenance) { + throw new Error( + 'Matching registry package has no provenance; inspect before retrying.', + ); + } + const tags = await readTags(); + log( + `Version ${version} already exists with matching integrity and provenance; skipping publish. ${distTag}=${tags?.[distTag] ?? ''}.`, + ); + return 'existing'; + } + + await publish(distTag); + log( + 'npm accepted publication. Waiting up to ten minutes for registry processing.', + ); + const deadline = Date.now() + 10 * 60 * 1000; + for ( + let attempt = 0; + attempt < attempts && Date.now() < deadline; + attempt += 1 + ) { + await sleep(15000); + let record; + let tags; + try { + record = await readVersion(); + if (!record) continue; + tags = await readTags(); + } catch (error) { + // Registry reads can fail transiently after acceptance. Never publish twice. + log(`Verification pending: ${error.message}`); + continue; + } + assertMatching(record, version, integrity); + if ( + record['dist.attestations']?.provenance && + tags?.[distTag] === version + ) { + log(`Verified ${version}: integrity, ${distTag}, and provenance.`); + return 'published'; + } + } + throw new Error( + 'npm accepted publication, but registry verification is still pending. Check the exact version before retrying. Once visible, rerun failed jobs with the same artifact; do not bump or republish blindly.', + ); +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + if (process.argv.length !== 4) + throw new Error('Provide a tarball and expected version.'); + const tarball = resolve(process.argv[2]); + const version = process.argv[3]; + const manifest = JSON.parse( + execFileSync('tar', ['-xOf', tarball, 'package/package.json'], { + encoding: 'utf8', + }), + ); + checkRelease(manifest, `v${version}`); + const integrity = `sha512-${createHash('sha512').update(readFileSync(tarball)).digest('base64')}`; + await publishVerified({ + version, + integrity, + readVersion: () => + lookup([ + `${manifest.name}@${version}`, + 'version', + 'dist.integrity', + 'dist.attestations', + ]), + readTags: () => lookup([manifest.name, 'dist-tags']), + publish: (distTag) => + execFileSync( + 'npm', + [ + 'publish', + tarball, + '--provenance', + '--access', + 'public', + '--tag', + distTag, + `--registry=${registry}`, + ], + { stdio: 'inherit' }, + ), + }); +} diff --git a/scripts/release.test.mjs b/scripts/release.test.mjs new file mode 100644 index 00000000..e47fe638 --- /dev/null +++ b/scripts/release.test.mjs @@ -0,0 +1,173 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { checkRelease, packageName, repositoryUrl } from './check-release.mjs'; +import { parseLookup, publishVerified } from './publish-package.mjs'; + +const manifest = { + name: packageName, + version: '5.10.1', + repository: { url: repositoryUrl }, + publishConfig: { registry: 'https://registry.npmjs.org/' }, +}; +const record = { + version: manifest.version, + 'dist.integrity': 'sha512-verified', + 'dist.attestations': { + provenance: { predicateType: 'https://slsa.dev/provenance/v1' }, + }, +}; +const options = { + version: manifest.version, + integrity: record['dist.integrity'], + readTags: async () => ({ latest: manifest.version }), + sleep: async () => {}, + log: () => {}, + attempts: 3, +}; + +test('metadata rejects the provenance casing failure and wrong release tag', () => { + checkRelease(manifest, 'v5.10.1'); + checkRelease(manifest); + assert.throws(() => + checkRelease({ + ...manifest, + repository: { url: repositoryUrl.replace('appandflow', 'AppAndFlow') }, + }), + ); + assert.throws(() => checkRelease(manifest, 'v5.10.0')); + assert.throws(() => checkRelease({ ...manifest, private: true })); + assert.throws(() => checkRelease({ ...manifest, name: 'example' })); +}); + +test('only a structured E404 means missing, not auth/network/malformed failures', () => { + assert.equal(parseLookup('{"error":{"code":"E404"}}', true), null); + for (const code of ['E401', 'E403', 'E500', 'ECONNRESET']) { + assert.throws(() => parseLookup(JSON.stringify({ error: { code } }), true)); + } + assert.throws(() => parseLookup('not JSON', true)); + assert.throws(() => parseLookup('null')); + assert.throws(() => parseLookup('[]')); +}); + +test('already published matching package skips publish and preserves a newer dist-tag', async () => { + let publishes = 0; + const result = await publishVerified({ + ...options, + readVersion: async () => record, + readTags: async () => ({ latest: '6.0.0' }), + publish: async () => { + publishes += 1; + }, + }); + assert.equal(result, 'existing'); + assert.equal(publishes, 0); +}); + +test('existing mismatched package cannot be republished', async () => { + let publishes = 0; + await assert.rejects( + publishVerified({ + ...options, + readVersion: async () => ({ + ...record, + 'dist.integrity': 'sha512-other', + }), + publish: async () => { + publishes += 1; + }, + }), + ); + assert.equal(publishes, 0); +}); + +test('registry outage before publish does not cause a write', async () => { + let publishes = 0; + await assert.rejects( + publishVerified({ + ...options, + readVersion: async () => { + throw new Error('E500'); + }, + publish: async () => { + publishes += 1; + }, + }), + ); + assert.equal(publishes, 0); +}); + +test('processing delay and transient verification error still publish exactly once', async () => { + let reads = 0; + const tags = []; + const result = await publishVerified({ + ...options, + readVersion: async () => { + reads += 1; + if (reads < 3) return null; + if (reads === 3) throw new Error('temporary network failure'); + return record; + }, + publish: async (tag) => { + tags.push(tag); + }, + }); + assert.equal(result, 'published'); + assert.deepEqual(tags, ['latest']); +}); + +test('accepted but unavailable package times out without republishing', async () => { + let publishes = 0; + await assert.rejects( + publishVerified({ + ...options, + readVersion: async () => null, + publish: async () => { + publishes += 1; + }, + }), + /accepted publication.*pending/, + ); + assert.equal(publishes, 1); +}); + +test('prerelease publishes to next and requires provenance', async () => { + const version = '5.11.0-beta.1'; + let reads = 0; + const tags = []; + await publishVerified({ + ...options, + version, + readVersion: async () => (++reads === 1 ? null : { ...record, version }), + readTags: async () => ({ next: version }), + publish: async (tag) => { + tags.push(tag); + }, + }); + assert.deepEqual(tags, ['next']); + await assert.rejects( + publishVerified({ + ...options, + readVersion: async () => ({ ...record, 'dist.attestations': undefined }), + publish: async () => assert.fail('must not publish'), + }), + /provenance/, + ); +}); + +test('post-publish integrity mismatch fails without consuming the retry window', async () => { + let reads = 0; + let publishes = 0; + await assert.rejects( + publishVerified({ + ...options, + readVersion: async () => + ++reads === 1 ? null : { ...record, 'dist.integrity': 'sha512-other' }, + publish: async () => { + publishes += 1; + }, + }), + /differs/, + ); + assert.equal(reads, 2); + assert.equal(publishes, 1); +}); From 5febb5b54913c708552c6b2a1fa9cca371c8a899 Mon Sep 17 00:00:00 2001 From: Melissa Vallee Date: Mon, 28 Sep 2026 16:31:35 -0400 Subject: [PATCH 2/6] fix: use compatible build runtime for release preflight --- .github/workflows/release.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 37275782..305ed592 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,10 +38,10 @@ jobs: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 with: fetch-depth: 0 - - name: Use Node.js 24 + - name: Use the repository build runtime uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: - node-version: 24 + node-version: 22.13.0 package-manager-cache: false - name: Validate candidate before native CI id: candidate @@ -73,8 +73,8 @@ jobs: id: pack run: | mkdir artifacts - npm pack --ignore-scripts --pack-destination artifacts --json > "$RUNNER_TEMP/pack.json" - filename=$(node -e 'const fs=require("node:fs"); const [p]=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); console.log(p.filename)' "$RUNNER_TEMP/pack.json") + npm pack --ignore-scripts --pack-destination artifacts + filename=$(node -e 'const fs=require("node:fs"); const files=fs.readdirSync("artifacts").filter(f=>f.endsWith(".tgz")); if(files.length!==1) throw new Error("Expected exactly one tarball"); console.log(files[0])') node scripts/check-package.mjs "./artifacts/$filename" echo "filename=$filename" >> "$GITHUB_OUTPUT" - name: Preserve verified package From b0d5d20ca9d9cd5da904ffccb3a64b6c77bdc33c Mon Sep 17 00:00:00 2001 From: Melissa Vallee Date: Mon, 28 Sep 2026 16:38:51 -0400 Subject: [PATCH 3/6] chore: release 5.10.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0fd8b06f..71e10726 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-safe-area-context", - "version": "5.10.0", + "version": "5.10.1", "description": "A flexible way to handle safe area, also works on Android and web.", "main": "lib/commonjs/index.js", "module": "lib/module/index.js", From f59a838b7710bc932c131174c10c08f66756b225 Mon Sep 17 00:00:00 2001 From: Melissa Vallee Date: Mon, 28 Sep 2026 16:48:02 -0400 Subject: [PATCH 4/6] Revert "chore: release 5.10.1" This reverts commit b0d5d20ca9d9cd5da904ffccb3a64b6c77bdc33c. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 71e10726..0fd8b06f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-safe-area-context", - "version": "5.10.1", + "version": "5.10.0", "description": "A flexible way to handle safe area, also works on Android and web.", "main": "lib/commonjs/index.js", "module": "lib/module/index.js", From 2ea614eb200de6aedecda25a9ae0da0841f30507 Mon Sep 17 00:00:00 2001 From: Melissa Vallee Date: Mon, 28 Sep 2026 16:51:12 -0400 Subject: [PATCH 5/6] chore: simplify publishing to tag-triggered releases --- .github/workflows/release.yml | 39 +------- RELEASE.md | 170 ++++++++++++---------------------- 2 files changed, 61 insertions(+), 148 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 305ed592..fe385412 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,20 +1,6 @@ name: Release on: - workflow_dispatch: - inputs: - publish: - description: 'Publish the selected v* tag (false runs checks only)' - type: boolean - default: false - pull_request: - paths: - - '.github/workflows/release.yml' - - '.github/workflows/js.yml' - - '.github/workflows/android.yml' - - '.github/workflows/ios.yml' - - 'scripts/**' - - 'package.json' push: tags: - 'v*' @@ -23,7 +9,7 @@ permissions: contents: read concurrency: - group: ${{ (github.event_name == 'push' || inputs.publish) && 'npm-release' || format('release-check-{0}', github.ref) }} + group: npm-release cancel-in-progress: false jobs: @@ -31,8 +17,6 @@ jobs: runs-on: ubuntu-latest outputs: sha: ${{ steps.candidate.outputs.sha }} - version: ${{ steps.candidate.outputs.version }} - publish: ${{ steps.candidate.outputs.publish }} filename: ${{ steps.pack.outputs.filename }} steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 @@ -45,20 +29,10 @@ jobs: package-manager-cache: false - name: Validate candidate before native CI id: candidate - env: - PUBLISH: ${{ github.event_name == 'push' || inputs.publish == true }} run: | - set -euo pipefail - if [[ "$PUBLISH" == true ]]; then - [[ "$GITHUB_REF" == refs/tags/v* ]] || { echo 'Select a v* tag to publish, not a branch.' >&2; exit 1; } - git merge-base --is-ancestor HEAD origin/main - node scripts/check-release.mjs "$GITHUB_REF_NAME" - else - node scripts/check-release.mjs - fi + git merge-base --is-ancestor HEAD origin/main + node scripts/check-release.mjs "$GITHUB_REF_NAME" echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - echo "version=$(node -p 'require("./package.json").version')" >> "$GITHUB_OUTPUT" - echo "publish=$PUBLISH" >> "$GITHUB_OUTPUT" - name: Enable the pinned Yarn version run: | corepack enable @@ -86,28 +60,24 @@ jobs: retention-days: 7 verify-javascript: - if: github.event_name != 'pull_request' needs: package uses: ./.github/workflows/js.yml with: ref: ${{ needs.package.outputs.sha }} verify-android: - if: github.event_name != 'pull_request' needs: package uses: ./.github/workflows/android.yml with: ref: ${{ needs.package.outputs.sha }} verify-ios: - if: github.event_name != 'pull_request' needs: package uses: ./.github/workflows/ios.yml with: ref: ${{ needs.package.outputs.sha }} publish: - if: needs.package.outputs.publish == 'true' needs: [package, verify-javascript, verify-android, verify-ios] runs-on: ubuntu-latest environment: release @@ -132,8 +102,7 @@ jobs: - name: Publish and verify with npm trusted publishing env: PACKAGE_FILENAME: ${{ needs.package.outputs.filename }} - RELEASE_VERSION: ${{ needs.package.outputs.version }} run: | node --version npm --version - node scripts/publish-package.mjs "./artifacts/$PACKAGE_FILENAME" "$RELEASE_VERSION" + node scripts/publish-package.mjs "./artifacts/$PACKAGE_FILENAME" "${GITHUB_REF_NAME#v}" diff --git a/RELEASE.md b/RELEASE.md index 644527af..e76cfb6a 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,8 +1,10 @@ # Release process -This repository publishes `react-native-safe-area-context` to npm through -[Release](.github/workflows/release.yml). The example and docs are not published. -Existing Android release-APK generation remains a separate GitHub release job. +Pushing a `vX.Y.Z` tag starts [Release](.github/workflows/release.yml). +The workflow validates the tag, repository metadata, and packed files, then runs +JavaScript, Android, and iOS checks on the same commit. After approval of the +`release` environment, it publishes the saved tarball through npm trusted +publishing with provenance. Stable releases use `latest`; prereleases use `next`. ## One-time setup @@ -16,113 +18,55 @@ In npm package Settings → Trusted Publisher, configure GitHub Actions: | Environment | `release` | | Allowed action | Direct `npm publish` | -Save and verify the connection; npm may require security-key authentication. -The publisher is the workflow identity, not a human maintainer. No npm write -token is needed. See [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/). - -Create GitHub's `release` environment with an appropriate maintainer reviewer -and deployment **tag** rule `v*`. Confirm the saved configuration before a -release. Workflow YAML alone does not create these protection rules. - -`package.json` uses `git+https://github.com/appandflow/react-native-safe-area-context.git`. -Keep this exact casing: npm provenance compares it with GitHub's repository -identity. The release preflight checks this before expensive native builds. - -## Prepare a candidate - -Start from current `main` and check npm before choosing the version: - -```sh -git fetch origin --tags -npm view react-native-safe-area-context dist-tags --json --registry=https://registry.npmjs.org/ -``` - -Stable versions publish to `latest`; prereleases publish to `next`. Follow the -repository's review requirements for both code and version changes. `main` -requires an approved PR; do not bypass that requirement to make a release. - -Run the existing checks plus release safeguards: - -```sh -yarn install --frozen-lockfile -yarn test -node --test scripts/*.test.mjs -yarn build -mkdir -p artifacts -npm pack --ignore-scripts --pack-destination artifacts -node scripts/check-package.mjs artifacts/react-native-safe-area-context-X.Y.Z.tgz -node scripts/check-release.mjs vX.Y.Z -``` - -The final two commands use the candidate's actual version. The package check -verifies CommonJS/ESM/declarations, native sources, codegen sources, Swift package -manifest, podspec, Jest mock, entrypoints, registry, and repository identity. -CI retains Android legacy/Fabric builds on Linux/Windows and both iOS builds. -Device behavior affected by the release still needs device validation. - -After the workflow is merged, run Actions → Release → Run workflow with -`publish` **false** for a dry run. PR changes to the release infrastructure also -run package safeguards; the existing JavaScript/native workflows keep their PR -triggers, avoiding duplicate native builds in Release. These runs never enter the release -environment or publish. They cannot prove npm's OIDC trust relationship; the -next intentional release provides that verification. - -## Release after review - -Merge the reviewed version change before tagging. The existing `release-it` -configuration no longer publishes locally. If its direct push would bypass -branch review, prepare the version through a PR instead and create the tag on -that merged commit: - -```sh -git switch main -git pull --ff-only -node scripts/check-release.mjs vX.Y.Z -git tag -a vX.Y.Z -m 'Release X.Y.Z' -git push origin vX.Y.Z -gh release create vX.Y.Z --verify-tag --generate-notes --title 'Release X.Y.Z' -``` - -A tag starts Release. Cheap metadata and tarball checks run first; all reusable -CI workflows then check the same resolved commit. Only after every check passes -can a reviewer approve the `release` environment. Publish downloads that exact -artifact and uses npm OIDC with provenance; it does not rebuild. Artifacts are -kept for seven days. - -Confirm the exact version, integrity, provenance, and dist-tag: - -```sh -npm view react-native-safe-area-context@X.Y.Z version dist.integrity dist.attestations --json --registry=https://registry.npmjs.org/ -npm view react-native-safe-area-context dist-tags --json --registry=https://registry.npmjs.org/ -``` - -## Retry and recovery - -- If the package and workflow are correct and the artifact still exists, use - `gh run rerun RUN_ID --failed`. This avoids repeating native CI. Approve the - environment again when required. An existing matching version is verified - and skipped; a different integrity fails. Retrying an old version never moves - a newer dist-tag backward. -- To run manually, select the existing `vX.Y.Z` **tag as the workflow ref**, then - set `publish` to true. CLI equivalent: - `gh workflow run release.yml --ref vX.Y.Z -F publish=true`. - A manual publish on a branch fails preflight before native builds. Checking - out a tag inside a run on `main` would not satisfy the `v*` environment rule. -- An expired artifact requires a fresh run of the tag. A rerun uses its original - workflow revision: merging a workflow fix does not update an old run. Never - move an already-published tag. If an unpublished version must be preserved - while correcting its source/workflow, obtain explicit maintainer agreement - for the recovery rather than silently retagging or changing deployment rules. -- npm may accept a package but take several minutes to expose it. The workflow - polls for ten minutes. If it reports publication accepted but verification - pending, inspect the exact registry version first. Once it appears, rerun - failed jobs to verify the matching artifact; do not blindly republish or bump. -- For OIDC permission errors, verify the saved npm owner/repo/workflow/environment - and direct-publish permission. Do not introduce a long-lived npm token. -- For E422 provenance failures, check repository URL casing in the tarball. - For a failed job with no logs/steps, inspect check annotations and environment - rules before diagnosing npm. - -Adding this workflow does not publish a version. Publication requires a pushed -release tag or an explicit manual publish on an existing release tag, successful -checks, and release-environment approval. +Save and verify the connection. Create GitHub's `release` environment with a +maintainer reviewer and deployment **tag** rule `v*`. No npm write token is +needed. See [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/). + +Keep the canonical lowercase repository URL in `package.json`; npm provenance +checks it against the GitHub repository identity. + +## Release + +1. Check npm's current versions and prepare the version bump in a separate PR. +2. Merge it after review and successful CI. Local npm publishing remains disabled + in `release-it`. +3. Tag the merged commit, using the actual version in place of `X.Y.Z`: + + ```sh + git switch main + git pull --ff-only + node scripts/check-release.mjs vX.Y.Z + git tag -a vX.Y.Z -m 'Release X.Y.Z' + git push origin vX.Y.Z + ``` + +4. Approve the `release` environment after checks pass. Confirm npm publication: + + ```sh + npm view react-native-safe-area-context@X.Y.Z version dist.integrity dist.attestations --json + npm view react-native-safe-area-context dist-tags --json + ``` + +5. Create the GitHub release: + + ```sh + gh release create vX.Y.Z --verify-tag --generate-notes --title 'Release X.Y.Z' + ``` + +Normal PR CI tests the release safeguards and inspects the package. Merging a PR +alone does not publish. Existing Android release-APK generation remains separate +and runs when the GitHub release is published. + +## Recovery + +- Use `gh run rerun RUN_ID --failed` to retry a failed publish with the saved + artifact, retained for seven days. Matching published versions are skipped; + integrity mismatches fail. Retrying never moves a newer dist-tag backward. +- npm processing can take several minutes. Verification polls for up to ten + minutes after acceptance. If it times out, check the exact registry version + before retrying; do not blindly republish or bump the version. +- If the artifact expired, rerun all jobs. Reruns use the original workflow + revision. Never move an already-published tag. +- For authentication failures, check the saved npm owner, repository, workflow, + environment, and direct-publish permission. For provenance failures, check the + repository URL casing in the tarball. From 34693d440bf29343fbadf67734eaf8059977e313 Mon Sep 17 00:00:00 2001 From: Melissa Vallee Date: Mon, 28 Sep 2026 17:03:54 -0400 Subject: [PATCH 6/6] docs: remove completed publishing setup instructions --- RELEASE.md | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index e76cfb6a..ff0a2d07 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -6,25 +6,6 @@ JavaScript, Android, and iOS checks on the same commit. After approval of the `release` environment, it publishes the saved tarball through npm trusted publishing with provenance. Stable releases use `latest`; prereleases use `next`. -## One-time setup - -In npm package Settings → Trusted Publisher, configure GitHub Actions: - -| Field | Value | -| ----------------- | -------------------------------- | -| Organization | `appandflow` | -| Repository | `react-native-safe-area-context` | -| Workflow filename | `release.yml` | -| Environment | `release` | -| Allowed action | Direct `npm publish` | - -Save and verify the connection. Create GitHub's `release` environment with a -maintainer reviewer and deployment **tag** rule `v*`. No npm write token is -needed. See [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/). - -Keep the canonical lowercase repository URL in `package.json`; npm provenance -checks it against the GitHub repository identity. - ## Release 1. Check npm's current versions and prepare the version bump in a separate PR.