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
40 changes: 40 additions & 0 deletions .github/workflows/claude-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Claude Code Review

on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]

jobs:
claude-review:
# Repo secrets are not available to PRs from forks; skip them.
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
id-token: write
Comment thread
fonkamloic marked this conversation as resolved.
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1

- uses: anthropics/claude-code-action@v1
Comment thread
fonkamloic marked this conversation as resolved.
Comment thread
fonkamloic marked this conversation as resolved.
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: |
REPO: ${{ github.repository }}
PR NUMBER: ${{ github.event.pull_request.number }}

Comment thread
fonkamloic marked this conversation as resolved.
Review this pull request as a senior Dart/Flutter engineer. Focus on:
- Correctness bugs and edge cases in the changed code
- API misuse and error-handling gaps
- Security issues (credential handling, injection, unsafe file I/O)
- Backwards compatibility for existing users of this package

Use `gh pr comment` for overall feedback and
`mcp__github_inline_comment__create_inline_comment` (with confirmed: true)
for line-specific issues. Only post GitHub comments — do not submit
review text as plain messages. Be concise; skip pure style nits.
claude_args: |
--model claude-sonnet-4-6
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)"
17 changes: 10 additions & 7 deletions lib/src/commands/codepush_commands/_codepush_login.dart
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,7 @@ class CodePushLoginSubCommand extends Command<int> {
final data = json.decode(body) as Map<String, dynamic>;

if (response.statusCode != 201) {
_logger.err(
'Failed to start login: ${data['error'] ?? 'Unknown'}',
);
_logger.err('Failed to start login: ${data['error'] ?? 'Unknown'}');
return ExitCode.software.code;
}

Expand All @@ -83,15 +81,19 @@ class CodePushLoginSubCommand extends Command<int> {
// Step 2: Open browser.
_logger.info('Your authorization code: $userCode');
_logger.info('');
_logger.info('Authorization URL (click or copy to use a different browser):');
_logger.info(
'Authorization URL (click or copy to use a different browser):',
);
_logger.info(' $authorizeUrl');
_logger.info('');

try {
await _openBrowser(authorizeUrl);
_logger.info('Browser opened. Authorize the CLI there.');
} catch (_) {
_logger.info('Could not open browser automatically — use the URL above.');
_logger.info(
'Could not open browser automatically — use the URL above.',
);
}

_logger.info('');
Expand Down Expand Up @@ -126,8 +128,9 @@ class CodePushLoginSubCommand extends Command<int> {
}

if (status == 'expired') {
progress
.fail('Authorization expired. Run "fcp codepush login" again.');
progress.fail(
'Authorization expired. Run "fcp codepush login" again.',
);
return ExitCode.software.code;
}

Expand Down
28 changes: 13 additions & 15 deletions lib/src/commands/codepush_commands/_codepush_patch.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,7 @@ import 'package:mason_logger/mason_logger.dart';
class CodePushPatchSubCommand extends Command<int> {
CodePushPatchSubCommand(this._logger) {
argParser
..addOption(
'release-id',
help: 'The release ID to patch against.',
)
..addOption('release-id', help: 'The release ID to patch against.')
..addOption(
'patch-file',
help: 'Path to an existing patch file to upload.',
Expand Down Expand Up @@ -202,8 +199,10 @@ class CodePushPatchSubCommand extends Command<int> {
iosPatchSourceImport = importPathForPatchSource(patchSource);
iosHelperImports = _discoverDirectHelpers(patchSource);
if (iosHelperImports.isNotEmpty) {
_logger.detail('Discovered ${iosHelperImports.length} '
'helper import(s) from patch source');
_logger.detail(
'Discovered ${iosHelperImports.length} '
'helper import(s) from patch source',
);
}
_logger.detail('Using iOS patch source: $patchSource');
_logger.detail('Generated iOS patch target: $generated');
Expand Down Expand Up @@ -311,8 +310,7 @@ class CodePushPatchSubCommand extends Command<int> {
final includeUris = <String>{
if (iosSwapMode && iosPatchSourceImport != null)
'$packagePrefix$iosPatchSourceImport',
for (final helper in iosHelperImports)
'$packagePrefix$helper',
for (final helper in iosHelperImports) '$packagePrefix$helper',
...explicitIncludes.where((u) => u.isNotEmpty),
}.toList();

Expand Down Expand Up @@ -626,8 +624,9 @@ class CodePushPatchSubCommand extends Command<int> {
if (!file.existsSync()) return null;
try {
for (final line in file.readAsLinesSync()) {
final match =
RegExp(r'^name:\s*([A-Za-z_][A-Za-z0-9_]*)\s*$').firstMatch(line);
final match = RegExp(
r'^name:\s*([A-Za-z_][A-Za-z0-9_]*)\s*$',
).firstMatch(line);
if (match != null) {
return 'package:${match.group(1)}/';
}
Expand All @@ -638,9 +637,7 @@ class CodePushPatchSubCommand extends Command<int> {
return null;
}

String? _resolveIosPatchSource({
String? explicitPath,
}) {
String? _resolveIosPatchSource({String? explicitPath}) {
if (explicitPath != null && explicitPath.isNotEmpty) {
if (!File(explicitPath).existsSync()) {
_logger.err('Patch entry source not found: $explicitPath');
Expand Down Expand Up @@ -717,8 +714,9 @@ class CodePushPatchSubCommand extends Command<int> {

// Match: import 'relative/path.dart'; or import "../path.dart";
// Exclude: dart: and package: imports.
final importPattern =
RegExp(r'''import\s+['"](?!dart:|package:)([^'"]+)['"]''');
final importPattern = RegExp(
r'''import\s+['"](?!dart:|package:)([^'"]+)['"]''',
);

for (final match in importPattern.allMatches(source)) {
final relativePath = match.group(1);
Expand Down
37 changes: 37 additions & 0 deletions lib/src/commands/codepush_commands/_codepush_release.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dart:io';

import 'package:args/command_runner.dart';
import 'package:flutter_compile/src/shared/android_baseline_yaml.dart';
import 'package:flutter_compile/src/shared/codepush_archive_service.dart';
import 'package:flutter_compile/src/shared/codepush_artifact_manager.dart';
import 'package:flutter_compile/src/shared/codepush_build_service.dart';
Expand Down Expand Up @@ -99,6 +100,7 @@ class CodePushReleaseSubCommand extends Command<int> {
final buildService = CodePushBuildService(logger: _logger);
String? baselineId;
String? originalIosInfoPlist;
String? originalAndroidYaml;
String? builtPlatform;

if (shouldBuild) {
Expand Down Expand Up @@ -171,6 +173,26 @@ class CodePushReleaseSubCommand extends Command<int> {
}
}

// Stamp the release version into the Android code push config
Comment thread
fonkamloic marked this conversation as resolved.
// asset BEFORE `flutter build`, so the shipped APK carries the
// version it is released as. Restored afterwards so the app repo
// stays clean (same pattern as the iOS Info.plist stamp above).
if ((platform == 'apk' || platform == 'appbundle') &&
version.isNotEmpty) {
originalAndroidYaml = writeReleaseVersionToAndroidYaml(version);
Comment thread
fonkamloic marked this conversation as resolved.
Comment thread
fonkamloic marked this conversation as resolved.
if (originalAndroidYaml == null) {
_logger.warn(
'Warning: $kDefaultAndroidCodePushYamlPath not found. '
'This build will not embed a release version. '
'Run "fcp codepush init" to set up Android.',
);
} else {
_logger.detail(
'Stamped release_version=$version into codepush.yaml',
);
}
}

final buildProgress = _logger.progress('Building release ($platform)');
final buildOk = await buildService.buildRelease(
platform: platform,
Expand Down Expand Up @@ -205,6 +227,21 @@ class CodePushReleaseSubCommand extends Command<int> {
restoreIosInfoPlist(originalIosInfoPlist);
_logger.detail('Restored ios/Runner/Info.plist');
}
Comment thread
fonkamloic marked this conversation as resolved.
if (originalAndroidYaml != null) {
Comment thread
fonkamloic marked this conversation as resolved.
Comment thread
fonkamloic marked this conversation as resolved.
Comment thread
fonkamloic marked this conversation as resolved.
try {
Comment thread
fonkamloic marked this conversation as resolved.
Comment thread
fonkamloic marked this conversation as resolved.
restoreAndroidYaml(originalAndroidYaml);
_logger.detail('Restored android assets/codepush.yaml');
} on FileSystemException catch (e) {
// Don't mask an in-flight build error with a restore failure;
// tell the user the repo is dirty and how to fix it.
_logger.err(
'Failed to restore $kDefaultAndroidCodePushYamlPath after the '
'build: $e\nThe file still contains the stamped release '
'version — restore it manually (e.g. git checkout -- '
'$kDefaultAndroidCodePushYamlPath).',
);
}
}
}
}

Expand Down
62 changes: 62 additions & 0 deletions lib/src/shared/android_baseline_yaml.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import 'dart:io';

const String kDefaultAndroidCodePushYamlPath =
'android/app/src/main/assets/codepush.yaml';

/// Stamps [releaseVersion] into the Android code push config asset so the
/// built APK carries the version it is released as. Returns the original
/// file content for restoring afterwards, or `null` when the file does not
/// exist (code push has not been initialized for Android).
String? writeReleaseVersionToAndroidYaml(
String releaseVersion, {
String yamlPath = kDefaultAndroidCodePushYamlPath,
}) {
if (!RegExp(r'^[A-Za-z0-9._+\-]+$').hasMatch(releaseVersion)) {
throw ArgumentError.value(
releaseVersion,
'releaseVersion',
'contains characters that would break the YAML config',
);
}

final yamlFile = File(yamlPath);
if (!yamlFile.existsSync()) return null;

final originalContent = yamlFile.readAsStringSync();
var content = originalContent;
final versionLine = 'release_version: "$releaseVersion"';
Comment thread
fonkamloic marked this conversation as resolved.

if (RegExp(r'^release_version:', multiLine: true).hasMatch(content)) {
content = content.replaceFirst(
RegExp(r'^release_version:.*$', multiLine: true),
versionLine,
Comment thread
fonkamloic marked this conversation as resolved.
);
} else {
if (content.isNotEmpty && !content.endsWith('\n')) content += '\n';
content += '$versionLine\n';
}

// Write to a temp file and rename over the original: a failed write
// (disk full, permissions) can then never corrupt the config, and the
// original error propagates without a doomed in-place rescue attempt.
final tempFile = File('$yamlPath.tmp');
Comment thread
fonkamloic marked this conversation as resolved.
Comment thread
fonkamloic marked this conversation as resolved.
try {
tempFile.writeAsStringSync(content);
tempFile.renameSync(yamlPath);
} on FileSystemException {
try {
tempFile.deleteSync();
} on FileSystemException {
// Best-effort cleanup; the original config is untouched either way.
}
rethrow;
Comment thread
fonkamloic marked this conversation as resolved.
}
return originalContent;
}

void restoreAndroidYaml(
Comment thread
fonkamloic marked this conversation as resolved.
String originalContent, {
String yamlPath = kDefaultAndroidCodePushYamlPath,
}) {
File(yamlPath).writeAsStringSync(originalContent);
Comment thread
fonkamloic marked this conversation as resolved.
Comment thread
fonkamloic marked this conversation as resolved.
Comment thread
fonkamloic marked this conversation as resolved.
}
Comment thread
fonkamloic marked this conversation as resolved.
Loading
Loading