Compare commits

...

2 Commits

Author SHA1 Message Date
Haitao Pan
c89be591ad test: make temp-dir cleanup resilient to concurrent-write races
The assistant execution target tests deleted their temp HOME/workspace
dirs with a raw recursive delete in addTearDown. A background flush
(e.g. controller dispose still persisting state) can keep writing into
the dir while the delete walks it, so the delete races and fails with
"Directory not empty" (errno 39), failing the test on CI.

Route all unguarded teardown deletes through the existing
_resilientDelete helper (re-check existence + retry), and harden that
helper so its final fallback never re-throws — a temp-dir cleanup
failure must never fail a test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 07:06:07 +08:00
Haitao Pan
fe4c0ebe24 ci: gate TestFlight behind opt-in toggle + Xcode 27 build fixes
TestFlight is now opt-in (default OFF). A workflow_dispatch boolean
`enable_testflight` (or the `ENABLE_TESTFLIGHT` repo variable) drives a
`prepare.outputs.testflight_enabled` flag that gates the macOS
app-store-pkg build leg and both testflight_ios/testflight_macos upload
legs. Missing Apple signing secrets no longer fail the normal DMG/IPA
release path (package-macos-app-store-pkg.sh hard-exits without them).

Xcode 27 build compatibility:
- Align Apple deployment targets so no pod sits below the app minimum
  (Xcode 27 rejects this): macOS pods + RunnerTests -> 15.6, iOS pods
  -> 15.5 to match the Runner targets.
- Add a `lipo` shim (scripts/xcode-tools/lipo) wired onto PATH in the
  iOS/macOS build phases; Xcode 27 only accepts one `-verify_arch`
  architecture per call while Flutter passes them all at once.
- macOS project hygiene: correct PrivacyInfo.xcprivacy path, set app
  display name + LSApplicationCategoryType.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 06:48:30 +08:00
9 changed files with 102 additions and 60 deletions

View File

@ -30,6 +30,11 @@ on:
- ".github/actions/setup-flutter-sdk/action.yml"
- ".github/workflows/build-and-release.yml"
workflow_dispatch:
inputs:
enable_testflight:
description: "Build & upload TestFlight (macOS/iOS App Store) artifacts"
type: boolean
default: false
permissions:
contents: read
@ -49,6 +54,7 @@ jobs:
contents: write
outputs:
should_release: ${{ steps.flags.outputs.should_release }}
testflight_enabled: ${{ steps.flags.outputs.testflight_enabled }}
release_tag: ${{ steps.meta.outputs.release_tag }}
release_title: ${{ steps.meta.outputs.release_title }}
release_notes: ${{ steps.meta.outputs.release_notes }}
@ -61,6 +67,9 @@ jobs:
- name: Determine release mode
id: flags
shell: bash
env:
ENABLE_TESTFLIGHT_INPUT: ${{ github.event.inputs.enable_testflight }}
ENABLE_TESTFLIGHT_VAR: ${{ vars.ENABLE_TESTFLIGHT }}
run: |
if [[ "${GITHUB_REF:-}" == refs/tags/v* || "${GITHUB_EVENT_NAME:-}" == "workflow_dispatch" || "${GITHUB_REF:-}" == "refs/heads/main" ]]; then
echo "should_release=true" >> "$GITHUB_OUTPUT"
@ -68,6 +77,16 @@ jobs:
echo "should_release=false" >> "$GITHUB_OUTPUT"
fi
# TestFlight is opt-in (default OFF). Enabled only when explicitly
# requested via the workflow_dispatch input or the ENABLE_TESTFLIGHT
# repo/org variable. Keeps missing Apple signing secrets from failing
# the normal DMG/IPA release path.
if [[ "${ENABLE_TESTFLIGHT_INPUT:-}" == "true" || "${ENABLE_TESTFLIGHT_VAR:-}" == "true" ]]; then
echo "testflight_enabled=true" >> "$GITHUB_OUTPUT"
else
echo "testflight_enabled=false" >> "$GITHUB_OUTPUT"
fi
- name: Compute release metadata
id: meta
shell: bash
@ -257,12 +276,12 @@ jobs:
go-version: "1.24.1"
- name: Build platform artifacts
if: ${{ steps.preflight.outputs.should_build_platform == 'true' && (matrix.release_only != 'true' || env.SHOULD_RELEASE == 'true') }}
if: ${{ steps.preflight.outputs.should_build_platform == 'true' && (matrix.release_only != 'true' || env.SHOULD_RELEASE == 'true') && (matrix.package != 'app-store-pkg' || needs.prepare.outputs.testflight_enabled == 'true') }}
shell: bash
run: bash ./scripts/ci/build_matrix_artifacts.sh "$PLATFORM" "$ARCH" "${{ matrix.package }}" "$SHOULD_RELEASE"
- name: Upload build artifacts
if: ${{ steps.preflight.outputs.should_build_platform == 'true' && (matrix.release_only != 'true' || env.SHOULD_RELEASE == 'true') }}
if: ${{ steps.preflight.outputs.should_build_platform == 'true' && (matrix.release_only != 'true' || env.SHOULD_RELEASE == 'true') && (matrix.package != 'app-store-pkg' || needs.prepare.outputs.testflight_enabled == 'true') }}
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.artifact_name }}
@ -338,7 +357,7 @@ jobs:
- name: Load App Store Connect secrets
id: vault
if: ${{ matrix.target != 'github_release' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
if: ${{ matrix.target != 'github_release' && needs.prepare.outputs.testflight_enabled == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
uses: hashicorp/vault-action@v4
with:
url: ${{ env.VAULT_ADDR }}
@ -356,7 +375,7 @@ jobs:
kv/data/github-actions/xworkmate-app APP_STORE_CONNECT_API_KEY_P8_BASE64 | APP_STORE_CONNECT_API_KEY_P8_BASE64
- name: Export App Store Connect secrets
if: ${{ matrix.target != 'github_release' }}
if: ${{ matrix.target != 'github_release' && needs.prepare.outputs.testflight_enabled == 'true' }}
run: |
{
echo "APPLE_CERT_P12_BASE64=${{ steps.vault.outputs.APPLE_CERT_P12_BASE64 }}"
@ -385,13 +404,13 @@ jobs:
RELEASE_NOTES: ${{ needs.prepare.outputs.release_notes }}
- name: Download TestFlight artifact
if: ${{ matrix.target != 'github_release' }}
if: ${{ matrix.target != 'github_release' && needs.prepare.outputs.testflight_enabled == 'true' }}
uses: actions/download-artifact@v8
with:
name: ${{ matrix.artifact_name }}
path: ${{ matrix.artifact_path }}
- name: Upload to TestFlight
if: ${{ matrix.target != 'github_release' }}
if: ${{ matrix.target != 'github_release' && needs.prepare.outputs.testflight_enabled == 'true' }}
shell: bash
run: bash ./scripts/ci/testflight_upload.sh "${{ matrix.testflight_platform }}" "${{ matrix.artifact_path }}"

View File

@ -39,6 +39,11 @@ post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config|
# Xcode 27 rejects dependency targets below the app's iOS 15.5 minimum.
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.5'
end
next unless ['Pods-Runner', 'Pods-RunnerTests'].include?(target.name)
target.build_configurations.each do |config|

View File

@ -67,6 +67,6 @@ SPEC CHECKSUMS:
super_native_extensions: b763c02dc3a8fd078389f410bf15149179020cb4
WebRTC-SDK: 79942c006ea64f6fb48d7da8a4786dfc820bc1db
PODFILE CHECKSUM: 5ab2a375a52a76f419425b2b219d2743259d6f1f
PODFILE CHECKSUM: ca16f6ef66890e172b6528d5f0eb390e0410291e
COCOAPODS: 1.16.2

View File

@ -291,7 +291,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
shellScript = "export PATH=\"$PROJECT_DIR/../scripts/xcode-tools:$PATH\"\n/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
@ -306,7 +306,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
shellScript = "export PATH=\"$PROJECT_DIR/../scripts/xcode-tools:$PATH\"\n/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
BA47ED2B244B5E2B99043424 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;

View File

@ -1,4 +1,4 @@
platform :osx, '14.0'
platform :osx, '15.6'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
@ -97,7 +97,8 @@ post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_macos_build_settings(target)
target.build_configurations.each do |config|
config.build_settings['MACOSX_DEPLOYMENT_TARGET'] = '14.0'
# Xcode 27 rejects dependency targets below the app's 15.6 minimum.
config.build_settings['MACOSX_DEPLOYMENT_TARGET'] = '15.6'
next unless ['Pods-Runner', 'Pods-RunnerTests', 'WebRTC-SDK', 'flutter_webrtc'].include?(target.name)

View File

@ -61,6 +61,6 @@ SPEC CHECKSUMS:
super_native_extensions: c2795d6d9aedf4a79fae25cb6160b71b50549189
WebRTC-SDK: 79942c006ea64f6fb48d7da8a4786dfc820bc1db
PODFILE CHECKSUM: 1eb7d5d1472c632b8f775dd34562291c20ae818a
PODFILE CHECKSUM: 7804cba3ecbc9953edc70dee53b2ce2b4aeaa013
COCOAPODS: 1.16.2

View File

@ -371,7 +371,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n";
shellScript = "export PATH=\"$PROJECT_DIR/../scripts/xcode-tools:$PATH\"\necho \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n";
};
33CC111E2044C6BF0003C045 /* ShellScript */ = {
isa = PBXShellScriptBuildPhase;
@ -392,7 +392,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "if [[ \"${WORKSPACE_DIR:-}\" == *.xcodeproj ]]; then\n echo \"error: XWorkmate macOS builds with CocoaPods plugins must be launched from macos/Runner.xcworkspace, not Runner.xcodeproj.\" >&2\n echo \"error: Close this project, open macos/Runner.xcworkspace in Xcode, and build the shared Runner scheme for My Mac.\" >&2\n echo \"error: Pods targets appearing in the workspace are expected. Only configure signing on the Runner target.\" >&2\n echo \"error: For release packaging, run 'flutter build macos' from the repository root.\" >&2\n exit 1\nfi\n\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
shellScript = "export PATH=\"$PROJECT_DIR/../scripts/xcode-tools:$PATH\"\nif [[ \"${WORKSPACE_DIR:-}\" == *.xcodeproj ]]; then\n echo \"error: XWorkmate macOS builds with CocoaPods plugins must be launched from macos/Runner.xcworkspace, not Runner.xcodeproj.\" >&2\n echo \"error: Close this project, open macos/Runner.xcworkspace in Xcode, and build the shared Runner scheme for My Mac.\" >&2\n echo \"error: Pods targets appearing in the workspace are expected. Only configure signing on the Runner target.\" >&2\n echo \"error: For release packaging, run 'flutter build macos' from the repository root.\" >&2\n exit 1\nfi\n\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
};
8E8C2A3EBAA3461603096C04 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
@ -511,7 +511,7 @@
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MACOSX_DEPLOYMENT_TARGET = 15.6;
MARKETING_VERSION = 1.0;
OTHER_CFLAGS = (
"$(inherited)",
@ -539,7 +539,7 @@
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MACOSX_DEPLOYMENT_TARGET = 15.6;
MARKETING_VERSION = 1.0;
OTHER_CFLAGS = (
"$(inherited)",
@ -567,7 +567,7 @@
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MACOSX_DEPLOYMENT_TARGET = 15.6;
MARKETING_VERSION = 1.0;
OTHER_CFLAGS = (
"$(inherited)",
@ -661,11 +661,13 @@
ENABLE_RESOURCE_ACCESS_USB = NO;
ENABLE_USER_SELECTED_FILES = readonly;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Xworkmate;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MACOSX_DEPLOYMENT_TARGET = 15.6;
OTHER_CFLAGS = (
"$(inherited)",
"-Wno-ignored-attributes",
@ -823,11 +825,13 @@
ENABLE_RESOURCE_ACCESS_USB = NO;
ENABLE_USER_SELECTED_FILES = readonly;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Xworkmate;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MACOSX_DEPLOYMENT_TARGET = 15.6;
OTHER_CFLAGS = (
"$(inherited)",
"-Wno-ignored-attributes",
@ -863,11 +867,13 @@
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Xworkmate;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MACOSX_DEPLOYMENT_TARGET = 15.6;
OTHER_CFLAGS = (
"$(inherited)",
"-Wno-ignored-attributes",

26
scripts/xcode-tools/lipo Executable file
View File

@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
real_lipo="$(xcrun --find lipo)"
args=("$@")
verify_index=-1
for ((index = 0; index < ${#args[@]}; index++)); do
if [[ "${args[index]}" == "-verify_arch" ]]; then
verify_index=$index
break
fi
done
# Xcode 27 accepts one architecture per -verify_arch invocation. Flutter
# passes all requested architectures at once, so verify each one separately.
if ((verify_index >= 0 && ${#args[@]} - verify_index > 2)); then
command_prefix=("${args[@]:0:verify_index}")
architectures=("${args[@]:verify_index+1}")
for architecture in "${architectures[@]}"; do
"$real_lipo" "${command_prefix[@]}" -verify_arch "$architecture"
done
exit 0
fi
exec "$real_lipo" "$@"

View File

@ -322,9 +322,7 @@ void main() {
'xworkmate-no-runtime-main-home-',
);
addTearDown(() async {
if (await localHome.exists()) {
await localHome.delete(recursive: true);
}
await _resilientDelete(localHome);
});
final controller = _sandboxController(
environmentOverride: const <String, String>{},
@ -358,9 +356,7 @@ void main() {
'xworkmate-refresh-no-session-one-',
);
addTearDown(() async {
if (await localHome.exists()) {
await localHome.delete(recursive: true);
}
await _resilientDelete(localHome);
});
final controller = _sandboxController(
environmentOverride: const <String, String>{},
@ -439,9 +435,7 @@ void main() {
'xworkmate-stable-task-selection-home-',
);
addTearDown(() async {
if (await localHome.exists()) {
await localHome.delete(recursive: true);
}
await _resilientDelete(localHome);
});
final controller = _sandboxController(
environmentOverride: const <String, String>{},
@ -1716,9 +1710,7 @@ void main() {
'xworkmate-acp-interrupt-artifacts-',
);
addTearDown(() async {
if (await localWorkspace.exists()) {
await localWorkspace.delete(recursive: true);
}
await _resilientDelete(localWorkspace);
});
final fakeGoTaskService = _RecordingGoTaskServiceClient()
..onExecuteTask = ((request) async {
@ -2017,9 +2009,7 @@ void main() {
'xworkmate-acp-handshake-interrupt-artifacts-',
);
addTearDown(() async {
if (await localWorkspace.exists()) {
await localWorkspace.delete(recursive: true);
}
await _resilientDelete(localWorkspace);
});
final fakeGoTaskService = _RecordingGoTaskServiceClient()
..updatesBeforeNextOutcome.add(
@ -2383,9 +2373,7 @@ void main() {
'xworkmate-background-completion-home-',
);
addTearDown(() async {
if (await localHome.exists()) {
await localHome.delete(recursive: true);
}
await _resilientDelete(localHome);
});
final fakeGoTaskService = _BlockingGoTaskServiceClient();
final controller = _connectedController(
@ -2508,9 +2496,7 @@ void main() {
'xworkmate-same-prompt-home-',
);
addTearDown(() async {
if (await localHome.exists()) {
await localHome.delete(recursive: true);
}
await _resilientDelete(localHome);
});
final fakeGoTaskService = _BlockingGoTaskServiceClient();
final controller = _connectedController(
@ -2683,9 +2669,7 @@ void main() {
'xworkmate-same-prompt-empty-home-',
);
addTearDown(() async {
if (await localHome.exists()) {
await localHome.delete(recursive: true);
}
await _resilientDelete(localHome);
});
final fakeGoTaskService = _BlockingGoTaskServiceClient();
final controller = _connectedController(
@ -2707,9 +2691,7 @@ void main() {
continue;
}
final directory = Directory(workspace);
if (await directory.exists()) {
await directory.delete(recursive: true);
}
await _resilientDelete(directory);
}
});
@ -2845,9 +2827,7 @@ void main() {
'xworkmate-terminal-failure-home-',
);
addTearDown(() async {
if (await localHome.exists()) {
await localHome.delete(recursive: true);
}
await _resilientDelete(localHome);
});
final fakeGoTaskService = _BlockingGoTaskServiceClient();
final controller = _connectedController(
@ -2925,9 +2905,7 @@ void main() {
'xworkmate-empty-output-home-',
);
addTearDown(() async {
if (await localHome.exists()) {
await localHome.delete(recursive: true);
}
await _resilientDelete(localHome);
});
final fakeGoTaskService = _BlockingGoTaskServiceClient();
final controller = _connectedController(
@ -3297,9 +3275,7 @@ void main() {
addTearDown(() async {
fakeGoTaskService.completeAll();
controller.dispose();
if (await localHome.exists()) {
await localHome.delete(recursive: true);
}
await _resilientDelete(localHome);
});
for (
@ -4902,19 +4878,28 @@ UiFeatureManifest _defaultDesktopManifest() {
}
Future<void> _resilientDelete(Directory dir) async {
for (var attempt = 0; attempt < 8; attempt++) {
if (!await dir.exists()) {
return;
}
for (var attempt = 0; attempt < 8; attempt++) {
try {
await dir.delete(recursive: true);
return;
} catch (error) {
// A background flush (e.g. controller dispose still persisting state)
// may keep writing into the temp dir, so a recursive delete can race
// and fail with "Directory not empty". Retry a few times.
debugPrint('Temporary directory delete retry: $error');
await Future<void>.delayed(const Duration(milliseconds: 50));
}
}
// Best-effort cleanup: never fail a test over leftover temp files; the OS
// reclaims the temp directory regardless.
try {
await dir.delete(recursive: true);
} catch (error) {
debugPrint('Giving up on temporary directory cleanup: $error');
}
}
AppController _sandboxController({