Add a script to post-process docs. (#112228)
diff --git a/dev/bots/post_process_docs.dart b/dev/bots/post_process_docs.dart
new file mode 100644
index 0000000..8be4123
--- /dev/null
+++ b/dev/bots/post_process_docs.dart
@@ -0,0 +1,155 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:convert';
+import 'dart:io';
+import 'package:intl/intl.dart';
+import 'package:meta/meta.dart';
+
+import 'package:path/path.dart' as path;
+import 'package:platform/platform.dart' as platform;
+
+import 'package:process/process.dart';
+
+const String kDocsRoot = 'dev/docs';
+const String kPublishRoot = '$kDocsRoot/doc';
+
+class CommandException implements Exception {}
+
+Future<void> main() async {
+ await postProcess();
+}
+
+/// Post-processes an APIs documentation zip file to modify the footer and version
+/// strings for commits promoted to either beta or stable channels.
+Future<void> postProcess() async {
+ final String revision = await gitRevision(fullLength: true);
+ print('Docs revision being processed: $revision');
+ final Directory tmpFolder = Directory.systemTemp.createTempSync();
+ final String zipDestination = path.join(tmpFolder.path, 'api_docs.zip');
+
+ if (!Platform.environment.containsKey('SDK_CHECKOUT_PATH')) {
+ print('SDK_CHECKOUT_PATH env variable is required for this script');
+ exit(1);
+ }
+ final String checkoutPath = Platform.environment['SDK_CHECKOUT_PATH']!;
+ final String docsPath = path.join(checkoutPath, 'dev', 'docs');
+ await runProcessWithValidations(
+ <String>[
+ 'curl',
+ '-L',
+ 'https://storage.googleapis.com/flutter_infra_release/flutter/$revision/api_docs.zip',
+ '--output',
+ zipDestination,
+ '--fail',
+ ],
+ docsPath,
+ );
+
+ // Unzip to docs folder.
+ await runProcessWithValidations(
+ <String>[
+ 'unzip',
+ '-o',
+ zipDestination,
+ ],
+ docsPath,
+ );
+
+ // Generate versions file.
+ await runProcessWithValidations(
+ <String>['flutter', '--version'],
+ docsPath,
+ );
+ final File versionFile = File('version');
+ final String version = versionFile.readAsStringSync();
+ // Recreate footer
+ final String publishPath = path.join(docsPath, 'doc', 'api', 'footer.js');
+ final File footerFile = File(publishPath)..createSync(recursive: true);
+ createFooter(footerFile, version);
+}
+
+/// Gets the git revision of the current checkout. [fullLength] if true will return
+/// the full commit hash, if false it will return the first 10 characters only.
+Future<String> gitRevision({
+ bool fullLength = false,
+ @visibleForTesting platform.Platform platform = const platform.LocalPlatform(),
+ @visibleForTesting ProcessManager processManager = const LocalProcessManager(),
+}) async {
+ const int kGitRevisionLength = 10;
+
+ final ProcessResult gitResult = processManager.runSync(<String>['git', 'rev-parse', 'HEAD']);
+ if (gitResult.exitCode != 0) {
+ throw 'git rev-parse exit with non-zero exit code: ${gitResult.exitCode}';
+ }
+ final String gitRevision = (gitResult.stdout as String).trim();
+ if (fullLength) {
+ return gitRevision;
+ }
+ return gitRevision.length > kGitRevisionLength ? gitRevision.substring(0, kGitRevisionLength) : gitRevision;
+}
+
+/// Wrapper function to run a subprocess checking exit code and printing stderr and stdout.
+/// [executable] is a string with the script/binary to execute, [args] is the list of flags/arguments
+/// and [workingDirectory] is as string to the working directory where the subprocess will be run.
+Future<void> runProcessWithValidations(
+ List<String> command,
+ String workingDirectory, {
+ @visibleForTesting ProcessManager processManager = const LocalProcessManager(),
+}) async {
+ final ProcessResult result =
+ processManager.runSync(command, stdoutEncoding: utf8, workingDirectory: workingDirectory);
+ if (result.exitCode == 0) {
+ print('Stdout: ${result.stdout}');
+ } else {
+ print('StdErr: ${result.stderr}');
+ throw CommandException();
+ }
+}
+
+/// Get the name of the release branch.
+///
+/// On LUCI builds, the git HEAD is detached, so first check for the env
+/// variable "LUCI_BRANCH"; if it is not set, fall back to calling git.
+Future<String> getBranchName({
+ @visibleForTesting platform.Platform platform = const platform.LocalPlatform(),
+ @visibleForTesting ProcessManager processManager = const LocalProcessManager(),
+}) async {
+ final RegExp gitBranchRegexp = RegExp(r'^## (.*)');
+ final String? luciBranch = platform.environment['LUCI_BRANCH'];
+ if (luciBranch != null && luciBranch.trim().isNotEmpty) {
+ return luciBranch.trim();
+ }
+ final ProcessResult gitResult = processManager.runSync(<String>['git', 'status', '-b', '--porcelain']);
+ if (gitResult.exitCode != 0) {
+ throw 'git status exit with non-zero exit code: ${gitResult.exitCode}';
+ }
+ final RegExpMatch? gitBranchMatch = gitBranchRegexp.firstMatch((gitResult.stdout as String).trim().split('\n').first);
+ return gitBranchMatch == null ? '' : gitBranchMatch.group(1)!.split('...').first;
+}
+
+/// Updates the footer of the api documentation with the correct branch and versions.
+/// [footerPath] is the path to the location of the footer js file and [version] is a
+/// string with the version calculated by the flutter tool.
+Future<void> createFooter(File footerFile, String version,
+ {@visibleForTesting String? timestampParam,
+ @visibleForTesting String? branchParam,
+ @visibleForTesting String? revisionParam}) async {
+ final String timestamp = timestampParam ?? DateFormat('yyyy-MM-dd HH:mm').format(DateTime.now());
+ final String gitBranch = branchParam ?? await getBranchName();
+ final String revision = revisionParam ?? await gitRevision();
+ final String gitBranchOut = gitBranch.isEmpty ? '' : '• $gitBranch';
+ footerFile.writeAsStringSync('''
+(function() {
+ var span = document.querySelector('footer>span');
+ if (span) {
+ span.innerText = 'Flutter $version • $timestamp • $revision $gitBranchOut';
+ }
+ var sourceLink = document.querySelector('a.source-link');
+ if (sourceLink) {
+ sourceLink.href = sourceLink.href.replace('/master/', '/$revision/');
+ }
+})();
+''');
+}
diff --git a/dev/bots/pubspec.yaml b/dev/bots/pubspec.yaml
index 3ad830b..00cc658 100644
--- a/dev/bots/pubspec.yaml
+++ b/dev/bots/pubspec.yaml
@@ -7,6 +7,7 @@
dependencies:
args: 2.3.1
crypto: 3.0.2
+ intl: 0.17.0
flutter_devicelab:
path: ../devicelab
http_parser: 4.0.1
@@ -23,6 +24,7 @@
async: 2.9.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
boolean_selector: 2.1.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
checked_yaml: 2.0.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
+ clock: 1.1.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
collection: 1.16.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
convert: 3.0.2 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
coverage: 1.6.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
@@ -69,4 +71,4 @@
dev_dependencies:
test_api: 0.4.14
-# PUBSPEC CHECKSUM: 09b7
+# PUBSPEC CHECKSUM: 7a48
diff --git a/dev/bots/test/post_process_docs_test.dart b/dev/bots/test/post_process_docs_test.dart
new file mode 100644
index 0000000..043d722
--- /dev/null
+++ b/dev/bots/test/post_process_docs_test.dart
@@ -0,0 +1,165 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:io';
+
+import 'package:file/memory.dart';
+import 'package:platform/platform.dart';
+
+import '../../../packages/flutter_tools/test/src/fake_process_manager.dart';
+import '../post_process_docs.dart';
+import 'common.dart';
+
+void main() async {
+ group('getBranch', () {
+ const String branchName = 'stable';
+ test('getBranchName does not call git if env LUCI_BRANCH provided', () async {
+ final Platform platform = FakePlatform(
+ environment: <String, String>{
+ 'LUCI_BRANCH': branchName,
+ },
+ );
+ final ProcessManager processManager = FakeProcessManager.empty();
+ final String calculatedBranchName = await getBranchName(
+ platform: platform,
+ processManager: processManager,
+ );
+ expect(calculatedBranchName, branchName);
+ });
+
+ test('getBranchName calls git if env LUCI_BRANCH not provided', () async {
+ final Platform platform = FakePlatform(
+ environment: <String, String>{},
+ );
+
+ final ProcessManager processManager = FakeProcessManager.list(
+ <FakeCommand>[
+ const FakeCommand(
+ command: <String>['git', 'status', '-b', '--porcelain'],
+ stdout: '## $branchName',
+ ),
+ ],
+ );
+
+ final String calculatedBranchName = await getBranchName(platform: platform, processManager: processManager);
+ expect(
+ calculatedBranchName,
+ branchName,
+ );
+ expect(processManager, hasNoRemainingExpectations);
+ });
+ test('getBranchName calls git if env LUCI_BRANCH is empty', () async {
+ final Platform platform = FakePlatform(
+ environment: <String, String>{
+ 'LUCI_BRANCH': '',
+ },
+ );
+
+ final ProcessManager processManager = FakeProcessManager.list(
+ <FakeCommand>[
+ const FakeCommand(
+ command: <String>['git', 'status', '-b', '--porcelain'],
+ stdout: '## $branchName',
+ ),
+ ],
+ );
+ final String calculatedBranchName = await getBranchName(
+ platform: platform,
+ processManager: processManager,
+ );
+ expect(
+ calculatedBranchName,
+ branchName,
+ );
+ expect(processManager, hasNoRemainingExpectations);
+ });
+ });
+
+ group('gitRevision', () {
+ test('Return short format', () async {
+ const String commitHash = 'e65f01793938e13cac2d321b9fcdc7939f9b2ea6';
+ final ProcessManager processManager = FakeProcessManager.list(
+ <FakeCommand>[
+ const FakeCommand(
+ command: <String>['git', 'rev-parse', 'HEAD'],
+ stdout: commitHash,
+ ),
+ ],
+ );
+ final String revision = await gitRevision(processManager: processManager);
+ expect(processManager, hasNoRemainingExpectations);
+ expect(revision, commitHash.substring(0, 10));
+ });
+
+ test('Return full length', () async {
+ const String commitHash = 'e65f01793938e13cac2d321b9fcdc7939f9b2ea6';
+ final ProcessManager processManager = FakeProcessManager.list(
+ <FakeCommand>[
+ const FakeCommand(
+ command: <String>['git', 'rev-parse', 'HEAD'],
+ stdout: commitHash,
+ ),
+ ],
+ );
+ final String revision = await gitRevision(fullLength: true, processManager: processManager);
+ expect(processManager, hasNoRemainingExpectations);
+ expect(revision, commitHash);
+ });
+ });
+
+ group('runProcessWithValidation', () {
+ test('With no error', () async {
+ const List<String> command = <String>['git', 'rev-parse', 'HEAD'];
+ final ProcessManager processManager = FakeProcessManager.list(
+ <FakeCommand>[
+ const FakeCommand(
+ command: command,
+ ),
+ ],
+ );
+ await runProcessWithValidations(command, '', processManager: processManager);
+ expect(processManager, hasNoRemainingExpectations);
+ });
+
+ test('With error', () async {
+ const List<String> command = <String>['git', 'rev-parse', 'HEAD'];
+ final ProcessManager processManager = FakeProcessManager.list(
+ <FakeCommand>[
+ const FakeCommand(
+ command: command,
+ exitCode: 1,
+ ),
+ ],
+ );
+ try {
+ await runProcessWithValidations(command, '', processManager: processManager);
+ throw Exception('Exception was not thrown');
+ } on CommandException catch (e) {
+ expect(e, isA<Exception>());
+ }
+ });
+ });
+
+ group('generateFooter', () {
+ test('generated correctly', () async {
+ const String expectedContent = '''
+(function() {
+ var span = document.querySelector('footer>span');
+ if (span) {
+ span.innerText = 'Flutter 3.0.0 • 2022-09-22 14:09 • abcdef • stable';
+ }
+ var sourceLink = document.querySelector('a.source-link');
+ if (sourceLink) {
+ sourceLink.href = sourceLink.href.replace('/master/', '/abcdef/');
+ }
+})();
+''';
+ final MemoryFileSystem fs = MemoryFileSystem();
+ final File footerFile = fs.file('/a/b/c/footer.js')..createSync(recursive: true);
+ await createFooter(footerFile, '3.0.0', timestampParam: '2022-09-22 14:09', branchParam: 'stable', revisionParam: 'abcdef');
+ final String content = await footerFile.readAsString();
+ expect(content, expectedContent);
+ });
+ });
+}