Skip to content
Open
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
## 0.13.0 - 2026-06-25

- resolve GitHub repository slug dynamically from git remote instead of
hardcoding a default, avoiding silent artifact fetches from the wrong repo
- add `--github-artifacts-runid` and `--github-artifacts-repo` options to
`flutterpi_tool build` for downloading engine binaries from GitHub Actions
workflow runs
- fix URL encoding in `getReleaseByTagName` for tags with special characters
- fix artifact total count parsing when GitHub API returns non-integer values

## 0.11.0 - 2026-04-19

- flutter 3.41.x compatibility (thanks to [@miguelzapp](https://github.com/miguelzapp)!)
Expand Down
50 changes: 46 additions & 4 deletions lib/src/cache.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,34 @@ import 'package:process/process.dart';

FlutterpiCache get flutterpiCache => globals.cache as FlutterpiCache;

/// Resolves a GitHub repository slug from the local git remote.
///
/// This avoids hardcoding a default repository, which could silently fetch
/// artifacts from the wrong repository if a user forgets to specify
/// `--github-artifacts-repo`. Instead, we parse the git remote URL to
/// determine the owner/repo dynamically.
///
/// Returns `null` if the git remote cannot be determined or is not a
/// GitHub repository.
gh.RepositorySlug? _resolveRepoSlugFromGit() {
try {
final result = io.Process.runSync('git', ['remote', 'get-url', 'origin']);
if (result.exitCode == 0) {
final url = (result.stdout as String).trim();
// Match both SSH (git@github.com:owner/repo.git) and
// HTTPS (https://github.com/owner/repo.git) formats.
final match =
RegExp(r'(?:github\.com[/:])([^/]+)/([^/.]+)').firstMatch(url);
if (match != null) {
return gh.RepositorySlug(match.group(1)!, match.group(2)!);
}
}
} catch (_) {
// git not available or not in a git repo, fall through to null
}
return null;
}

extension GithubReleaseFindAsset on gh.Release {
gh.ReleaseAsset? findAsset(String name) {
return assets!.cast<gh.ReleaseAsset?>().singleWhere(
Expand Down Expand Up @@ -367,7 +395,11 @@ class GithubWorkflowRunArtifact extends FlutterpiArtifact {
this.availableEngineVersion,
required super.cache,
required this.artifactDescription,
}) : repo = repo ?? gh.RepositorySlug('ardera', 'flutter-ci'),
}) : repo = repo ??
_resolveRepoSlugFromGit() ??
(throw ArgumentError(
'Could not determine repository slug. Specify --github-artifacts-repo or run from a git repository.',
)),
storageKey = _getStorageKeyForArtifact(artifactDescription),
super(artifactDescription.cacheKey);

Expand Down Expand Up @@ -467,7 +499,11 @@ class GithubReleaseArtifact extends FlutterpiArtifact {
required super.cache,
required this.github,
required this.artifactDescription,
}) : repo = repo ?? gh.RepositorySlug('ardera', 'flutter-ci'),
}) : repo = repo ??
_resolveRepoSlugFromGit() ??
(throw ArgumentError(
'Could not determine repository slug. Specify --github-artifacts-repo or run from a git repository.',
)),
storageKey = getStorageKeyForArtifact(artifactDescription),
super(artifactDescription.cacheKey);

Expand Down Expand Up @@ -818,7 +854,10 @@ class FlutterpiCacheWithFlutterArtifacts extends FlutterCache
required MyGithub github,
gh.RepositorySlug? repo,
}) {
repo ??= gh.RepositorySlug('ardera', 'flutter-ci');
repo ??= _resolveRepoSlugFromGit() ??
(throw ArgumentError(
'Could not determine repository slug. Specify --github-artifacts-repo or run from a git repository.',
));

final cache = FlutterpiCacheWithFlutterArtifacts.withoutEngineArtifacts(
logger: logger,
Expand Down Expand Up @@ -858,7 +897,10 @@ class FlutterpiCacheWithFlutterArtifacts extends FlutterCache
required String runId,
String? availableEngineVersion,
}) {
repo ??= gh.RepositorySlug('ardera', 'flutter-ci');
repo ??= _resolveRepoSlugFromGit() ??
(throw ArgumentError(
'Could not determine repository slug. Specify --github-artifacts-repo or run from a git repository.',
));

final cache = FlutterpiCacheWithFlutterArtifacts.withoutEngineArtifacts(
logger: logger,
Expand Down
18 changes: 16 additions & 2 deletions lib/src/cli/commands/build.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import 'dart:async';

import 'package:args/command_runner.dart';
import 'package:flutterpi_tool/src/artifacts.dart';
import 'package:flutterpi_tool/src/cache.dart';
import 'package:flutterpi_tool/src/cli/command_runner.dart';
import 'package:flutterpi_tool/src/fltool/common.dart';
import 'package:flutterpi_tool/src/fltool/globals.dart' as globals;
Expand Down Expand Up @@ -39,6 +38,21 @@ class BuildCommand extends FlutterpiCommand {
usesLocalFlutterpiExecutableArg(verboseHelp: verboseHelp);
usesFilesystemLayoutArg(verboseHelp: verboseHelp);

// GitHub workflow artifacts options
argParser
..addOption(
'github-artifacts-runid',
help: 'Download engine binaries from a specific GitHub Actions workflow run instead of releases.',
valueHelp: 'run-id',
hide: !verboseHelp,
)
..addOption(
'github-artifacts-repo',
help: 'GitHub repository to download engine artifacts from (owner/repo).',
valueHelp: 'owner/repo',
hide: !verboseHelp,
);

argParser
..addSeparator('Target options')
..addOption(
Expand Down Expand Up @@ -145,7 +159,7 @@ class BuildCommand extends FlutterpiCommand {
}

// update the cached flutter-pi artifacts
await flutterpiCache.updateAll(
await globals.flutterpiCache.updateAll(
const {DevelopmentArtifact.universal},
host: host,
offline: false,
Expand Down
74 changes: 67 additions & 7 deletions lib/src/context.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:io' as io;

import 'package:github/github.dart' as gh;
import 'package:flutterpi_tool/src/application_package_factory.dart';
import 'package:flutterpi_tool/src/artifacts.dart';
import 'package:flutterpi_tool/src/build_system/build_app.dart';
Expand All @@ -19,6 +20,41 @@ import 'package:flutterpi_tool/src/more_os_utils.dart';
// ignore: implementation_imports
import 'package:flutter_tools/src/context_runner.dart' as fl;

/// Raw command-line arguments, set by main() before context initialization.
List<String> rawCommandLineArgs = [];

/// Parse raw command-line arguments for --github-artifacts-runid and related options.
_WorkflowArgs? _parseWorkflowArgs(List<String> args) {
String? runId;
String? repo;
String? authToken;
for (var i = 0; i < args.length; i++) {
final arg = args[i];
if (arg.startsWith('--github-artifacts-runid=')) {
runId = arg.substring('--github-artifacts-runid='.length);
} else if (arg == '--github-artifacts-runid' && i + 1 < args.length) {
runId = args[++i];
} else if (arg.startsWith('--github-artifacts-repo=')) {
repo = arg.substring('--github-artifacts-repo='.length);
} else if (arg == '--github-artifacts-repo' && i + 1 < args.length) {
repo = args[++i];
} else if (arg.startsWith('--github-artifacts-auth-token=')) {
authToken = arg.substring('--github-artifacts-auth-token='.length);
} else if (arg == '--github-artifacts-auth-token' && i + 1 < args.length) {
authToken = args[++i];
}
}
if (runId == null) return null;
return _WorkflowArgs(runId: runId, repo: repo, authToken: authToken);
}

class _WorkflowArgs {
final String runId;
final String? repo;
final String? authToken;
_WorkflowArgs({required this.runId, this.repo, this.authToken});
}

Future<V> runInContext<V>(
FutureOr<V> Function() fn, {
bool verbose = false,
Expand All @@ -28,20 +64,44 @@ Future<V> runInContext<V>(
overrides: {
Analytics: () => const NoOpAnalytics(),
fl.TemplateRenderer: () => const fl.MustacheTemplateRenderer(),
fl.Cache: () => FlutterpiCache(
fl.Cache: () {
final workflowArgs = _parseWorkflowArgs(rawCommandLineArgs);
final httpClient = http.IOClient(
globals.httpClientFactory?.call() ?? io.HttpClient(),
);
final String? token = workflowArgs?.authToken ??
globals.platform.environment['GITHUB_TOKEN'];
final github = MyGithub.caching(
httpClient: httpClient,
auth: token != null ? gh.Authentication.bearerToken(token) : null,
);
if (workflowArgs != null) {
return FlutterpiCache.fromWorkflow(
hooks: globals.shutdownHooks,
logger: globals.logger,
fileSystem: globals.fs,
platform: globals.platform,
osUtils: globals.os as MoreOperatingSystemUtils,
projectFactory: globals.projectFactory,
processManager: globals.processManager,
github: MyGithub.caching(
httpClient: http.IOClient(
globals.httpClientFactory?.call() ?? io.HttpClient(),
),
),
),
repo: workflowArgs.repo != null
? gh.RepositorySlug.full(workflowArgs.repo!)
: null,
runId: workflowArgs.runId,
github: github,
);
}
return FlutterpiCache(
hooks: globals.shutdownHooks,
logger: globals.logger,
fileSystem: globals.fs,
platform: globals.platform,
osUtils: globals.os as MoreOperatingSystemUtils,
projectFactory: globals.projectFactory,
processManager: globals.processManager,
github: github,
);
},
fl.OperatingSystemUtils: () => MoreOperatingSystemUtils(
fileSystem: globals.fs,
logger: globals.logger,
Expand Down
1 change: 1 addition & 0 deletions lib/src/executable.dart
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ Future<void> main(List<String> args) async {

fltool.Cache.flutterRoot = await getFlutterRoot();

rawCommandLineArgs = args;
await runInContext(
() async {
try {
Expand Down
5 changes: 3 additions & 2 deletions lib/src/github.dart
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ class MyGithubImpl extends MyGithub {
String tagName, {
required gh.RepositorySlug repo,
}) async {
return await github.repositories.getReleaseByTagName(repo, tagName);
return await github.repositories.getReleaseByTagName(repo, Uri.encodeComponent(tagName));
}

@visibleForTesting
Expand Down Expand Up @@ -142,7 +142,8 @@ class MyGithubImpl extends MyGithub {
},
);

total ??= response['total_count'] as int;
final rawTotal = response['total_count'];
total ??= rawTotal is int ? rawTotal : (response['artifacts'] as Iterable).length;

for (final artifact in response['artifacts']) {
results.add(GithubArtifact.fromJson(artifact));
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: flutterpi_tool
description: A tool to make development & distribution of flutter-pi apps easier.
version: 0.11.0
version: 0.13.0
repository: https://github.com/ardera/flutterpi_tool

environment:
Expand Down