From 9b12bae5084f5480b1c31fa88797dd2ff00547af Mon Sep 17 00:00:00 2001 From: Melissa Vallee Date: Tue, 29 Sep 2026 10:24:50 -0400 Subject: [PATCH] chore: automate npm publishing with trusted provenance --- .github/workflows/ci.yml | 12 ++- .github/workflows/release.yml | 80 ++++++++++++++++ RELEASE.md | 52 ++++++++++ package.json | 4 +- scripts/check-package.mjs | 95 +++++++++++++++++++ scripts/check-release.mjs | 43 +++++++++ scripts/publish-package.mjs | 168 +++++++++++++++++++++++++++++++++ scripts/test-release.mjs | 173 ++++++++++++++++++++++++++++++++++ 8 files changed, 623 insertions(+), 4 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/test-release.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c0da6e..1435313 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,15 @@ jobs: - name: Setup uses: ./.github/actions/setup - - name: Build package - run: yarn prepare + - name: Test release safeguards + run: node --test scripts/test-release.mjs + + - name: Build and inspect npm package + run: | + yarn prepare + mkdir artifacts + npm pack --ignore-scripts --pack-destination artifacts + node scripts/check-package.mjs artifacts/*.tgz build-android: runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f867fe1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,80 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: read + +concurrency: + group: npm-release + cancel-in-progress: false + +jobs: + package: + runs-on: ubuntu-latest + outputs: + filename: ${{ steps.pack.outputs.filename }} + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + fetch-depth: 0 + - name: Setup + uses: ./.github/actions/setup + - name: Validate candidate before native CI + run: | + git merge-base --is-ancestor HEAD origin/main + node scripts/check-release.mjs "$GITHUB_REF_NAME" + - name: Test release safeguards + run: node --test scripts/test-release.mjs + - name: Build package + run: yarn prepare + - name: Pack and inspect + id: pack + run: | + mkdir artifacts + 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 + uses: actions/upload-artifact@v4 + with: + name: npm-package + path: artifacts/*.tgz + if-no-files-found: error + retention-days: 7 + + verify: + needs: package + uses: ./.github/workflows/ci.yml + + publish: + needs: [package, verify] + runs-on: ubuntu-latest + environment: release + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - 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 }} + run: | + node --version + npm --version + node scripts/publish-package.mjs "./artifacts/$PACKAGE_FILENAME" "${GITHUB_REF_NAME#v}" diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..8d2ed77 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,52 @@ +# Release process + +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`. + +## 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-transformer-text-input@X.Y.Z version dist.integrity dist.attestations --json + npm view react-native-transformer-text-input 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. + +## 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. diff --git a/package.json b/package.json index d2b4185..a4fb861 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ ], "repository": { "type": "git", - "url": "git+https://github.com/AppAndFlow/react-native-transformer-text-input.git" + "url": "git+https://github.com/appandflow/react-native-transformer-text-input.git" }, "author": "Janic Duplessis (https://appandflow.com)", "license": "MIT", @@ -190,7 +190,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..14f1b27 --- /dev/null +++ b/scripts/check-package.mjs @@ -0,0 +1,95 @@ +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', + 'RNTransformerTextInput.podspec', + 'react-native.config.js', + 'src/NativeTransformerTextInputModule.ts', + 'src/TransformerTextInputDecoratorViewNativeComponent.ts', + 'src/TransformerTextInput.web.tsx', + 'lib/module/TransformerTextInput.web.js', + 'ios/TransformerTextInputModule.mm', + 'ios/TransformerTextInputDecoratorView.mm', + 'android/build.gradle', + 'android/src/main/jni/CMakeLists.txt', + 'android/src/main/jni/TransformerTextInputJni.cpp', + 'android/src/main/java/com/appandflow/transformertextinput/TransformerTextInputPackage.kt', + 'cpp/TransformerTextInputRuntime.cpp', + 'cpp/TransformerTextInputRuntime.h', +]) { + if (!entries.includes(`package/${required}`)) { + throw new Error(`Missing package file: ${required}`); + } +} + +function checkTarget(target) { + if (typeof target === 'string') { + if (!entries.includes(`package/${target.replace(/^\.\//, '')}`)) { + throw new Error(`Missing exported file: ${target}`); + } + } else if (target && typeof target === 'object') { + Object.values(target).forEach(checkTarget); + } +} +checkTarget(manifest.exports); +for (const field of ['main', 'types']) { + if (!manifest[field]) throw new Error(`Missing entrypoint: ${field}`); + checkTarget(manifest[field]); +} + +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, + ) || + /(?:^|\/)(?:__tests__|__mocks__|__fixtures__)(?:\/|$)/.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..a7efd33 --- /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-transformer-text-input'; +export const repositoryUrl = + 'git+https://github.com/appandflow/react-native-transformer-text-input.git'; + +export function checkRelease(manifest, tag) { + if (manifest.name !== packageName || manifest.private) { + throw new Error( + 'Expected the public react-native-transformer-text-input 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 0000000..5f34aa4 --- /dev/null +++ b/scripts/publish-package.mjs @@ -0,0 +1,168 @@ +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/test-release.mjs b/scripts/test-release.mjs new file mode 100644 index 0000000..4c0c1a9 --- /dev/null +++ b/scripts/test-release.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: '0.4.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, 'v0.4.1'); + checkRelease(manifest); + assert.throws(() => + checkRelease({ + ...manifest, + repository: { url: repositoryUrl.replace('appandflow', 'AppAndFlow') }, + }), + ); + assert.throws(() => checkRelease(manifest, 'v0.4.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 = '0.5.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); +});