Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
name: CI
on:
workflow_call:
push:
branches:
- main
Expand Down Expand Up @@ -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
Expand Down
80 changes: 80 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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}"
52 changes: 52 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <janic@appandflow.com> (https://appandflow.com)",
"license": "MIT",
Expand Down Expand Up @@ -190,7 +190,7 @@
"tagName": "v${version}"
},
"npm": {
"publish": true
"publish": false
},
"github": {
"release": true
Expand Down
95 changes: 95 additions & 0 deletions scripts/check-package.mjs
Original file line number Diff line number Diff line change
@@ -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`,
);
43 changes: 43 additions & 0 deletions scripts/check-release.mjs
Original file line number Diff line number Diff line change
@@ -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`,
);
}
Loading
Loading