fix(ci): avoid unrelated iOS release-gate runs

This commit is contained in:
joshavant
2026-08-11 18:32:34 -05:00
committed by Josh Avant
parent bbb5164ec9
commit 49dabadf86
7 changed files with 104 additions and 16 deletions
+9 -4
View File
@@ -120,6 +120,7 @@ jobs:
run_macos_swift: ${{ steps.manifest.outputs.run_macos_swift }}
run_openclawkit_tests: ${{ steps.manifest.outputs.run_openclawkit_tests }}
run_ios_build: ${{ steps.manifest.outputs.run_ios_build }}
run_ios_screenshots: ${{ steps.changed_scope.outputs.run_ios_screenshots }}
run_android_job: ${{ steps.manifest.outputs.run_android_job }}
use_compatible_android_ci: ${{ steps.manifest.outputs.use_compatible_android_ci }}
run_protocol_event_coverage: ${{ steps.manifest.outputs.run_protocol_event_coverage }}
@@ -428,8 +429,10 @@ jobs:
OPENCLAW_CI_DOCS_ONLY: ${{ github.event_name == 'workflow_dispatch' && 'false' || steps.docs_scope.outputs.docs_only }}
OPENCLAW_CI_DOCS_CHANGED: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.docs_scope.outputs.docs_changed }}
OPENCLAW_CI_RUN_NODE: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_node || 'false' }}
OPENCLAW_CI_RUN_MACOS: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_macos || 'false' }}
OPENCLAW_CI_RUN_IOS_BUILD: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_ios_build || 'false' }}
# Ordinary manual/release validation stays full. A release_gate is a
# hosted substitute for PR CI, so it must retain the exact Apple scope.
OPENCLAW_CI_RUN_MACOS: ${{ github.event_name == 'workflow_dispatch' && !inputs.release_gate && 'true' || steps.changed_scope.outputs.run_macos || 'false' }}
OPENCLAW_CI_RUN_IOS_BUILD: ${{ github.event_name == 'workflow_dispatch' && !inputs.release_gate && 'true' || steps.changed_scope.outputs.run_ios_build || 'false' }}
OPENCLAW_CI_RUN_ANDROID: ${{ github.event_name == 'workflow_dispatch' && (inputs.release_gate || inputs.include_android) && 'true' || steps.changed_scope.outputs.run_android || 'false' }}
OPENCLAW_CI_RUN_WINDOWS: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_windows || 'false' }}
OPENCLAW_CI_RUN_NODE_FAST_ONLY: ${{ github.event_name == 'workflow_dispatch' && 'false' || steps.changed_scope.outputs.run_node_fast_only || 'false' }}
@@ -3555,11 +3558,13 @@ jobs:
retention-days: 14
- name: Capture iOS release screenshots
if: ${{ (github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && needs.preflight.outputs.run_macos == 'true')) && env.HISTORICAL_TARGET != 'true' }}
# Full manual/release validation always captures. PRs and their exact-head
# release-gate substitutes use the same conservative screenshot-risk scope.
if: ${{ ((github.event_name == 'workflow_dispatch' && (!inputs.release_gate || needs.preflight.outputs.run_ios_screenshots == 'true')) || (github.event_name == 'pull_request' && needs.preflight.outputs.run_ios_screenshots == 'true')) && env.HISTORICAL_TARGET != 'true' }}
run: pnpm ios:screenshots
- name: Upload iOS release screenshot evidence
if: ${{ always() && (github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && needs.preflight.outputs.run_macos == 'true')) && env.HISTORICAL_TARGET != 'true' }}
if: ${{ always() && ((github.event_name == 'workflow_dispatch' && (!inputs.release_gate || needs.preflight.outputs.run_ios_screenshots == 'true')) || (github.event_name == 'pull_request' && needs.preflight.outputs.run_ios_screenshots == 'true')) && env.HISTORICAL_TARGET != 'true' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ios-release-screenshots-${{ needs.preflight.outputs.checkout_revision }}
+10 -6
View File
@@ -15,10 +15,12 @@ complete integration cycle run while GitHub keeps only the newest pending push.
New merges replace that pending run instead of canceling work that already
registered a Blacksmith matrix. Pull requests still cancel superseded heads,
and manual dispatches use isolated groups. `preflight` classifies the diff and
turns expensive lanes off when only unrelated areas changed. Manual
turns expensive lanes off when only unrelated areas changed. Ordinary manual
`workflow_dispatch` runs intentionally bypass smart scoping and fan out the
full graph for release candidates and broad validation. Android lanes stay
opt-in through `include_android` (or the `release_gate` input). Release-only
full graph for release candidates and broad validation. Exact-head
`release_gate` fallbacks retain the pull request's macOS and iOS scope instead
of forcing unrelated Apple lanes. Android lanes stay opt-in through
`include_android` (or the `release_gate` input). Release-only
plugin coverage lives in the separate
[`Plugin Prerelease`](#plugin-prerelease) workflow and only runs from
[`Full Release Validation`](#full-release-validation) or an explicit manual
@@ -47,7 +49,7 @@ dispatch.
| `checks-windows` | Windows-specific process/path tests plus shared runtime import specifier regressions | Windows-relevant changes |
| `macos-node` | Focused macOS TypeScript tests: launchd, Homebrew, runtime paths, packaging scripts, process-group wrapper | macOS-relevant changes |
| `macos-swift` | Swift lint and build for the macOS app, plus tests for the app and shared OpenClawKit package | macOS-relevant changes |
| `ios-build` | Xcode project generation plus the iOS app simulator build | iOS app, shared app kit, or Swabble changes |
| `ios-build` | Swift lint, Debug and Release builds, focused simulator lifecycle tests, and the full release screenshot matrix when screenshot-pipeline owners changed | iOS/capture changes |
| `android` | Android unit tests for both flavors plus one debug APK build | Android-relevant changes |
| `openclaw/ci-gate` | Final aggregate: requires preflight and security; accepts skips only for manifest-disabled downstream lanes | Every non-draft CI run |
| `test-performance-agent` | Separate workflow: daily Codex slow-test optimization after trusted activity | Main CI success or manual dispatch |
@@ -94,7 +96,9 @@ When the check fails, update the PR body instead of pushing another code commit.
## Scope and routing
Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests in `src/scripts/ci-changed-scope.test.ts`. Manual dispatch skips changed-scope detection and makes the preflight manifest act as if every scoped area changed.
Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests in `src/scripts/ci-changed-scope.test.ts`. Ordinary manual dispatch skips changed-scope detection and makes the preflight manifest act as if every scoped area changed. The exact-head `release_gate` exception evaluates the fetched pull request merge tree and retains its macOS, iOS-build, and screenshot-risk decisions.
Release screenshot routing is deliberately conservative because an app change can break deterministic App Store capture without breaking compilation. Pull requests and exact-head release gates run the full iPhone, iPad, and Watch matrix when the diff touches `apps/ios/**`, linked OpenClawKit or Swabble code, Apple Swift configuration, or the scripts used by screenshot capture. Ordinary manual CI and Full Release Validation always run that matrix. The screenshot decision is independent of macOS routing; a pure iOS app change does not select macOS jobs by itself.
Separate iOS and macOS Periphery workflows enforce a zero-findings dead-code policy. Each runs only when a non-draft pull request touches its native scan scope, or when manually dispatched.
@@ -158,7 +162,7 @@ Barnacle treats bug-labeled issues as verification candidates rather than inacti
## Manual dispatches
Manual CI dispatches run the same job graph as normal CI but force every non-Android scoped lane on: Linux Node shards, bundled-plugin shards, plugin and channel contract shards, Node 22 compatibility, `check-*`, `check-additional-*`, built-artifact smoke checks, docs checks, Python skills, Windows, macOS, iOS build, and Control UI/native app i18n. Automatic source PRs verify native extraction inventory and Android/Apple localization safety without requiring translated or platform-generated output in the same PR. The serialized Native App Locale Refresh workflow rebuilds those artifacts in one isolated PR and enables exact-head auto-merge after required checks pass. Full native parity remains blocking for generated-artifact PRs, manual CI, Full Release Validation, and release prep. Control UI locale parity remains advisory on automatic PR and `main` runs and blocking on manual/release CI. Standalone manual CI dispatches run Android only with `include_android=true` (the `release_gate` input also forces Android); the full release umbrella enables Android by passing `include_android=true`. Plugin prerelease static checks, the release-only `agentic-plugins` shard, the full extension batch sweep, and plugin prerelease Docker lanes are excluded from CI. The Docker prerelease suite runs only when `Full Release Validation` dispatches the separate `Plugin Prerelease` workflow with the release-validation gate enabled.
Ordinary manual CI dispatches run the same job graph as normal CI but force every non-Android scoped lane on: Linux Node shards, bundled-plugin shards, plugin and channel contract shards, Node 22 compatibility, `check-*`, `check-additional-*`, built-artifact smoke checks, docs checks, Python skills, Windows, macOS, iOS build, and Control UI/native app i18n. The exact-head `release_gate` fallback instead keeps the pull request's macOS and iOS scope, including conservative release screenshot capture for screenshot-pipeline owners. Automatic source PRs verify native extraction inventory and Android/Apple localization safety without requiring translated or platform-generated output in the same PR. The serialized Native App Locale Refresh workflow rebuilds those artifacts in one isolated PR and enables exact-head auto-merge after required checks pass. Full native parity remains blocking for generated-artifact PRs, manual CI, Full Release Validation, and release prep. Control UI locale parity remains advisory on automatic PR and `main` runs and blocking on manual/release CI. Standalone manual CI dispatches run Android only with `include_android=true` (the `release_gate` input also forces Android); the full release umbrella enables Android by passing `include_android=true`. Plugin prerelease static checks, the release-only `agentic-plugins` shard, the full extension batch sweep, and plugin prerelease Docker lanes are excluded from CI. The Docker prerelease suite runs only when `Full Release Validation` dispatches the separate `Plugin Prerelease` workflow with the release-validation gate enabled.
PR max-lines checks derive the baseline from the checked-out synthetic merge tree and verify its head parent against the event head. Manual runs use a unique concurrency group so a release-candidate full suite is not cancelled by another push or PR run on the same ref. The optional `target_ref` input lets a trusted caller run that graph against a branch, tag, or full commit SHA while using the workflow file from the selected dispatch ref; the max-lines baseline is compared with the target's merge base against the default-branch head resolved for that run. The `release_gate` input is an exact-SHA maintainer fallback for capacity-stalled PR CI: it requires `target_ref` to be a full commit SHA that matches the dispatched branch head and `pull_request_number` to identify the open PR whose merge tree is validated.
+31 -2
View File
@@ -44,13 +44,17 @@ const APPLE_SWIFT_CONFIG_RE = /^config\/(?:swiftformat|swiftlint\.yml)$/;
const APPLE_SHARED_CONTRACT_FIXTURE_RE =
/^test\/fixtures\/(?:device-identity-coordinator|talk-config)-contract\.json$/;
const MACOS_NATIVE_RE =
/^(apps\/macos\/|apps\/macos-mlx-tts\/|apps\/ios\/|apps\/shared\/|apps\/swabble\/|Swabble\/)/;
/^(apps\/macos\/|apps\/macos-mlx-tts\/|apps\/shared\/|apps\/swabble\/|Swabble\/)/;
const MACOS_SCRIPT_SCOPE_RE =
/^(?:scripts\/(?:check-swift-tools|codesign-mac-app|create-dmg|format-swift|install-swift-tools|install-xcodegen|lint-swift|notarize-mac-artifact|package-mac-app|package-mac-dist)\.sh|scripts\/lib\/(?:plistbuddy|swift-toolchain)\.sh|test\/scripts\/(?:codesign-mac-app|create-dmg|notarize-mac-artifact|package-mac-app|package-mac-dist)\.test\.ts)$/;
const WORKSPACE_RSYNC_RECEIVER_SCOPE_RE =
/^src\/(?:worker\/workspace-rsync-receiver\.ts|gateway\/worker-environments\/workspace-(?:accepted-(?:remote-script|sync)|mutation-remote-script|rsync-path\.test|sync(?:-helpers)?)\.ts)$/;
const IOS_BUILD_RE =
/^(apps\/ios\/|apps\/shared\/|apps\/swabble\/|Swabble\/|scripts\/(?:check-swift-tools|format-swift|install-swift-tools|install-xcodegen|lint-swift)\.sh$|scripts\/(?:ios-(?:configure-signing|team-id|write-version-xcconfig)\.sh|ios-write-swift-filelist\.m[jt]s|ios-version\.ts)$|scripts\/lib\/(?:ios-version\.ts|release-version\.mjs|version-script-args\.ts)$)/;
/^(apps\/ios\/|apps\/shared\/|apps\/swabble\/|Swabble\/|scripts\/(?:check-swift-tools|format-swift|install-swift-tools|install-xcodegen|lint-swift)\.sh$|scripts\/(?:ios-(?:configure-signing|screenshots|team-id|write-version-xcconfig)\.sh|ios-write-swift-filelist\.m[jt]s|ios-version\.ts)$|scripts\/lib\/(?:ios-fastlane\.sh|ios-version\.ts|release-version\.mjs|version-script-args\.ts)$)/;
const IOS_SCREENSHOT_APP_SCOPE_RE =
/^(?:apps\/ios\/|apps\/shared\/OpenClawKit\/|apps\/swabble\/|Swabble\/)/;
const IOS_SCREENSHOT_SCRIPT_SCOPE_RE =
/^scripts\/(?:check-swift-tools|format-swift|install-swift-tools|install-xcodegen|lint-swift)\.sh$|^scripts\/(?:ios-(?:configure-signing|screenshots|team-id|write-version-xcconfig)\.sh|ios-write-swift-filelist\.m[jt]s|ios-version\.ts)$|^scripts\/lib\/(?:ios-fastlane\.sh|ios-version\.ts|release-version\.mjs|version-script-args\.ts)$/;
const ANDROID_NATIVE_RE = /^(apps\/android\/|apps\/shared\/)/;
const NODE_SCOPE_RE =
/^(src\/|test\/|extensions\/|packages\/|scripts\/|ui\/|\.github\/|openclaw\.mjs$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|tsconfig.*\.json$|vitest.*\.ts$|tsdown\.config\.ts$|\.oxlintrc\.json$|\.oxfmtrc\.jsonc$)/;
@@ -245,6 +249,26 @@ export function detectChangedScope(changedPaths) {
};
}
/**
* Release screenshot capture is a conservative pipeline-integrity gate. App,
* linked Swift, and capture-tool changes must prove the real release lane.
* @param {string[] | null} changedPaths
* @returns {boolean}
*/
export function shouldRunIosScreenshots(changedPaths) {
if (!Array.isArray(changedPaths)) {
return true;
}
return changedPaths.some((rawPath) => {
const { path } = getChangedPathFacts(rawPath);
return (
IOS_SCREENSHOT_APP_SCOPE_RE.test(path) ||
IOS_SCREENSHOT_SCRIPT_SCOPE_RE.test(path) ||
APPLE_SWIFT_CONFIG_RE.test(path)
);
});
}
/**
* Generated Control UI locale snapshots belong in their isolated automation PR.
* Mixing them into a source PR recreates deterministic rebase conflicts.
@@ -589,6 +613,11 @@ export function writeGitHubOutput(
appendFileSync(outputPath, `run_node=${scope.runNode}\n`, "utf8");
appendFileSync(outputPath, `run_macos=${scope.runMacos}\n`, "utf8");
appendFileSync(outputPath, `run_ios_build=${scope.runIosBuild}\n`, "utf8");
appendFileSync(
outputPath,
`run_ios_screenshots=${shouldRunIosScreenshots(changedPaths)}\n`,
"utf8",
);
appendFileSync(outputPath, `run_android=${scope.runAndroid}\n`, "utf8");
appendFileSync(outputPath, `run_windows=${scope.runWindows}\n`, "utf8");
appendFileSync(outputPath, `run_skills_python=${scope.runSkillsPython}\n`, "utf8");
+2 -2
View File
@@ -190,7 +190,7 @@ describe("detectChangedScope", () => {
});
expect(detectChangedScope(["apps/ios/Sources/RootTabs.swift"])).toEqual({
runNode: false,
runMacos: true,
runMacos: false,
runIosBuild: true,
runAndroid: false,
runWindows: false,
@@ -1043,7 +1043,7 @@ describe("detectChangedScope", () => {
const output = parseGitHubOutput(fs.readFileSync(outputPath, "utf8"));
expect(Object.keys(output).toSorted()).toEqual(
"changed_paths_json run_android run_changed_smoke run_control_ui_i18n run_fast_install_smoke run_full_install_smoke run_ios_build run_macos run_native_i18n run_node run_node_fast_ci_routing run_node_fast_only run_node_fast_plugin_contracts run_skills_python run_ui_tests run_windows strict_control_ui_i18n strict_native_i18n".split(
"changed_paths_json run_android run_changed_smoke run_control_ui_i18n run_fast_install_smoke run_full_install_smoke run_ios_build run_ios_screenshots run_macos run_native_i18n run_node run_node_fast_ci_routing run_node_fast_only run_node_fast_plugin_contracts run_skills_python run_ui_tests run_windows strict_control_ui_i18n strict_native_i18n".split(
" ",
),
);
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
const { detectChangedScope, shouldRunIosScreenshots } =
await import("../../scripts/ci-changed-scope.mjs");
describe("shouldRunIosScreenshots", () => {
it("conservatively routes screenshot-pipeline owners to release capture", () => {
for (const changedPath of [
"apps/ios/Sources/RootTabs.swift",
"apps/ios/fastlane/Fastfile",
"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift",
"apps/swabble/Sources/SwabbleKit/WakeWordGate.swift",
"scripts/ios-screenshots.sh",
"scripts/lib/ios-fastlane.sh",
"scripts/ios-write-swift-filelist.mjs",
"config/swiftformat",
]) {
expect(shouldRunIosScreenshots([changedPath]), changedPath).toBe(true);
}
for (const changedPath of [
"apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt",
"docs/ci.md",
"ui/src/pages/activity/activity-page.ts",
]) {
expect(shouldRunIosScreenshots([changedPath]), changedPath).toBe(false);
}
expect(shouldRunIosScreenshots([])).toBe(false);
expect(shouldRunIosScreenshots(null)).toBe(true);
});
it("keeps screenshot capture wrappers inside the iOS build lane", () => {
for (const changedPath of ["scripts/ios-screenshots.sh", "scripts/lib/ios-fastlane.sh"]) {
expect(detectChangedScope([changedPath]).runIosBuild, changedPath).toBe(true);
}
});
});
+11 -1
View File
@@ -1636,7 +1636,17 @@ NODE
'node scripts/ci-changed-scope.mjs --base "$BASE" --head "$HEAD_SHA"',
);
expect(workflow.jobs.preflight.permissions).toEqual({ contents: "read" });
expect(readFileSync(".github/workflows/ci.yml", "utf8")).toContain(
expect(workflow.jobs.preflight.outputs.run_ios_screenshots).toBe(
"${{ steps.changed_scope.outputs.run_ios_screenshots }}",
);
const workflowSource = readFileSync(".github/workflows/ci.yml", "utf8");
expect(workflowSource).toContain(
"OPENCLAW_CI_RUN_MACOS: ${{ github.event_name == 'workflow_dispatch' && !inputs.release_gate && 'true' || steps.changed_scope.outputs.run_macos || 'false' }}",
);
expect(workflowSource).toContain(
"OPENCLAW_CI_RUN_IOS_BUILD: ${{ github.event_name == 'workflow_dispatch' && !inputs.release_gate && 'true' || steps.changed_scope.outputs.run_ios_build || 'false' }}",
);
expect(workflowSource).toContain(
"OPENCLAW_CI_RUN_ANDROID: ${{ github.event_name == 'workflow_dispatch' && (inputs.release_gate || inputs.include_android) && 'true' || steps.changed_scope.outputs.run_android || 'false' }}",
);
@@ -320,7 +320,9 @@ describe("iOS Fastlane release upload gates", () => {
expect(iosJob).toContain("Capture iOS release screenshots");
expect(iosJob).toContain("github.event_name == 'workflow_dispatch'");
expect(iosJob).toContain("github.event_name == 'pull_request'");
expect(iosJob).toContain("needs.preflight.outputs.run_macos == 'true'");
expect(iosJob).toContain("inputs.release_gate");
expect(iosJob).toContain("needs.preflight.outputs.run_ios_screenshots == 'true'");
expect(iosJob).not.toContain("needs.preflight.outputs.run_macos == 'true'");
expect(iosJob).toContain("run: pnpm ios:screenshots");
expect(iosJob).toContain("Upload iOS release screenshot evidence");
expect(iosJob).toContain("apps/ios/build/SnapshotTestResults/*.xcresult");