From 07348f21e935d84bdd5f4b68f8088b914cb898fa Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Sun, 18 Oct 2026 21:33:33 +0800 Subject: [PATCH] ci: skip native E2E for a version-only release commit A release commit only bumps package.json / oh-package.json5 versions and adds .github/releases/vX.Y.Z.md, yet package.json in the path filters re-ran the whole native suite (~55 min of macOS) on a tree its parent had just tested. scripts/version-only-commit.js checks the diff itself (only the version line of either manifest, plus release notes). On a single-commit push to master, .github/actions/version-only skips the native jobs of e2e-ios, e2e-android, expo-build, harmony-build and test's cpp-test for such a commit and posts a "native-inherited" status; publish.yml then requires the parent's native-required on top of the release commit's own (lint and test still run). `chore: release` pushes get their own concurrency group so they no longer cancel the parent's still-running checks. Pull requests are unaffected. Co-Authored-By: Claude Opus 5.5 --- .github/actions/version-only/action.yml | 39 ++++++++++ .github/workflows/e2e_android.yml | 30 +++++++- .github/workflows/e2e_ios.yml | 26 ++++++- .github/workflows/expo_build.yml | 28 ++++++- .github/workflows/harmony-build.yml | 26 ++++++- .github/workflows/lint.yml | 8 +- .github/workflows/native-gate.yml | 5 ++ .github/workflows/publish.yml | 51 +++++++++---- .github/workflows/test.yml | 18 ++++- scripts/version-only-commit.js | 83 +++++++++++++++++++++ src/__tests__/versionOnlyCommit.test.ts | 98 +++++++++++++++++++++++++ 11 files changed, 388 insertions(+), 24 deletions(-) create mode 100644 .github/actions/version-only/action.yml create mode 100644 scripts/version-only-commit.js create mode 100644 src/__tests__/versionOnlyCommit.test.ts diff --git a/.github/actions/version-only/action.yml b/.github/actions/version-only/action.yml new file mode 100644 index 00000000..9f04180f --- /dev/null +++ b/.github/actions/version-only/action.yml @@ -0,0 +1,39 @@ +name: Detect a version-only push +description: >- + Outputs native=false for a push whose single new commit only bumps the + package.json / oh-package.json5 version and edits .github/releases/** + (scripts/version-only-commit.js), so the native jobs can skip a release + commit that builds exactly what its already-tested parent built. The + decision is recorded as the "native-inherited" commit status, which makes + publish.yml require the parent's native-required as well. Everything else, + pull requests included, gets native=true. Needs a checkout with + fetch-depth >= 2 and `statuses: write`. +outputs: + native: + description: "'false' to skip the native jobs, otherwise 'true'." + value: ${{ steps.check.outputs.native }} +runs: + using: composite + steps: + - id: check + shell: bash + env: + GH_TOKEN: ${{ github.token }} + EVENT_NAME: ${{ github.event_name }} + BEFORE: ${{ github.event.before }} + run: | + native=true + parent="$(git rev-parse --verify --quiet HEAD^1 || true)" + # Only when the push added exactly this one commit: a push that + # brought several gets no runs for the earlier ones, so the parent + # would have no native-required to rely on. + if [ "$EVENT_NAME" = push ] && [ -n "$parent" ] && [ "$parent" = "$BEFORE" ] \ + && [ "$(node scripts/version-only-commit.js HEAD)" = true ]; then + native=false + echo "::notice::Version-only commit: native jobs are skipped; publishing relies on the native-required of $parent." + gh api "repos/$GITHUB_REPOSITORY/statuses/$(git rev-parse HEAD)" \ + -f state=success -f context=native-inherited \ + -f description="native E2E inherited from ${parent:0:12}" \ + -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$parent" >/dev/null + fi + echo "native=$native" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/e2e_android.yml b/.github/workflows/e2e_android.yml index 7b5e1383..8957e1be 100644 --- a/.github/workflows/e2e_android.yml +++ b/.github/workflows/e2e_android.yml @@ -23,8 +23,12 @@ on: - master paths: *e2e-android-paths +# A release commit (`chore: release ...`) gets a group of its own on push so +# it never cancels its parent's still-running checks: when it only bumps the +# version (.github/actions/version-only), its native jobs skip and +# publish.yml relies on the parent's native-required. concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: "${{ github.workflow }}-${{ (github.event_name == 'push' && startsWith(github.event.head_commit.message, 'chore: release ') && github.sha) || github.ref }}" cancel-in-progress: true # Read by .detoxrc.js (detox build/test only): the ABI to build and the AVD @@ -34,7 +38,27 @@ env: DETOX_AVD_NAME: api34 jobs: + # A version-only release commit builds what its parent already tested + # (.github/actions/version-only), so the jobs below skip it. + changes: + name: Detect a version-only push + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + statuses: write # native-inherited + outputs: + native: ${{ steps.version-only.outputs.native }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 2 + - id: version-only + uses: ./.github/actions/version-only + e2e-android: + needs: changes + if: needs.changes.outputs.native == 'true' runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -99,6 +123,8 @@ jobs: # (v10.48.2 的 "doesn't declare constants" 启动崩溃就是这样漏掉的)。 # 只验证 debug 包能启动进首屏,不跑更新流,所以不需要更新 server 和产物。 e2e-android-debug-smoke: + needs: changes + if: needs.changes.outputs.native == 'true' runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -149,6 +175,8 @@ jobs: retention-days: 7 e2e-android-rn077-oldarch: + needs: changes + if: needs.changes.outputs.native == 'true' runs-on: ubuntu-latest timeout-minutes: 35 steps: diff --git a/.github/workflows/e2e_ios.yml b/.github/workflows/e2e_ios.yml index 0a4d7200..2de493db 100644 --- a/.github/workflows/e2e_ios.yml +++ b/.github/workflows/e2e_ios.yml @@ -24,8 +24,12 @@ on: - master paths: *e2e-ios-paths +# A release commit (`chore: release ...`) gets a group of its own on push so +# it never cancels its parent's still-running checks: when it only bumps the +# version (.github/actions/version-only), its native jobs skip and +# publish.yml relies on the parent's native-required. concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: "${{ github.workflow }}-${{ (github.event_name == 'push' && startsWith(github.event.head_commit.message, 'chore: release ') && github.sha) || github.ref }}" cancel-in-progress: true env: @@ -33,11 +37,31 @@ env: XCODE_VERSION: '26.4' jobs: + # A version-only release commit builds what its parent already tested + # (.github/actions/version-only), so the jobs below skip it. + changes: + name: Detect a version-only push + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + statuses: write # native-inherited + outputs: + native: ${{ steps.version-only.outputs.native }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 2 + - id: version-only + uses: ./.github/actions/version-only + # Builds the release .app once (pod install + xcodebuild, or the cached app # with a refreshed JS bundle) and prepares the local update artifacts; both # test legs below install that same build instead of compiling it twice. e2e-ios-build: name: e2e-ios (build) + needs: changes + if: needs.changes.outputs.native == 'true' runs-on: macos-26 timeout-minutes: 45 steps: diff --git a/.github/workflows/expo_build.yml b/.github/workflows/expo_build.yml index 50317971..9d645a8d 100644 --- a/.github/workflows/expo_build.yml +++ b/.github/workflows/expo_build.yml @@ -40,8 +40,12 @@ on: permissions: contents: read +# A release commit (`chore: release ...`) gets a group of its own on push so +# it never cancels its parent's still-running checks: when it only bumps the +# version (.github/actions/version-only), its native jobs skip and +# publish.yml relies on the parent's native-required. concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: "${{ github.workflow }}-${{ (github.event_name == 'push' && startsWith(github.event.head_commit.message, 'chore: release ') && github.sha) || github.ref }}" cancel-in-progress: true env: @@ -50,8 +54,28 @@ env: EXPO_DIR: Example/expoUsePushy jobs: + # A version-only release commit builds what its parent already tested + # (.github/actions/version-only), so the jobs below skip it. + changes: + name: Detect a version-only push + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + statuses: write # native-inherited + outputs: + native: ${{ steps.version-only.outputs.native }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 2 + - id: version-only + uses: ./.github/actions/version-only + expo-ios: name: expo-build (ios) + needs: changes + if: needs.changes.outputs.native == 'true' runs-on: macos-26 timeout-minutes: 45 steps: @@ -117,6 +141,8 @@ jobs: expo-android: name: expo-build (android) + needs: changes + if: needs.changes.outputs.native == 'true' runs-on: ubuntu-latest timeout-minutes: 40 steps: diff --git a/.github/workflows/harmony-build.yml b/.github/workflows/harmony-build.yml index 1968a8ad..295a0586 100644 --- a/.github/workflows/harmony-build.yml +++ b/.github/workflows/harmony-build.yml @@ -29,12 +29,36 @@ on: - master paths: *harmony-paths +# A release commit (`chore: release ...`) gets a group of its own on push so +# it never cancels its parent's still-running checks: when it only bumps the +# version (.github/actions/version-only), its native jobs skip and +# publish.yml relies on the parent's native-required. concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + group: "${{ github.workflow }}-${{ github.event.pull_request.number || (github.event_name == 'push' && startsWith(github.event.head_commit.message, 'chore: release ') && github.sha) || github.ref }}" cancel-in-progress: true jobs: + # A version-only release commit builds what its parent already tested + # (.github/actions/version-only), so the jobs below skip it. + changes: + name: Detect a version-only push + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + statuses: write # native-inherited + outputs: + native: ${{ steps.version-only.outputs.native }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 2 + - id: version-only + uses: ./.github/actions/version-only + harmony-build: + needs: changes + if: needs.changes.outputs.native == 'true' runs-on: ubuntu-latest # Keep the digest in sync with publish.yml; bump both deliberately. container: ghcr.io/sanchuanhehe/harmony-next-pipeline-docker/harmonyos-ci-image:latest@sha256:2836142b7b6ae8837ad0e36cacc650f00e8c88f55b4e9f34be66cc98a731c5da diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e118ce48..1dddd462 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -9,9 +9,13 @@ on: - master # Cancel a currently running workflow from the same PR/branch/tag -# when a new workflow is triggered +# when a new workflow is triggered. A release commit (`chore: release ...`) +# gets a group of its own on push so it never cancels its parent's +# still-running checks: when it only bumps the version +# (.github/actions/version-only), its native jobs skip and publish.yml relies +# on the parent's native-required. concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + group: "${{ github.workflow }}-${{ github.event.pull_request.number || (github.event_name == 'push' && startsWith(github.event.head_commit.message, 'chore: release ') && github.sha) || github.ref }}" cancel-in-progress: true jobs: diff --git a/.github/workflows/native-gate.yml b/.github/workflows/native-gate.yml index 6ababb65..cda3b309 100644 --- a/.github/workflows/native-gate.yml +++ b/.github/workflows/native-gate.yml @@ -9,6 +9,11 @@ name: native-gate # workflow_dispatch-only (no hosted HarmonyOS runner) and is therefore not # part of the gate; harmony-build (HAR compile in the pinned container) and # expo-build (the Expo example on both platforms) are. +# +# A version-only release commit pushed to master skips the native jobs of +# those workflows (.github/actions/version-only), whose runs then succeed on +# the detect job alone; it is marked "native-inherited", and publish.yml +# requires the parent's native-required on top of this one. on: workflow_run: workflows: [lint, test, e2e-android, e2e-ios, harmony-build, expo-build] diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 618b4859..a7f45779 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -20,6 +20,12 @@ jobs: harmony_changed: ${{ steps.check.outputs.changed }} prev_tag: ${{ steps.check.outputs.prev_tag }} steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + # Only tags, commits and trees are read (git tag --merged, git diff + # --name-only, git rev-parse); skip the blobs of the whole history. + filter: blob:none # Both publish jobs need this job, so a release can only ship a commit # whose lint/test/native E2E gate (native-gate.yml posts it as the # "native-required" commit status) is green. Pending or not yet posted @@ -31,6 +37,15 @@ jobs: GH_TOKEN: ${{ github.token }} SHA: ${{ github.sha }} run: | + # A version-only release commit skips its native jobs on push + # (.github/actions/version-only posts "native-inherited" for it, + # before any of its workflows completes), so its own + # native-required only covers lint and test; the native E2E it + # ships ran on its parent, whose native-required must be green too. + status() { + gh api "repos/$GITHUB_REPOSITORY/commits/$1/status" \ + --jq "[.statuses[] | select(.context == \"$2\")][0].state // \"missing\"" + } # The gate turns green only after the slowest native E2E: e2e-ios # is a build job (timeout 45 min) followed by its test legs # (timeout 60 min), plus macOS queueing. A green release commit @@ -38,24 +53,30 @@ jobs: # push; wait out both budgets instead. # Checks at minute 0 through 120: the last poll lands on the # boundary itself, with no trailing sleep before giving up. + required="$SHA" for attempt in $(seq 0 120); do - state="$(gh api "repos/$GITHUB_REPOSITORY/commits/$SHA/status" \ - --jq '[.statuses[] | select(.context == "native-required")][0].state // "missing"')" - echo "native-required for $SHA: $state" - case "$state" in - success) exit 0 ;; - pending|missing) if [ "$attempt" -lt 120 ]; then sleep 60; fi ;; - *) echo "::error::native-required is $state for $SHA; fix CI before publishing"; exit 1 ;; - esac + green=true + for sha in $required; do + state="$(status "$sha" native-required)" + echo "native-required for $sha: $state" + case "$state" in + success) ;; + pending|missing) green=false ;; + *) echo "::error::native-required is $state for $sha; fix CI before publishing"; exit 1 ;; + esac + done + if [ "$green" = true ]; then + if [ "$required" = "$SHA" ] && [ "$(status "$SHA" native-inherited)" = success ]; then + required="$SHA $(git rev-parse "$SHA^1")" + echo "$SHA only bumps the version; also requiring its parent's native-required." + continue + fi + exit 0 + fi + if [ "$attempt" -lt 120 ]; then sleep 60; fi done - echo "::error::native-required for $SHA did not turn green within 120 minutes" + echo "::error::native-required for $required did not turn green within 120 minutes" exit 1 - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - # Only tags, commits and trees are read (git tag --merged, git diff - # --name-only); skip the blobs of the whole history. - filter: blob:none - name: Check Harmony changes id: check shell: bash diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 920bf112..d9102a97 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,15 +9,22 @@ on: - master # Cancel a currently running workflow from the same PR/branch/tag -# when a new workflow is triggered +# when a new workflow is triggered. A release commit (`chore: release ...`) +# gets a group of its own on push so it never cancels its parent's +# still-running checks: when it only bumps the version +# (.github/actions/version-only), its native jobs skip and publish.yml relies +# on the parent's native-required. concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + group: "${{ github.workflow }}-${{ github.event.pull_request.number || (github.event_name == 'push' && startsWith(github.event.head_commit.message, 'chore: release ') && github.sha) || github.ref }}" cancel-in-progress: true jobs: # The native jobs below only run when their inputs change. A job skipped by # its `if:` still reports success, so required checks and native-gate (which # looks at the workflow conclusion) are satisfied on unrelated changes. + # package.json is a cpp input for its `files` list, but a version-only + # release commit (.github/actions/version-only) leaves it as tested on the + # parent, so cpp-test skips that one too; js-test always runs. changes: name: Detect changed native inputs runs-on: ubuntu-latest @@ -25,11 +32,16 @@ jobs: permissions: contents: read pull-requests: read + statuses: write # native-inherited outputs: - cpp: ${{ steps.filter.outputs.cpp }} + cpp: ${{ steps.filter.outputs.cpp == 'true' && steps.version-only.outputs.native == 'true' }} ios: ${{ steps.filter.outputs.ios }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 2 + - id: version-only + uses: ./.github/actions/version-only - id: filter uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 with: diff --git a/scripts/version-only-commit.js b/scripts/version-only-commit.js new file mode 100644 index 00000000..9ebe9a54 --- /dev/null +++ b/scripts/version-only-commit.js @@ -0,0 +1,83 @@ +#!/usr/bin/env node +// Prints "true" when a commit's diff from its first parent only bumps the +// version of package.json / harmony/pushy/oh-package.json5 and edits the +// release notes under .github/releases/, "false" otherwise (the reason goes +// to stderr). Such a release commit builds the same native code as its +// parent, so the E2E workflows skip their jobs for it and publish.yml relies +// on the parent's native-required instead (see .github/actions/version-only). +// +// GitHub path filters only see file names; this looks at the diff itself, so +// any other change to either manifest (dependencies, `files`, codegenConfig, +// scripts) still runs the full native suite. +// +// Usage: node scripts/version-only-commit.js [rev] (rev defaults to HEAD) +const { execFileSync } = require('child_process'); + +const MANIFESTS = ['package.json', 'harmony/pushy/oh-package.json5']; +const RELEASE_NOTES = /^\.github\/releases\/[^/]+$/; +// One `version` entry per line, key bare or quoted (JSON5), as both +// manifests are written; mirrors the regex in check-release-version.js. +const VERSION_LINE = + /^\s*(?:"version"|'version'|version)\s*:\s*['"][^'"]+['"]\s*,?\s*$/; + +function git(cwd, args) { + return execFileSync('git', args, { cwd, encoding: 'utf8' }); +} + +function versionOnlyReason(rev = 'HEAD', cwd = process.cwd()) { + let parent; + try { + parent = git(cwd, ['rev-parse', '--verify', '--quiet', `${rev}^1`]).trim(); + } catch { + return `${rev} has no parent in this clone`; + } + const files = git(cwd, ['diff', '--name-only', '--no-renames', parent, rev]) + .split('\n') + .filter(Boolean); + if (files.length === 0) { + return `${rev} changes nothing`; + } + for (const file of files) { + if (RELEASE_NOTES.test(file)) { + continue; + } + if (!MANIFESTS.includes(file)) { + return `${file} changed`; + } + const changed = git(cwd, [ + 'diff', + '-U0', + '--no-color', + parent, + rev, + '--', + file, + ]) + .split('\n') + .filter((line) => /^[-+]/.test(line) && !/^(---|\+\+\+) /.test(line)); + const removed = changed.filter((line) => line[0] === '-'); + const added = changed.filter((line) => line[0] === '+'); + if ( + removed.length !== 1 || + added.length !== 1 || + !VERSION_LINE.test(removed[0].slice(1)) || + !VERSION_LINE.test(added[0].slice(1)) + ) { + return `${file} changed beyond its version line`; + } + } + return null; +} + +module.exports = { versionOnlyReason }; + +if (require.main === module) { + const rev = process.argv[2] || 'HEAD'; + const reason = versionOnlyReason(rev); + console.error( + reason + ? `version-only-commit: no, ${reason}` + : `version-only-commit: yes, ${rev} only bumps the version / release notes` + ); + console.log(reason ? 'false' : 'true'); +} diff --git a/src/__tests__/versionOnlyCommit.test.ts b/src/__tests__/versionOnlyCommit.test.ts new file mode 100644 index 00000000..461d68ce --- /dev/null +++ b/src/__tests__/versionOnlyCommit.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { versionOnlyReason } from '../../scripts/version-only-commit'; + +// A commit that only bumps the manifest versions and adds release notes skips +// native E2E and is published on its parent's native-required, so anything +// else touching those manifests must not pass for one. +const pkg = (version: string, extra = '') => + `{\n "name": "react-native-update",\n "version": "${version}",${extra}\n "main": "src/index.ts"\n}\n`; +const ohPkg = (version: string) => + `{\n name: 'pushy',\n version: '${version}',\n dependencies: {},\n}\n`; + +let repo: string; +const git = (...args: string[]) => + execFileSync('git', args, { cwd: repo, encoding: 'utf8' }); +const write = (files: Record) => { + for (const [path, content] of Object.entries(files)) { + mkdirSync(dirname(join(repo, path)), { recursive: true }); + writeFileSync(join(repo, path), content); + } +}; +const commit = (files: Record) => { + write(files); + git('add', '-A'); + git('commit', '-q', '--allow-empty', '-m', 'commit'); +}; +const reason = () => versionOnlyReason('HEAD', repo); + +beforeEach(() => { + repo = mkdtempSync(join(tmpdir(), 'version-only-')); + git('init', '-q'); + git('config', 'user.email', 'test@example.com'); + git('config', 'user.name', 'test'); + git('config', 'commit.gpgsign', 'false'); + commit({ + 'package.json': pkg('1.0.0'), + 'harmony/pushy/oh-package.json5': ohPkg('1.0.0'), + 'src/index.ts': 'export {};\n', + }); +}); + +afterEach(() => { + rmSync(repo, { recursive: true, force: true }); +}); + +describe('version-only-commit', () => { + test('a release commit is version-only', () => { + commit({ + 'package.json': pkg('1.0.1'), + 'harmony/pushy/oh-package.json5': ohPkg('1.0.1'), + '.github/releases/v1.0.1.md': '# v1.0.1\n', + }); + expect(reason()).toBeNull(); + }); + + test('release notes alone are version-only', () => { + commit({ '.github/releases/v1.0.0.md': '# v1.0.0\n' }); + expect(reason()).toBeNull(); + }); + + test('any other file is not', () => { + commit({ 'package.json': pkg('1.0.1'), 'src/index.ts': 'export {}\n' }); + expect(reason()).toBe('src/index.ts changed'); + }); + + test('nested paths under .github/releases are not release notes', () => { + commit({ '.github/releases/x/action.yml': 'x\n' }); + expect(reason()).toBe('.github/releases/x/action.yml changed'); + }); + + test('another manifest change next to the bump is not', () => { + commit({ 'package.json': pkg('1.0.1', '\n "files": ["src"],') }); + expect(reason()).toBe('package.json changed beyond its version line'); + }); + + test('a manifest change without the bump is not', () => { + commit({ + 'harmony/pushy/oh-package.json5': ohPkg('1.0.0').replace( + 'dependencies: {}', + "dependencies: { a: '1' }" + ), + }); + expect(reason()).toBe( + 'harmony/pushy/oh-package.json5 changed beyond its version line' + ); + }); + + test('an empty commit or a root commit is not', () => { + commit({}); + expect(reason()).toBe('HEAD changes nothing'); + git('checkout', '-q', '--orphan', 'other'); + git('commit', '-q', '-m', 'root'); + expect(reason()).toBe('HEAD has no parent in this clone'); + }); +});