Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
18 changes: 6 additions & 12 deletions lib/commands/release/start_release_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -706,18 +704,14 @@ class StartReleaseCommand extends AlexCommand with IntlMixin {
return;
}

// `✓ Built <path> (<size>)` for Android
// and `✓ Built IPA to <path> (<size>)` 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. '
Expand Down
58 changes: 58 additions & 0 deletions lib/src/release/build_output.dart
Original file line number Diff line number Diff line change
@@ -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 <path> (<size>)` for Android
/// and `✓ Built IPA to <path> (<size>)` 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 <String>[];

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);
}
}
93 changes: 93 additions & 0 deletions test/src/release/build_output_test.dart
Original file line number Diff line number Diff line change
@@ -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);
});
});
}
Loading