diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index abeb4d0..0120782 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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 @@ -33,7 +29,6 @@ concurrency: permissions: contents: read actions: read - issues: read jobs: release: @@ -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: @@ -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 diff --git a/docs/releasing.md b/docs/releasing.md index 8e1308b..bdd3019 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -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 @@ -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 @@ -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` tag must agree. @@ -242,7 +229,8 @@ version and its `v` 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.` 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 diff --git a/docs/support.md b/docs/support.md index 9d7d36c..5debc9e 100644 --- a/docs/support.md +++ b/docs/support.md @@ -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. diff --git a/e2e/README.md b/e2e/README.md index d800cb3..2d99927 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -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 diff --git a/scripts/release-readiness.mjs b/scripts/release-readiness.mjs index ee6241c..7f6198d 100644 --- a/scripts/release-readiness.mjs +++ b/scripts/release-readiness.mjs @@ -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]*$/ @@ -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') @@ -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 @@ -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, { @@ -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' }) diff --git a/scripts/release-readiness.test.mjs b/scripts/release-readiness.test.mjs index 084366a..fa545f3 100644 --- a/scripts/release-readiness.test.mjs +++ b/scripts/release-readiness.test.mjs @@ -6,9 +6,9 @@ import { tmpdir } from 'node:os' import path from 'node:path' import test from 'node:test' import { buildManifest, EXPECTED_COUNTS } from '../e2e/helpers/ios-shards.mjs' -import { fetchCommentEditMetadata, PHYSICAL_CHECKS, REQUIRED_JOBS, validateReadiness, validateSource } from './release-readiness.mjs' +import { REQUIRED_JOBS, validateReadiness, validateSource } from './release-readiness.mjs' -// Synthetic records exercise the gate only. They are never release acceptance evidence. +// Synthetic records exercise the gate only. They are never release evidence. const sha = 'a'.repeat(40) const sources = Object.fromEntries(Object.entries(EXPECTED_COUNTS).map(([spec, count]) => [ spec, 'describe("suite", () => { for (let i = 0; i < ' + count + '; i++) it("case " + i, () => {}) })' @@ -52,15 +52,8 @@ function fixture(version = '1.0.0-rc.0', runIds = [101]) { return { run, jobs, manifest, expectedManifest: structuredClone(manifest), bundles, androidLog, report: { sourceSha: sha, cleanSource: true, version, filename: 'package.tgz', integrity: digest, files: ['package.json'] }, bytes, packedPackage: pkg } - }), - permission: 'maintain', comment: { id: 789, node_id: 'IC_fixture', user: { type: 'User', login: 'maintainer' }, - created_at: '2026-10-16T14:00:00Z', updated_at: '2026-10-16T14:00:00Z' } + }) } - value.comment.body = JSON.stringify({ schemaVersion: 1, sourceSha: sha, version, packageIntegrity: digest, - verdict: 'accepted', platform: 'physical-ios', device: 'iPhone test fixture', os: 'iOS fixture', - testedAt: '2026-10-16T13:00:00Z', checks: Object.fromEntries(PHYSICAL_CHECKS.map((name) => [name, 'passed'])) }) - value.comment.editMetadata = { __typename: 'IssueComment', id: value.comment.node_id, body: value.comment.body, - author: { login: 'maintainer' }, lastEditedAt: null, editor: null, includesCreatedEdit: false } return value } @@ -132,22 +125,7 @@ const mutations = { 'wrong package version': (v) => { v.runs[0].report.version = '0.1.12' }, 'modified tarball': (v) => { v.runs[0].bytes = Buffer.from('different') }, 'unsafe filename': (v) => { v.runs[0].report.filename = '../package.tgz' }, - 'actual packed version mismatch': (v) => { v.runs[0].packedPackage = { ...v.pkg, version: '0.1.12' } }, - 'automated acceptance': (v) => { v.comment.user.type = 'Bot' }, - 'non-maintainer acceptance': (v) => { v.permission = 'write' }, - 'missing authoritative edit metadata': (v) => { delete v.comment.editMetadata }, - 'missing lastEditedAt field': (v) => { delete v.comment.editMetadata.lastEditedAt }, - 'missing editor field': (v) => { delete v.comment.editMetadata.editor }, - 'missing edit history flag': (v) => { delete v.comment.editMetadata.includesCreatedEdit }, - 'edited acceptance even with equal REST timestamps': (v) => { v.comment.editMetadata.lastEditedAt = v.comment.created_at }, - 'maintainer edited their own acceptance': (v) => { v.comment.editMetadata.editor = { login: 'maintainer' } }, - 'comment has edit history': (v) => { v.comment.editMetadata.includesCreatedEdit = true }, - 'edit metadata from another comment': (v) => { v.comment.editMetadata.id = 'IC_other' }, - 'edit metadata from another author': (v) => { v.comment.editMetadata.author.login = 'other' }, - 'body changed between REST and GraphQL': (v) => { v.comment.editMetadata.body = '{}' }, - 'wrong GraphQL node type': (v) => { v.comment.editMetadata.__typename = 'Issue' }, - 'missing comment creation time': (v) => { delete v.comment.created_at }, - 'missing human statement': (v) => { v.comment.body = '{}' } + 'actual packed version mismatch': (v) => { v.runs[0].packedPackage = { ...v.pkg, version: '0.1.12' } } } test('accepts exact case multisets despite nested-suite display order, timestamps and ANSI colors', () => { const value = fixture() @@ -172,37 +150,7 @@ for (const [name, mutate] of Object.entries(mutations)) { assert.throws(() => validateReadiness(value)) }) } -for (const [field, replacement] of Object.entries({ - sourceSha: 'b'.repeat(40), version: '0.1.12', packageIntegrity: 'different', verdict: 'pending', - platform: 'simulator', device: '', os: '', testedAt: '2099-01-01T00:00:00Z', checks: {} -})) { - test('rejects physical acceptance mismatch: ' + field, () => { - const value = fixture() - value.comment.body = JSON.stringify({ ...JSON.parse(value.comment.body), [field]: replacement }) - value.comment.editMetadata.body = value.comment.body - assert.throws(() => validateReadiness(value)) - }) -} - -test('rejects a collaborator turning a maintainer pending statement into accepted evidence', () => { - const value = fixture() - // Both APIs now return an accepted body, but REST still identifies the maintainer author. - value.comment.editMetadata.lastEditedAt = '2026-10-16T15:00:00Z' - value.comment.editMetadata.editor = { login: 'write-collaborator' } - value.comment.editMetadata.includesCreatedEdit = true - value.comment.updated_at = value.comment.editMetadata.lastEditedAt - assert.throws(() => validateReadiness(value), /must be unedited/) -}) - -test('uses edit-specific metadata rather than inferring edits from REST updated_at', () => { - const value = fixture() - value.comment.updated_at = '2026-10-16T15:00:00Z' - assert.equal(validateReadiness(value).acceptance.reviewer, 'maintainer') -}) - -test('short physical smoke retains seven grouped checks and both complete 62-case mobile inventories', () => { - assert.deepEqual(PHYSICAL_CHECKS, ['defaultAppearance', 'physicalSwipesAndActions', 'autoHaptics', - 'hapticsOn', 'hapticsOff', 'hapticSuppression', 'reduceMotion']) +test('requires both complete 62-case mobile inventories', () => { const value = fixture() for (const evidence of value.runs) { assert.equal(evidence.manifest.shards.reduce((count, shard) => count + shard.cases.length, 0), 62) @@ -210,44 +158,6 @@ test('short physical smoke retains seven grouped checks and both complete 62-cas assert.deepEqual(validateReadiness(value).ciRuns, [101]) }) -for (const check of PHYSICAL_CHECKS) { - test('requires physical smoke confirmation: ' + check, () => { - const value = fixture() - const report = JSON.parse(value.comment.body) - delete report.checks[check] - value.comment.body = JSON.stringify(report) - value.comment.editMetadata.body = value.comment.body - assert.throws(() => validateReadiness(value), /Physical check not accepted/) - }) -} - -test('fetches edit provenance through the REST comment global node ID', async () => { - const metadata = fixture().comment.editMetadata - const result = await fetchCommentEditMetadata('IC_fixture', 'synthetic-token', async (url, options) => { - assert.equal(url, 'https://api.github.com/graphql') - assert.equal(options.method, 'POST') - assert.equal(options.headers.Authorization, 'Bearer synthetic-token') - const { query, variables } = JSON.parse(options.body) - assert.deepEqual(variables, { id: 'IC_fixture' }) - for (const field of ['IssueComment', 'id', 'body', 'lastEditedAt', 'includesCreatedEdit', 'author', 'editor']) { - assert.ok(query.includes(field), 'Missing GraphQL field: ' + field) - } - return { ok: true, json: async () => ({ data: { node: metadata } }) } - }) - assert.deepEqual(result, metadata) -}) - -for (const [name, response] of Object.entries({ - 'HTTP failure': { ok: false, status: 403 }, - 'partial GraphQL errors': { ok: true, json: async () => ({ data: { node: fixture().comment.editMetadata }, errors: [{ message: 'denied' }] }) }, - 'missing node': { ok: true, json: async () => ({ data: { node: null } }) }, - 'missing data': { ok: true, json: async () => ({}) } -})) { - test('fails closed fetching comment metadata: ' + name, async () => { - await assert.rejects(fetchCommentEditMetadata('IC_fixture', 'synthetic-token', async () => response)) - }) -} - // Exercise the actual workflow shell boundaries with functions that replace git/npm. // These tests never invoke real git, contact a remote, create tags or publish packages. // Directory changes are real: git must resolve the checkout while npm uses the artifact directory.