From d70da1923e82ade5f31fd114831f278e3aad9938 Mon Sep 17 00:00:00 2001 From: greymag Date: Mon, 24 Aug 2026 10:09:29 +0200 Subject: [PATCH] fix(release): pick the right artifact line for a local iOS build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `flutter build ipa` prints two `✓ Built` lines — for the Xcode archive and for the IPA itself: ✓ Built build/ios/archive/Runner.xcarchive (202.3MB) ✓ Built IPA to build/ios/ipa (24.4MB) Alex took the first one, so the archive path was compared with the expected `build/ios/ipa` and the release failed, even though the `.ipa` was built correctly. Now all `✓ Built` lines are collected and the one with a path inside the build output directory is preferred (the last line is used as a fallback, so a failure still reports a concrete path). Parsing and the output directory check are extracted to `BuildOutput` and covered with tests. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + .../release/start_release_command.dart | 18 ++-- lib/src/release/build_output.dart | 58 ++++++++++++ test/src/release/build_output_test.dart | 93 +++++++++++++++++++ 4 files changed, 158 insertions(+), 12 deletions(-) create mode 100644 lib/src/release/build_output.dart create mode 100644 test/src/release/build_output_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index bcf8c6c..e540db2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## Next +* [Release] `start`: fixed a failure of a local iOS build (`--local`), when alex reported that the artifact was placed in an unexpected directory, though the `.ipa` was built correctly. `flutter build ipa` prints two `✓ Built` lines — for the Xcode archive and for the IPA itself — and alex checked the first one. Now the line with a path in the build output directory is used. * [Release] `start`: if the release failed, then all the changes made in the repository are rolled back automatically: uncommitted and untracked changes are discarded, release branch and tag are removed (only if they didn't exist before the release), and `develop` and `master` are restored to the state before the release. Every step is printed, and if the rollback itself fails, then instructions for a manual cleanup are shown. Nothing is rolled back as soon as the push is started — a push can update the remote even if it failed as a whole, and a published release should not be rewritten — in that case alex only reports that the release should be finished or reverted manually. ## 1.14.0 diff --git a/lib/commands/release/start_release_command.dart b/lib/commands/release/start_release_command.dart index 6a94799..e9d7f17 100644 --- a/lib/commands/release/start_release_command.dart +++ b/lib/commands/release/start_release_command.dart @@ -11,6 +11,7 @@ import 'package:alex/src/fs/path_utils.dart'; import 'package:alex/src/l10n/comparers/arb_comparer.dart'; import 'package:alex/src/l10n/locale/locales.dart'; import 'package:alex/src/release/build_artifact_name.dart'; +import 'package:alex/src/release/build_output.dart'; import 'package:alex/src/release/release_rollback.dart'; import 'package:dart_openai/openai.dart'; import 'package:list_ext/list_ext.dart'; @@ -660,11 +661,8 @@ class StartReleaseCommand extends AlexCommand with IntlMixin { // Android: ✓ Built build/app/outputs/bundle/release/app-release.aab (27.1MB). // iOS: ✓ Built IPA to build/ios/ipa (20.0MB). - final buildLine = res.stdout - ?.toString() - .split('\n') - .firstWhereOrNull((line) => line.contains('✓ Built ')) - ?.trim(); + final buildLine = + BuildOutput.getArtifactLine(res.stdout?.toString(), outputDir.path); if (buildLine != null && buildLine.isNotEmpty) printInfo(buildLine); _checkReportedPathInOutputDir(buildLine, outputDir, platform); @@ -706,18 +704,14 @@ class StartReleaseCommand extends AlexCommand with IntlMixin { return; } - // `✓ Built ()` for Android - // and `✓ Built IPA to ()` for iOS. - final match = RegExp(r'✓ Built (?:.*? to )?(.+?) \(').firstMatch(buildLine); - if (match == null) { + final reportedPath = BuildOutput.parseArtifactPath(buildLine); + if (reportedPath == null) { printVerbose("Can't parse an artifact path from the line <$buildLine>"); return; } - final reportedPath = p.absolute(match.group(1)!.trim()); final expectedPath = p.absolute(outputDir.path); - if (!p.equals(reportedPath, expectedPath) && - !p.isWithin(expectedPath, reportedPath)) { + if (!BuildOutput.isInDir(reportedPath, expectedPath)) { throw RunException.err( 'Build for ${platform.name} has placed the artifact in ' '<$reportedPath>, but <$expectedPath> was expected. ' diff --git a/lib/src/release/build_output.dart b/lib/src/release/build_output.dart new file mode 100644 index 0000000..f7da098 --- /dev/null +++ b/lib/src/release/build_output.dart @@ -0,0 +1,58 @@ +import 'package:list_ext/list_ext.dart'; +import 'package:path/path.dart' as p; + +/// Parser of an output of a `flutter build` command. +abstract class BuildOutput { + static const _artifactLineMarker = '✓ Built '; + + /// `✓ Built ()` for Android + /// and `✓ Built IPA to ()` for iOS. + static final _artifactPath = RegExp(r'✓ Built (?:.*? to )?(.+?) \('); + + /// Returns a line of the [output] which reports a path of a build artifact. + /// + /// A build can print more than one such line — for example + /// `flutter build ipa` reports both the Xcode archive and the IPA itself: + /// + /// ``` + /// ✓ Built build/ios/archive/Runner.xcarchive (202.3MB) + /// ✓ Built IPA to build/ios/ipa (24.4MB) + /// ``` + /// + /// So a line with a path inside the [outputDirPath] is preferred, + /// and the last of the printed lines is used as a fallback. + /// + /// Returns `null` if the [output] contains no such line. + static String? getArtifactLine(String? output, String outputDirPath) { + final lines = output + ?.split('\n') + .where((line) => line.contains(_artifactLineMarker)) + .map((line) => line.trim()) + .toList() ?? + const []; + + if (lines.isEmpty) return null; + + return lines.reversed.firstWhereOrNull((line) { + final path = parseArtifactPath(line); + return path != null && isInDir(path, outputDirPath); + }) ?? + lines.last; + } + + /// Returns an absolute artifact path reported by the [artifactLine]. + /// + /// Returns `null` if the line has an unknown format. + static String? parseArtifactPath(String artifactLine) { + final match = _artifactPath.firstMatch(artifactLine); + return match != null ? p.absolute(match.group(1)!.trim()) : null; + } + + /// Returns `true` if the [path] is the [dirPath] itself + /// or is located inside it. + static bool isInDir(String path, String dirPath) { + final dir = p.absolute(dirPath); + final target = p.absolute(path); + return p.equals(target, dir) || p.isWithin(dir, target); + } +} diff --git a/test/src/release/build_output_test.dart b/test/src/release/build_output_test.dart new file mode 100644 index 0000000..c6cd01c --- /dev/null +++ b/test/src/release/build_output_test.dart @@ -0,0 +1,93 @@ +import 'package:alex/src/release/build_output.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +const _androidOutputDir = 'build/app/outputs/bundle/release'; +const _iosOutputDir = 'build/ios/ipa'; + +void main() { + group('getArtifactLine', () { + test('should return a line for android build', () { + const output = "Running Gradle task 'bundleRelease'...\n" + "Running Gradle task 'bundleRelease'... 51,4s\n" + '✓ Built build/app/outputs/bundle/release/app-release.aab (27.1MB).\n'; + + expect(BuildOutput.getArtifactLine(output, _androidOutputDir), + '✓ Built build/app/outputs/bundle/release/app-release.aab (27.1MB).'); + }); + + test('should return an ipa line if an archive is also reported', () { + // Flutter reports the Xcode archive before the IPA itself. + const output = 'Building com.example.app for device (ios-release)...\n' + '✓ Built build/ios/archive/Runner.xcarchive (202.3MB)\n' + 'Building App Store IPA...\n' + '✓ Built IPA to build/ios/ipa (24.4MB).\n'; + + expect(BuildOutput.getArtifactLine(output, _iosOutputDir), + '✓ Built IPA to build/ios/ipa (24.4MB).'); + }); + + test( + 'should return a line with a path in the output dir ' + 'regardless of its position', () { + const output = '✓ Built IPA to build/ios/ipa (24.4MB).\n' + '✓ Built build/ios/archive/Runner.xcarchive (202.3MB)\n'; + + expect(BuildOutput.getArtifactLine(output, _iosOutputDir), + '✓ Built IPA to build/ios/ipa (24.4MB).'); + }); + + test('should return the last line if no one is in the output dir', () { + const output = '✓ Built build/ios/archive/Runner.xcarchive (202.3MB)\n' + '✓ Built build/ios/something_else (24.4MB).\n'; + + expect(BuildOutput.getArtifactLine(output, _iosOutputDir), + '✓ Built build/ios/something_else (24.4MB).'); + }); + + test('should return null if there is no artifact line', () { + expect(BuildOutput.getArtifactLine('Running Gradle task...\n', 'build'), + isNull); + expect(BuildOutput.getArtifactLine('', 'build'), isNull); + expect(BuildOutput.getArtifactLine(null, 'build'), isNull); + }); + }); + + group('parseArtifactPath', () { + test('should parse an android line', () { + expect( + BuildOutput.parseArtifactPath( + '✓ Built build/app/outputs/bundle/release/app-release.aab (27.1MB).'), + p.absolute('build/app/outputs/bundle/release/app-release.aab')); + }); + + test('should parse an ios line', () { + expect( + BuildOutput.parseArtifactPath( + '✓ Built IPA to build/ios/ipa (24.4MB).'), + p.absolute('build/ios/ipa')); + }); + + test('should return null for an unknown format', () { + expect(BuildOutput.parseArtifactPath('✓ Built something'), isNull); + }); + }); + + group('isInDir', () { + test('should return true for the dir itself', () { + expect(BuildOutput.isInDir('build/ios/ipa', 'build/ios/ipa'), isTrue); + }); + + test('should return true for a file inside the dir', () { + expect(BuildOutput.isInDir('build/ios/ipa/app.ipa', 'build/ios/ipa'), + isTrue); + }); + + test('should return false for a path out of the dir', () { + expect( + BuildOutput.isInDir( + 'build/ios/archive/Runner.xcarchive', 'build/ios/ipa'), + isFalse); + }); + }); +}