Skip to content
Merged
11 changes: 2 additions & 9 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,6 @@ on:
description: 'One successful full CI run ID for this exact SHA'
required: true
type: string
acceptance_comment:
description: 'GitHub issue comment ID containing maintainer physical acceptance JSON'
required: true
type: string

concurrency:
group: npm-release
Expand All @@ -33,7 +29,6 @@ concurrency:
permissions:
contents: read
actions: read
issues: read

jobs:
release:
Expand All @@ -44,13 +39,11 @@ jobs:
permissions:
contents: write
actions: read
issues: read
id-token: write
env:
RELEASE_SOURCE: ${{ inputs.source_sha }}
RELEASE_VERSION: ${{ inputs.version }}
RELEASE_RUNS: ${{ inputs.ci_runs }}
RELEASE_ACCEPTANCE: ${{ inputs.acceptance_comment }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
Expand All @@ -74,13 +67,13 @@ jobs:
- name: Test readiness gate offline
run: node --test scripts/release-readiness.test.mjs

- name: Verify source, actual mobile evidence, package and physical acceptance
- name: Verify source, actual mobile evidence and package
env:
GH_TOKEN: ${{ github.token }}
run: >-
node scripts/release-readiness.mjs
--source "$RELEASE_SOURCE" --version "$RELEASE_VERSION"
--runs "$RELEASE_RUNS" --acceptance "$RELEASE_ACCEPTANCE"
--runs "$RELEASE_RUNS"
--out "$RUNNER_TEMP/verified-release"

- name: Preserve verified release report and tested tarball
Expand Down
94 changes: 41 additions & 53 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,12 @@
The prepared `main` revision is the release candidate. `CHANGELOG.md` is the
release-note source of truth. This procedure does not authorize a version change,
tag, dispatch, or publication by itself. Historical runs and local or offline
checks cannot replace a successful run of the prepared source or physical
acceptance.
checks cannot replace a successful run of the prepared source.

## Prepare an unpublished exact version

1. Finish the implementation and its independent review. Final acceptance must
use the exact prepared source and version. Release candidates use an explicit
1. Finish the implementation and its independent review. The full CI run must use
the exact prepared source and version. Release candidates use an explicit
`1.0.0-rc.N`, and the stable release uses `1.0.0`. Never use
`npm version prerelease` to infer v1.
2. In a separately reviewed preparation change, use
Expand All @@ -25,10 +24,12 @@ acceptance.
with both mobile suites enabled. The run must completely succeed on attempt 1.
A failed-jobs rerun is not release evidence. Do not change assertions, retries,
the 120-second case deadlines, or the 45-minute mobile job deadlines.
If the [physical iPhone check](#physical-iphone-check) applies, do it on the
same SHA.

RC and stable preparation each change source identity. Evidence for an RC does
not authorize publishing a subsequently bumped stable package. Repeat full
acceptance on the stable source. If `main` moves before publication, prepare and
not authorize publishing a subsequently bumped stable package. Repeat the full
CI run on the stable source. If `main` moves before publication, prepare and
test the intended current revision again; never rewrite evidence to match it.

## Dependency advisories
Expand Down Expand Up @@ -138,57 +139,43 @@ complete WDIO summary and every passing case title (including duplicate counts)
A success badge, hand-entered count or summary without case output cannot pass.
Missing/expired artifacts or logs and changed reporter formats fail closed.
Recycling, detach and RTL remain mandatory in this same-source 62-case E2E
inventory on both platforms. They are automated evidence requirements,
not extra physical smoke confirmations; removing their manual keys does not remove
any automated cases, assertions or required jobs.

## Explicit physical acceptance

A repository admin or maintainer must record a JSON issue comment after testing
the exact prepared source/package on a physical iPhone. Supply that comment's
numeric ID, not an issue ID, to the workflow. Simulator, unit, parser and harness
passes cannot create or imply this acceptance. Do not paste a passing template
without performing the checks. The required JSON fields are:

- `schemaVersion`: `1`; `verdict`: `"accepted"`; `platform`: `"physical-ios"`
- `sourceSha`: full prepared SHA; `version`: exact prepared version
- `packageIntegrity`: the tested package report's SHA-512 integrity
- `device`: model (no serial/UDID); `os`: tested iOS version; `testedAt`: ISO timestamp
- `checks`: an object with each of the following keys explicitly set to `"passed"`

Keep the physical smoke short, with seven grouped confirmations:

- `defaultAppearance`: inspect the default row and action appearance.
- `physicalSwipesAndActions`: swipe both edges and tap the revealed actions.
- `autoHaptics`: feel Auto feedback, including repeated threshold crossings within
one gesture to confirm feedback is not repeated.
- `hapticsOn`: feel explicitly enabled feedback.
- `hapticsOff`: confirm explicitly disabled feedback stays silent.
- `hapticSuppression`: confirm programmatic changes, cancellation and auto-close
do not produce unintended feedback.
- `reduceMotion`: change Reduce Motion while the app is running and check its
live effect on motion and Auto feedback.

The comment author must be a human with current admin/maintain permission, and
the acceptance comment must be **unedited**. Corrections require a new comment
and its new numeric ID, even if the original author makes the correction.
The gate resolves the REST comment's `node_id` through
[GitHub's global node lookup](https://docs.github.com/en/graphql/guides/using-global-node-ids)
and matches the returned comment ID, author and exact body. GitHub's
[IssueComment fields](https://docs.github.com/en/graphql/reference/issues#issuecomment)
must report `lastEditedAt: null`, `editor: null` and `includesCreatedEdit: false`.
Missing metadata, GraphQL errors and mismatched snapshots fail closed. REST's
`user` and `updated_at` alone do not establish who wrote the final body. The test
time must precede or equal comment creation. Keep unresolved observations out of
an accepted record; document them as blockers instead.
inventory on both platforms. They are automated evidence requirements.

## Physical iPhone check

Simulators cannot show haptics. Run this check on a physical iPhone with the
prepared SHA in these cases. The publish workflow does not check it.

- No earlier check SHA is recorded.
- Since the last check SHA, one of these changed: `ios/` code, TypeScript code for
haptics, appearance or Reduce Motion, or the React Native or Expo version in
`example/package.json`.

Steps:


1. Build the example app in Release configuration on the iPhone.
2. Inspect the default row and action appearance.
3. Swipe both edges and tap the revealed actions.
4. Feel Auto feedback. Cross the threshold several times in one gesture, and make
sure the feedback does not repeat.
5. Feel feedback with haptics on. Make sure nothing plays with haptics off.
6. Make sure programmatic changes, cancellation and auto-close play no feedback.
7. Change Reduce Motion while the app runs. Check its live effect on motion, and
make sure Auto feedback does not change.

If a check fails, fix it and prepare the release again. If all checks pass, write
the tested SHA, the device and the iOS version in a comment on the preparation PR.
The next check compares against that SHA.

## Verify, then publish

Configure the GitHub `npm-release` environment with required human reviewers and
main-only deployment rules, and npm trusted publishing for this workflow. Retain
Configure the GitHub `npm-release` environment with a main-only deployment rule,
and npm trusted publishing for this workflow. A required reviewer is optional. It
adds an approval step before each run. Retain
the reviewed `main` workflow as the authority. Dispatch **Publish to npm** from
the prepared `main` revision with `operation=verify`, full `source_sha`, exact
`version`, `ci_runs=ID`, and `acceptance_comment=ID`. Verification does not tag
`version`, and `ci_runs=ID`. Verification does not tag
or publish. It saves a verified report and the tested tarball. The source package,
lockfile, changelog entry, tarball and eventual `v<version>` tag must agree.

Expand Down Expand Up @@ -242,7 +229,8 @@ version and its `v<version>` tag are permanent records.
Release that names the problem and the replacement.
5. Fix the problem on `main` through the normal review. Then prepare the next exact
version, for example `1.0.0-rc.<N+1>` after a bad RC. Run the complete procedure
on that source: fresh full CI, physical acceptance, verify, then publish. No
on that source: fresh full CI, the physical iPhone check if it applies, verify,
then publish. No
evidence of the bad version carries over. The publish of the corrective version
moves its dist-tag to it.
6. The readiness gate and the publish workflow accept only `1.0.0-rc.N` and
Expand Down
7 changes: 5 additions & 2 deletions docs/support.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,11 @@ the UIKit view on the simulator.

- iOS versions below the simulator runtime above, and Android API levels other than 34.
- Physical Android devices for 1.0. Earlier 0.x benchmarks used a Pixel 8 Pro.
- Haptics and Reduce Motion on a physical iPhone. The maintainer checks them by
hand on each release candidate. This page will name the device for 1.0.0.
- Automated tests of how haptics feel and how Reduce Motion behaves on a device.
The maintainer checks them by hand on a physical iPhone. The check runs when no
earlier check is recorded, and after a change to the iOS code, the haptics,
appearance or Reduce Motion code, or the example app's React Native or Expo
version.

If you run the package on another configuration, an issue with the result helps,
whether it works or not.
Expand Down
2 changes: 1 addition & 1 deletion e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

The mobile suites exercise the demo through WebdriverIO and Appium. The command
ownership regressions exercise the installed test framework and HTTP transport
offline; they do not replace Android, iOS, or physical-device acceptance.
offline; they do not replace Android, iOS, or the physical iPhone check.

## Offline harness checks

Expand Down
74 changes: 5 additions & 69 deletions scripts/release-readiness.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,6 @@ export const REQUIRED_JOBS = Object.freeze([
'iOS / performance', 'iOS / rtl', 'iOS / swipe-back', 'iOS / swipeable', 'iOS / two-sided',
'e2e-ios'
])
export const PHYSICAL_CHECKS = Object.freeze([
'defaultAppearance', 'physicalSwipesAndActions', 'autoHaptics', 'hapticsOn',
'hapticsOff', 'hapticSuppression', 'reduceMotion'
])
const packageName = 'react-native-swipeable-actions'
const shaPattern = /^[a-f0-9]{40}$/
const idPattern = /^[1-9][0-9]*$/
Expand Down Expand Up @@ -118,61 +114,6 @@ export function validateAndroidLog(log, manifest) {
assert.equal(seen.size, 5)
}

// REST's user identifies the original author, not whoever last edited the body.
// Resolve its global node ID and require GitHub's edit-specific metadata.
export async function fetchCommentEditMetadata(nodeId, token, request = fetch) {
assert.ok(typeof nodeId === 'string' && nodeId.length > 0, 'Missing comment node ID')
const response = await request('https://api.github.com/graphql', {
method: 'POST',
headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `query AcceptanceComment($id: ID!) {
node(id: $id) {
__typename
... on IssueComment {
id body lastEditedAt includesCreatedEdit
author { login }
editor { login }
}
}
}`,
variables: { id: nodeId }
}),
signal: AbortSignal.timeout(120000)
})
assert.ok(response.ok, 'GitHub comment edit metadata request failed: ' + response.status)
const result = await response.json()
assert.ok(!result.errors?.length, 'GitHub comment edit metadata returned GraphQL errors')
assert.ok(result.data?.node, 'GitHub comment edit metadata unavailable')
return result.data.node
}

export function validatePhysicalAcceptance(comment, permission, { sourceSha, version, packageIntegrity }) {
assert.equal(comment.user?.type, 'User', 'Physical acceptance must be a human statement')
assert.ok(['admin', 'maintain'].includes(permission), 'Physical acceptance must be from a maintainer')
const metadata = comment.editMetadata
assert.equal(metadata?.__typename, 'IssueComment', 'Authoritative comment edit metadata required')
assert.ok(typeof comment.node_id === 'string' && comment.node_id.length > 0, 'Missing comment node ID')
assert.equal(metadata.id, comment.node_id, 'Edit metadata belongs to another comment')
assert.equal(metadata.body, comment.body, 'Comment changed while fetching acceptance; retry with a new statement')
assert.equal(metadata.author?.login, comment.user.login, 'Comment author differs between API responses')
assert.equal(metadata.lastEditedAt, null, 'Physical acceptance must be unedited; post a new maintainer statement')
assert.equal(metadata.editor, null, 'Physical acceptance has an editor; post a new maintainer statement')
assert.equal(metadata.includesCreatedEdit, false, 'Physical acceptance has edit history; post a new maintainer statement')
const report = JSON.parse(comment.body)
assert.equal(report.schemaVersion, 1)
assert.equal(report.verdict, 'accepted', 'Physical acceptance is incomplete')
assert.equal(report.sourceSha, sourceSha, 'Physical acceptance is for another source')
assert.equal(report.version, version, 'Physical acceptance is for another version')
assert.equal(report.packageIntegrity, packageIntegrity, 'Physical acceptance is for another package')
assert.equal(report.platform, 'physical-ios', 'Simulator evidence cannot establish haptic acceptance')
for (const field of ['device', 'os']) assert.ok(typeof report[field] === 'string' && report[field].trim())
const testedAt = Date.parse(report.testedAt)
assert.ok(Number.isFinite(testedAt) && testedAt <= Date.parse(comment.created_at), 'Invalid physical test time')
for (const check of PHYSICAL_CHECKS) assert.equal(report.checks?.[check], 'passed', 'Physical check not accepted: ' + check)
return { commentId: comment.id, reviewer: comment.user.login, testedAt: report.testedAt }
}

export function validateReadiness(input) {
const release = validateSource(input)
assert.equal(input.runs.length, 1, 'Exactly one fresh, fully green full CI run required')
Expand All @@ -194,15 +135,14 @@ export function validateReadiness(input) {
}
validateAndroidLog(androidLog, manifest)
const packageIntegrity = validatePackage(report, bytes, input.version, input.sourceSha, packedPackage)
const acceptance = validatePhysicalAcceptance(input.comment, input.permission, { ...input, packageIntegrity })
return { schemaVersion: 1, sourceSha: input.sourceSha, version: input.version, ...release,
packageIntegrity, ciRuns: [run.id], acceptance }
packageIntegrity, ciRuns: [run.id] }
}

async function main(args) {
assert.equal(args.length, 10, 'Expected --source SHA --version VERSION --runs ID --acceptance COMMENT_ID --out DIRECTORY')
const options = Object.fromEntries(Array.from({ length: 5 }, (_, i) => [args[i * 2], args[i * 2 + 1]]))
assert.deepEqual(Object.keys(options).sort(), ['--acceptance', '--out', '--runs', '--source', '--version'])
assert.equal(args.length, 8, 'Expected --source SHA --version VERSION --runs ID --out DIRECTORY')
const options = Object.fromEntries(Array.from({ length: 4 }, (_, i) => [args[i * 2], args[i * 2 + 1]]))
assert.deepEqual(Object.keys(options).sort(), ['--out', '--runs', '--source', '--version'])
const sourceSha = options['--source']
const version = options['--version']
const repository = process.env.GITHUB_REPOSITORY
Expand All @@ -219,7 +159,7 @@ async function main(args) {
validateSource(input)
const runIds = options['--runs'].split(',')
assert.equal(runIds.length, 1, 'Exactly one full CI run ID required')
for (const id of [...runIds, options['--acceptance']]) assert.match(id, idPattern)
for (const id of runIds) assert.match(id, idPattern)
const base = 'https://api.github.com/repos/' + repository
const request = async (route) => {
const response = await fetch(base + route, {
Expand Down Expand Up @@ -293,10 +233,6 @@ async function main(args) {
const packedPackage = JSON.parse(execFileSync('tar', ['-xOf', tarball, 'package/package.json'], { encoding: 'utf8', maxBuffer: 1024 * 1024 }))
input.runs.push({ run, jobs, manifest, expectedManifest, bundles, androidLog, report, bytes, packedPackage })
}
input.comment = await json('/issues/comments/' + options['--acceptance'])
input.comment.editMetadata = await fetchCommentEditMetadata(input.comment.node_id, process.env.GH_TOKEN)
const collaborator = await json('/collaborators/' + encodeURIComponent(input.comment.user.login) + '/permission')
input.permission = collaborator.permission === 'admin' ? 'admin' : collaborator.role_name
const result = validateReadiness(input)
await mkdir(options['--out'], { recursive: true })
await writeFile(path.join(options['--out'], 'package.tgz'), input.runs[0].bytes, { flag: 'wx' })
Expand Down
Loading
Loading