diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20df82a..f964656 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,8 @@ on: - checks_requested concurrency: + # Keep the event name in the group: sharing one group across events once let + # a release-authorizing push run deadlock behind other events for 13 hours. group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} cancel-in-progress: true diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 199aa93..a6abfd0 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -13,6 +13,8 @@ permissions: contents: read concurrency: + # Keep the event name in the group: sharing one group across events once let + # a release-authorizing push run deadlock behind other events for 13 hours. group: docs-${{ github.event_name }}-${{ github.ref }} cancel-in-progress: true diff --git a/.github/workflows/exact-package-candidate.yml b/.github/workflows/exact-package-candidate.yml index 48daed7..ffa11d5 100644 --- a/.github/workflows/exact-package-candidate.yml +++ b/.github/workflows/exact-package-candidate.yml @@ -439,6 +439,8 @@ jobs: target: google_apis profile: pixel_2 script: | + # Gradle must run from the generated consumer directory, not the + # repository root, or it builds the wrong project. cd "$RUNNER_TEMP/ReorderableConsumer/android" && ./gradlew app:installDebug --no-daemon -PnewArchEnabled=true -PhermesEnabled=true adb shell am start -W -n com.reorderableconsumer/.MainActivity @@ -576,6 +578,9 @@ jobs: bundle install bundle exec pod install --project-directory=ios ) + # build-framework-cache must run before detox build: without a + # prebuilt framework cache, Detox compiles it lazily mid-build and + # the Apple contract build fails on hosted runners. yarn detox build-framework-cache yarn detox build --configuration '${{ matrix.configuration }}' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 74040ea..4db8598 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,22 +32,6 @@ jobs: - name: Setup uses: ./.github/actions/setup - - name: Require ordinary push gates for the same commit - env: - GH_TOKEN: ${{ github.token }} - RELEASE_COMMIT: ${{ github.event.workflow_run.head_sha }} - run: | - for workflow in ci.yml docs.yml; do - conclusion=$(gh run list \ - --workflow "$workflow" \ - --commit "$RELEASE_COMMIT" \ - --event push \ - --limit 10 \ - --json conclusion,status \ - --jq '[.[] | select(.status == "completed")][0].conclusion') - test "$conclusion" = success - done - - name: Select Changesets mode id: mode run: node scripts/select-release-mode.mjs diff --git a/docs/adr/0010-release-documentation-and-contribution-lifecycle.md b/docs/adr/0010-release-documentation-and-contribution-lifecycle.md index 85012b1..b2beb55 100644 --- a/docs/adr/0010-release-documentation-and-contribution-lifecycle.md +++ b/docs/adr/0010-release-documentation-and-contribution-lifecycle.md @@ -3,3 +3,5 @@ The first stable release is `1.0.0`, not `0.x`, because the v1 effort freezes the exported API and portable behaviour and therefore makes a SemVer stability promise. Releases accumulate through Changesets and are cut on demand from an automated release PR; CI packs once, proves the exact tarball according to ADR-0006, and publishes that artifact through npm trusted publishing, while an optional `next` channel carries prereleases. Rspress on GitHub Pages is the canonical documentation surface, backed by repository-authored guides, generated TypeScript API reference, and type-checked examples; the README remains a quick start. Release-surface pull requests require a changeset and the relevant full CI matrix unless a maintainer records a commit-scoped `No changeset needed` override, while contributor-facing guidance stays separate from maintainer-only agent and triage conventions. The project publishes its compatibility matrix but makes no maintenance cadence, SLA, continuity, or backport promise and adds no separate security policy for v1. + +Amended 2026-09-05 ([#58](https://github.com/thiagobrez/react-native-reorderable/issues/58)): fast-check authorization moved from runtime re-verification to the `main` ruleset. The release workflow no longer polls `gh run list` for ci.yml/docs.yml conclusions on the release commit; the ruleset (pull requests required, strict up-to-date checks, zero bypass actors) is the guarantee that every `main` SHA passed the fast set. The sha256-pinned tarball chain — `workflow_run` gated to `event == push && head_branch == main`, artifact download pinned to the triggering run id, and `publish-verified-candidate.mjs` re-asserting sha256/byte-length/sourceCommit — is the only runtime verification. diff --git a/docs/releasing.md b/docs/releasing.md index b11a0d8..6bce818 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -87,7 +87,7 @@ The release workflow intentionally has no npm token. Its publish job receives a Every user-visible package change carries a file from `yarn changeset`. After a fully green push to `main`, Changesets creates or updates `changeset-release/main`. This pull request is the on-demand release boundary: leave it open while accumulating changes and merge it when the release should happen. -Merging the release pull request causes the exact-package workflow to build one tarball and run that same tarball through package inspection, clean consumers, the supported React Native compatibility matrix, performance checks, and the four-engine device contract. Publication starts only after that workflow and the ordinary `CI` and `Documentation` workflows are green for the same commit. Immediately before npm, the publisher rechecks the tarball bytes, SHA-256, source commit, package name, and version against its manifest. +Merging the release pull request causes the exact-package workflow to build one tarball and run that same tarball through package inspection, clean consumers, the supported React Native compatibility matrix, performance checks, and the four-engine device contract. Publication starts only after that workflow succeeds; the `main` ruleset's required pull-request checks are what guarantee the commit already passed the fast set. Immediately before npm, the publisher rechecks the tarball bytes, SHA-256, source commit, package name, and version against its manifest. Changesets then creates the package tag and GitHub release. The same release workflow builds and deploys the documentation from that tagged commit. Stable versions use the npm `latest` tag. diff --git a/scripts/__tests__/ci-fixtures.test.mjs b/scripts/__tests__/ci-fixtures.test.mjs deleted file mode 100644 index 641fa34..0000000 --- a/scripts/__tests__/ci-fixtures.test.mjs +++ /dev/null @@ -1,223 +0,0 @@ -import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; -import { - chmodSync, - mkdtempSync, - readFileSync, - statSync, - writeFileSync, -} from 'node:fs'; -import { tmpdir } from 'node:os'; -import { resolve } from 'node:path'; -import test from 'node:test'; - -const repository = resolve(import.meta.dirname, '../..'); - -test('Jest resolves the package self-reference to source in a clean checkout', () => { - const metadata = JSON.parse( - readFileSync(resolve(repository, 'package.json'), 'utf8') - ); - - assert.ok( - metadata.jest.testEnvironmentOptions.customExportConditions.includes( - 'react-native-reorderable-source' - ) - ); - assert.equal( - metadata.jest.moduleNameMapper['^react-native-reorderable$'], - '/src/index.tsx' - ); -}); - -test('Scenario Lab permits the candidate tarball to update the temporary lockfile', () => { - const workflow = readFileSync( - resolve(repository, '.github/workflows/exact-package-candidate.yml'), - 'utf8' - ); - - assert.match( - workflow, - /install-candidate-in-example\.mjs[\s\S]*?yarn install --no-immutable/ - ); - assert.match( - workflow, - /npx pod-install[\s\S]*?patch-fmt-consteval\.mjs[\s\S]*?xcodebuild/ - ); - assert.match( - workflow, - /expo-modules-autolinking react-native-config[\s\S]*?--platform '\$\{\{ matrix\.platform \}\}'[\s\S]*?--json/ - ); - assert.match( - workflow, - /EVENT_NAME: \$\{\{ github\.event_name \}\}[\s\S]*?PUSH_BEFORE_SHA: \$\{\{ github\.event\.before \}\}[\s\S]*?sha=\$PUSH_BEFORE_SHA/ - ); -}); - -test('root-level Apple workflows run CocoaPods from the bare example', () => { - for (const workflowPath of [ - '.github/workflows/exact-package-candidate.yml', - '.github/workflows/physical-performance.yml', - ]) { - const workflow = readFileSync(resolve(repository, workflowPath), 'utf8'); - assert.match( - workflow, - /cd example[\s\S]*?bundle install[\s\S]*?bundle exec pod install --project-directory=ios/ - ); - } -}); - -test('release-authorizing push gates do not share concurrency with other events', () => { - for (const workflowPath of [ - '.github/workflows/ci.yml', - '.github/workflows/docs.yml', - '.github/workflows/exact-package-candidate.yml', - ]) { - const workflow = readFileSync(resolve(repository, workflowPath), 'utf8'); - - assert.match( - workflow, - /group: .*\$\{\{ github\.event_name \}\}.*\$\{\{ github\.ref \}\}/, - workflowPath - ); - } -}); - -test('Apple contract jobs prepare the Detox framework cache explicitly', () => { - const workflow = readFileSync( - resolve(repository, '.github/workflows/exact-package-candidate.yml'), - 'utf8' - ); - - assert.match( - workflow, - /yarn detox build-framework-cache[\s\S]*?yarn detox build --configuration/ - ); -}); - -test('manual Reassure runs compare against a prior commit', () => { - const workflow = readFileSync( - resolve(repository, '.github/workflows/exact-package-candidate.yml'), - 'utf8' - ); - - assert.match( - workflow, - /baseline_sha="\$\(git merge-base HEAD origin\/main\)"[\s\S]*?if \[\[ "\$baseline_sha" == "\$\(git rev-parse HEAD\)" \]\]; then[\s\S]*?baseline_sha="\$\(git rev-parse HEAD\^\)"/ - ); - assert.doesNotMatch( - workflow, - /echo "sha=\$\(git merge-base HEAD origin\/main\)"/ - ); -}); - -test('the minimum Android runtime invokes Gradle from its generated fixture', () => { - const workflow = readFileSync( - resolve(repository, '.github/workflows/exact-package-candidate.yml'), - 'utf8' - ); - - assert.match( - workflow, - /cd "\$RUNNER_TEMP\/ReorderableConsumer\/android" && \.\/gradlew app:installDebug/ - ); -}); - -test('the iOS floor check uses the oldest durable hosted runtime', () => { - const workflow = readFileSync( - resolve(repository, '.github/workflows/exact-package-candidate.yml'), - 'utf8' - ); - - assert.match( - workflow, - /minimum-ios-runtime:[\s\S]*?name: Nightly iOS 15\.1 deployment target on hosted iOS 18\.5[\s\S]*?runs-on: macos-15/ - ); - assert.match(workflow, /IPHONEOS_DEPLOYMENT_TARGET = 15\\\.1/); - assert.match(workflow, /SimRuntime\.iOS-18-5/); - assert.doesNotMatch(workflow, /runs-on: \[self-hosted, macOS, ios-15\.1\]/); -}); - -test('Expo clean consumers do not install the React Native community CLI', () => { - const fixture = mkdtempSync(resolve(tmpdir(), 'reorderable-consumer-')); - writeFileSync( - resolve(fixture, 'package.json'), - `${JSON.stringify({ dependencies: {} }, null, 2)}\n` - ); - - execFileSync( - process.execPath, - [ - resolve(repository, 'scripts/configure-clean-consumer.mjs'), - fixture, - resolve(repository, 'candidate.tgz'), - 'expo', - JSON.stringify({ - react: '19.2.3', - reactNative: '0.86.2', - reactNativeGestureHandler: '3.2.1', - reactNativeReanimated: '4.5.3', - reactNativeWorklets: '0.11.4', - }), - ], - { stdio: 'pipe' } - ); - - const metadata = JSON.parse( - readFileSync(resolve(fixture, 'package.json'), 'utf8') - ); - assert.equal( - metadata.devDependencies?.['@react-native-community/cli'], - undefined - ); -}); - -test('the autolinking verifier accepts Expo platform-specific output', () => { - execFileSync( - process.execPath, - [ - resolve(repository, 'scripts/verify-consumer-autolinking.mjs'), - '--platform', - 'ios', - ], - { - input: JSON.stringify({ - dependencies: { - 'react-native-reorderable': { - platforms: { ios: { podspecPath: 'Reorderable.podspec' } }, - }, - }, - }), - stdio: ['pipe', 'pipe', 'pipe'], - } - ); -}); - -test('the fmt workaround disables only the vulnerable 11.0.2 consteval branch', () => { - const fixture = mkdtempSync(resolve(tmpdir(), 'reorderable-fmt-')); - const header = resolve(fixture, 'base.h'); - writeFileSync( - header, - `#define FMT_VERSION 110002 -#elif defined(__cpp_consteval) -# define FMT_USE_CONSTEVAL 1 -#elif FMT_GCC_VERSION >= 1002 || FMT_CLANG_VERSION >= 1101 -# define FMT_USE_CONSTEVAL 1 -` - ); - chmodSync(header, 0o444); - - const patcher = resolve(repository, 'scripts/patch-fmt-consteval.mjs'); - execFileSync(process.execPath, [patcher, header]); - execFileSync(process.execPath, [patcher, header]); - - const patched = readFileSync(header, 'utf8'); - assert.match( - patched, - /#elif defined\(__cpp_consteval\)\n# define FMT_USE_CONSTEVAL 0/ - ); - assert.match( - patched, - /#elif FMT_GCC_VERSION >= 1002 \|\| FMT_CLANG_VERSION >= 1101\n# define FMT_USE_CONSTEVAL 1/ - ); - assert.equal(statSync(header).mode & 0o777, 0o444); -}); diff --git a/scripts/__tests__/consumer-scripts.test.mjs b/scripts/__tests__/consumer-scripts.test.mjs new file mode 100644 index 0000000..ee13648 --- /dev/null +++ b/scripts/__tests__/consumer-scripts.test.mjs @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { + chmodSync, + mkdtempSync, + readFileSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import test from 'node:test'; + +const repository = resolve(import.meta.dirname, '../..'); + +test('Expo clean consumers do not install the React Native community CLI', () => { + const fixture = mkdtempSync(resolve(tmpdir(), 'reorderable-consumer-')); + writeFileSync( + resolve(fixture, 'package.json'), + `${JSON.stringify({ dependencies: {} }, null, 2)}\n` + ); + + execFileSync( + process.execPath, + [ + resolve(repository, 'scripts/configure-clean-consumer.mjs'), + fixture, + resolve(repository, 'candidate.tgz'), + 'expo', + JSON.stringify({ + react: '19.2.3', + reactNative: '0.86.2', + reactNativeGestureHandler: '3.2.1', + reactNativeReanimated: '4.5.3', + reactNativeWorklets: '0.11.4', + }), + ], + { stdio: 'pipe' } + ); + + const metadata = JSON.parse( + readFileSync(resolve(fixture, 'package.json'), 'utf8') + ); + assert.equal( + metadata.devDependencies?.['@react-native-community/cli'], + undefined + ); +}); + +test('the autolinking verifier accepts Expo platform-specific output', () => { + execFileSync( + process.execPath, + [ + resolve(repository, 'scripts/verify-consumer-autolinking.mjs'), + '--platform', + 'ios', + ], + { + input: JSON.stringify({ + dependencies: { + 'react-native-reorderable': { + platforms: { ios: { podspecPath: 'Reorderable.podspec' } }, + }, + }, + }), + stdio: ['pipe', 'pipe', 'pipe'], + } + ); +}); + +test('the fmt workaround disables only the vulnerable 11.0.2 consteval branch', () => { + const fixture = mkdtempSync(resolve(tmpdir(), 'reorderable-fmt-')); + const header = resolve(fixture, 'base.h'); + writeFileSync( + header, + `#define FMT_VERSION 110002 +#elif defined(__cpp_consteval) +# define FMT_USE_CONSTEVAL 1 +#elif FMT_GCC_VERSION >= 1002 || FMT_CLANG_VERSION >= 1101 +# define FMT_USE_CONSTEVAL 1 +` + ); + chmodSync(header, 0o444); + + const patcher = resolve(repository, 'scripts/patch-fmt-consteval.mjs'); + execFileSync(process.execPath, [patcher, header]); + execFileSync(process.execPath, [patcher, header]); + + const patched = readFileSync(header, 'utf8'); + assert.match( + patched, + /#elif defined\(__cpp_consteval\)\n# define FMT_USE_CONSTEVAL 0/ + ); + assert.match( + patched, + /#elif FMT_GCC_VERSION >= 1002 \|\| FMT_CLANG_VERSION >= 1101\n# define FMT_USE_CONSTEVAL 1/ + ); + assert.equal(statSync(header).mode & 0o777, 0o444); +}); diff --git a/src/__tests__/issue39-device-contract.test.ts b/src/__tests__/issue39-device-contract.test.ts index 34b90b2..ed01f9b 100644 --- a/src/__tests__/issue39-device-contract.test.ts +++ b/src/__tests__/issue39-device-contract.test.ts @@ -625,7 +625,6 @@ describe('issue 39 portable device contract', () => { it('pins the exact four engine/runtime configurations and retains recordings', () => { const config = read('.detoxrc.cjs'); - const workflow = read('.github/workflows/exact-package-candidate.yml'); const isolatedRunner = read('scripts/run-device-contract-isolated.mjs'); const jobRunner = read('scripts/run-device-contract-job.mjs'); @@ -636,7 +635,6 @@ describe('issue 39 portable device contract', () => { 'android.fallback', ]) { expect(config).toContain(`'${configuration}'`); - expect(workflow).toContain(configuration); } expect(read('package.json')).toContain('"detox": "20.51.4"'); expect(read('e2e/jest.config.cjs')).toContain( @@ -660,8 +658,6 @@ describe('issue 39 portable device contract', () => { ); expect(isolatedRunner).toContain("'--record-videos'"); expect(isolatedRunner).toContain("'--take-screenshots'"); - expect(workflow).toContain('if: always()'); - expect(workflow).not.toContain('--retries'); expect(jobRunner).toContain('index <= 2'); expect(jobRunner).toContain('ISSUE39_MATRIX_ATTEMPT: attempt'); expect(jobRunner).toContain('publishSuccessfulAttempt(attempt)'); @@ -670,10 +666,6 @@ describe('issue 39 portable device contract', () => { expect(isolatedRunner).toContain('reachedContractAssertion'); expect(isolatedRunner).toContain("failureKind === 'infrastructure'"); expect(isolatedRunner).toContain('infrastructureFailure ? 75 : 1'); - expect(workflow).toContain('timeout-minutes: 150'); - expect(workflow).toContain('api-level: 36'); - expect(workflow).toContain('profile: pixel_2'); - expect(workflow).not.toContain('profile: pixel_7_pro'); }); it('keeps the physical iOS 27 VoiceOver record tied to the named public contract', () => { @@ -725,7 +717,6 @@ describe('issue 39 portable device contract', () => { const jobRunner = read('scripts/run-device-contract-job.mjs'); const iosReset = read('scripts/reset-device-contract-ios-simulator.mjs'); const detoxConfig = read('.detoxrc.cjs'); - const workflow = read('.github/workflows/exact-package-candidate.yml'); expect(runner.indexOf('onActionStarted?.();')).toBeLessThan( runner.indexOf('await actionStarted;') @@ -813,6 +804,5 @@ describe('issue 39 portable device contract', () => { expect(iosReset).toContain("['simctl', 'boot', target.udid]"); expect(iosReset).toContain("['simctl', 'bootstatus', target.udid, '-b']"); expect(iosReset).not.toContain("['simctl', 'shutdown', 'all']"); - expect(workflow).toContain('scripts/run-device-contract-job.mjs'); }); });