Excise health from firehose (#118)

* Excise health from firehose

* Switch to branch

* Fix call site

* Fix call site 2

* Add changelog entry

* Change PR comment

* Typo

* Refactor severity

* Add version health check

* Change validation markdown

* Add tags to skip

* Prepare for publish

* Changes as per review
diff --git a/.github/workflows/health.yaml b/.github/workflows/health.yaml
index 49f964d..78774f9 100644
--- a/.github/workflows/health.yaml
+++ b/.github/workflows/health.yaml
@@ -41,8 +41,8 @@
         required: false
         type: string
       checks:
-        description: What to check for in the PR health check - any subset of "version,changelog,license"
-        default: "version,changelog,license"
+        description: What to check for in the PR health check - any subset of "version changelog license"
+        default: "version changelog license"
         type: string
         required: false
 
@@ -60,7 +60,7 @@
           sdk: ${{ inputs.sdk }}
 
       - name: Install firehose
-        run: dart pub global activate --source git https://github.com/dart-lang/ecosystem.git --git-path pkgs/firehose/ --git-ref=unifiedWorkflow #TODO remove, just for testing
+        run: dart pub global activate firehose
 
       - name: Validate packages
         if: ${{ github.event_name == 'pull_request' }}
@@ -68,4 +68,4 @@
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
           ISSUE_NUMBER: ${{ github.event.number }}
           PR_LABELS: "${{ join(github.event.pull_request.labels.*.name) }}"
-        run: dart pub global run firehose --health ${{ inputs.checks }}
+        run: dart pub global run firehose:health ${{ inputs.checks }}
diff --git a/.github/workflows/health_internal.yaml b/.github/workflows/health_internal.yaml
index 81616c5..f038f24 100644
--- a/.github/workflows/health_internal.yaml
+++ b/.github/workflows/health_internal.yaml
@@ -8,5 +8,3 @@
 jobs:
   health:
     uses: ./.github/workflows/health.yaml
-    with:
-      checks: "changelog,license"
\ No newline at end of file
diff --git a/pkgs/firehose/CHANGELOG.md b/pkgs/firehose/CHANGELOG.md
index 2fca507..8b7870a 100644
--- a/pkgs/firehose/CHANGELOG.md
+++ b/pkgs/firehose/CHANGELOG.md
@@ -1,5 +1,6 @@
-## 0.3.18-wip
+## 0.3.18
 - Add Github workflow for PR health.
+- Refactorings to health workflow.
 
 ## 0.3.17
 
diff --git a/pkgs/firehose/bin/firehose.dart b/pkgs/firehose/bin/firehose.dart
index 5e01d27..e561bd9 100644
--- a/pkgs/firehose/bin/firehose.dart
+++ b/pkgs/firehose/bin/firehose.dart
@@ -20,11 +20,10 @@
 
     var validate = argResults['validate'] == true;
     var publish = argResults['publish'] == true;
-    var health = argResults['health'] != null;
 
-    if (!validate && !publish && !health) {
-      _usage(argParser, error: '''
-Error: one of --validate, --publish, or --health must be specified.''');
+    if (!validate && !publish) {
+      _usage(argParser,
+          error: 'Error: one of --validate or --publish must be specified.');
       exit(1);
     }
 
@@ -42,8 +41,6 @@
       await firehose.validate();
     } else if (publish) {
       await firehose.publish();
-    } else if (health) {
-      await firehose.healthCheck(argResults['health'] as List);
     }
   } on ArgParserException catch (e) {
     _usage(argParser, error: e.message);
@@ -76,11 +73,6 @@
       help: 'Validate packages and indicate whether --publish would publish '
           'anything.',
     )
-    ..addMultiOption(
-      'health',
-      defaultsTo: ['version', 'license', 'changelog'],
-      help: 'Check PR health.',
-    )
     ..addFlag(
       'publish',
       negatable: false,
diff --git a/pkgs/firehose/bin/health.dart b/pkgs/firehose/bin/health.dart
new file mode 100644
index 0000000..6672f3e
--- /dev/null
+++ b/pkgs/firehose/bin/health.dart
@@ -0,0 +1,22 @@
+// Copyright (c) 2023, the Dart project authors.  Please see the AUTHORS file
+// for details. 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:args/args.dart';
+import 'package:firehose/health.dart';
+
+void main(List<String> arguments) async {
+  var argParser = ArgParser()
+    ..addMultiOption(
+      'checks',
+      defaultsTo: ['version', 'license', 'changelog'],
+      allowed: ['version', 'license', 'changelog'],
+      help: 'Check PR health.',
+    );
+  var parsedArgs = argParser.parse(arguments);
+
+  await Health(Directory.current)
+      .healthCheck(parsedArgs['checks'] as List<String>);
+}
diff --git a/pkgs/firehose/lib/firehose.dart b/pkgs/firehose/lib/firehose.dart
index 56a51d4..617ca54 100644
--- a/pkgs/firehose/lib/firehose.dart
+++ b/pkgs/firehose/lib/firehose.dart
@@ -8,7 +8,6 @@
 import 'dart:math';
 
 import 'package:firehose/src/repo.dart';
-import 'package:path/path.dart' as path;
 
 import 'src/github.dart';
 import 'src/pub.dart';
@@ -19,13 +18,6 @@
 const String _githubActionsUser = 'github-actions[bot]';
 
 const String _publishBotTag = '## Package publishing';
-const String _publishBotTag2 = '### Package publish validation';
-
-const String _licenseBotTag = '### License Headers';
-
-const String _changelogBotTag = '### Changelog entry';
-
-const String _prHealthTag = '## PR Health';
 
 const String _ignoreWarningsLabel = 'publish-ignore-warnings';
 
@@ -34,178 +26,6 @@
 
   Firehose(this.directory);
 
-  Future<void> healthCheck(List argResult) async {
-    print('Start health check for the checks $argResult');
-    var checks = [
-      if (argResult.contains('version')) validateCheck,
-      if (argResult.contains('license')) licenseCheck,
-      if (argResult.contains('changelog')) changelogCheck,
-    ];
-    await _healthCheck(checks);
-  }
-
-  Future<void> _healthCheck(
-      List<Future<HealthCheckResult> Function(Github)> checks) async {
-    var github = Github();
-
-    // Do basic validation of our expected env var.
-    if (!_expectEnv(github.githubAuthToken, 'GITHUB_TOKEN')) return;
-    if (!_expectEnv(github.repoSlug, 'GITHUB_REPOSITORY')) return;
-    if (!_expectEnv(github.issueNumber, 'ISSUE_NUMBER')) return;
-    if (!_expectEnv(github.sha, 'GITHUB_SHA')) return;
-
-    if ((github.actor ?? '').endsWith(_botSuffix)) {
-      print('Skipping package validation for ${github.actor} PRs.');
-      return;
-    }
-
-    var checked =
-        await Future.wait(checks.map((check) => check(github)).toList());
-    await writeInComment(github, checked);
-
-    github.close();
-  }
-
-  Future<HealthCheckResult> validateCheck(Github github) async {
-    var results = await _validate(github);
-
-    var markdownTable = '''
-| Package | Version | Status | Publish tag (post-merge) |
-| :--- | ---: | :--- | ---: |
-${results.describeAsMarkdown}
-
-    ''';
-
-    return HealthCheckResult(
-      _publishBotTag2,
-      results.severity,
-      markdownTable,
-    );
-  }
-
-  Future<HealthCheckResult> licenseCheck(Github github) async {
-    final license = '''
-// Copyright (c) ${DateTime.now().year}, the Dart project authors. Please see the AUTHORS file
-// for details. All rights reserved. Use of this source code is governed by a
-// BSD-style license that can be found in the LICENSE file.''';
-
-    var filePaths = await _getFilesWithoutLicenses(github);
-
-    var markdownResult = '''
-Some `.dart` files were found to not have license headers. Please add the following header to all listed files:
-```
-$license
-```
-
-| Files |
-| :--- |
-${filePaths.map((e) => '|$e|').join('\n')}
-
-Either manually or by running the following in your repository directory
-
-```
-dart pub global activate --source git https://github.com/mosuem/file_licenser
-dart pub global run file_licenser .
-```
-
-'''; //TODO: replace by pub.dev version
-
-    return HealthCheckResult(
-      _licenseBotTag,
-      filePaths.isNotEmpty ? Severity.error : Severity.success,
-      markdownResult,
-    );
-  }
-
-  Future<HealthCheckResult> changelogCheck(Github github) async {
-    var filePaths = await _packagesWithoutChangelog(github);
-
-    final markdownResult = '''
-Changes to these files need to be accounted for in their respective changelogs:
-
-| Package | Files |
-| :--- | :--- |
-${filePaths.entries.map((e) => '| package:${e.key.name} | ${e.value.map((e) => path.relative(e, from: Directory.current.path)).join('<br />')} |').join('\n')}
-''';
-
-    return HealthCheckResult(
-      _changelogBotTag,
-      filePaths.isNotEmpty ? Severity.error : Severity.success,
-      markdownResult,
-    );
-  }
-
-  Future<Map<Package, List<String>>> _packagesWithoutChangelog(
-      Github github) async {
-    final repo = Repository();
-    final packages = repo.locatePackages();
-
-    final files = await github.listFilesForPR();
-    print('Collecting packages without changed changelogs:');
-    final packagesWithoutChangedChangelog = packages.where((package) {
-      var changelogPath = package.changelog.file.path;
-      var changelog =
-          path.relative(changelogPath, from: Directory.current.path);
-      return !files.contains(changelog);
-    }).toList();
-    print('Done, found ${packagesWithoutChangedChangelog.length} packages.');
-
-    print('Collecting files without license headers in those packages:');
-    var packagesWithChanges = <Package, List<String>>{};
-    for (final file in files) {
-      for (final package in packagesWithoutChangedChangelog) {
-        if (fileNeedsEntryInChangelog(package, file)) {
-          print(file);
-          packagesWithChanges.update(
-            package,
-            (changedFiles) => [...changedFiles, file],
-            ifAbsent: () => [file],
-          );
-        }
-      }
-    }
-    print('''
-Done, found ${packagesWithChanges.length} packages with a need for a changelog.''');
-    return packagesWithChanges;
-  }
-
-  bool fileNeedsEntryInChangelog(Package package, String file) {
-    final directoryPath = package.directory.path;
-    final directory =
-        path.relative(directoryPath, from: Directory.current.path);
-    final isInPackage = path.isWithin(directory, file);
-    final isInLib = path.isWithin(path.join(directory, 'lib'), file);
-    final isInBin = path.isWithin(path.join(directory, 'bin'), file);
-    final isPubspec = file.endsWith('pubspec.yaml');
-    final isReadme = file.endsWith('README.md');
-    return isInPackage && (isInLib || isInBin || isPubspec || isReadme);
-  }
-
-  Future<List<String>> _getFilesWithoutLicenses(Github github) async {
-    var dir = Directory.current;
-    var dartFiles = await dir
-        .list(recursive: true)
-        .where((f) => f.path.endsWith('.dart'))
-        .toList();
-    print('Collecting files without license headers:');
-    var filesWithoutLicenses = dartFiles
-        .map((file) {
-          var fileContents = File(file.path).readAsStringSync();
-          var fileContainsCopyright = fileContents.contains('// Copyright (c)');
-          if (!fileContainsCopyright) {
-            var relativePath =
-                path.relative(file.path, from: Directory.current.path);
-            print(relativePath);
-            return relativePath;
-          }
-        })
-        .whereType<String>()
-        .toList();
-    print('''
-Done, found ${filesWithoutLicenses.length} files without license headers''');
-    return filesWithoutLicenses;
-  }
-
   /// Validate the packages in the repository.
   ///
   /// This method is intended to run in the context of a PR. It will:
@@ -217,22 +37,22 @@
     var github = Github();
 
     // Do basic validation of our expected env var.
-    if (!_expectEnv(github.githubAuthToken, 'GITHUB_TOKEN')) return;
-    if (!_expectEnv(github.repoSlug, 'GITHUB_REPOSITORY')) return;
-    if (!_expectEnv(github.issueNumber, 'ISSUE_NUMBER')) return;
-    if (!_expectEnv(github.sha, 'GITHUB_SHA')) return;
+    if (!expectEnv(github.githubAuthToken, 'GITHUB_TOKEN')) return;
+    if (!expectEnv(github.repoSlug, 'GITHUB_REPOSITORY')) return;
+    if (!expectEnv(github.issueNumber, 'ISSUE_NUMBER')) return;
+    if (!expectEnv(github.sha, 'GITHUB_SHA')) return;
 
     if ((github.actor ?? '').endsWith(_botSuffix)) {
       print('Skipping package validation for ${github.actor} PRs.');
       return;
     }
 
-    var results = await _validate(github);
+    var results = await verify(github);
 
     var markdownTable = '''
 | Package | Version | Status | Publish tag (post-merge) |
 | :--- | ---: | :--- | ---: |
-${results.describeAsMarkdown}
+${results.describeAsMarkdown()}
 
 Documentation at https://github.com/dart-lang/ecosystem/wiki/Publishing-automation.
 ''';
@@ -280,60 +100,7 @@
     github.close();
   }
 
-  Future<void> writeInComment(
-    Github github,
-    List<HealthCheckResult> results,
-  ) async {
-    var commentText = results.map((e) {
-      var markdown = e.markdown;
-      var s = '''
-<details${e.severity == Severity.error ? ' open' : ''}>
-<summary>
-Details
-</summary>
-
-$markdown
-</details>
-
-''';
-      return '${e.tag} ${e.severity.emoji}\n\n$s';
-    }).join('\n');
-
-    var summary = '$_prHealthTag\n\n$commentText';
-    github.appendStepSummary(summary);
-
-    var repoSlug = github.repoSlug!;
-    var issueNumber = github.issueNumber!;
-
-    var existingCommentId = await allowFailure(
-      github.findCommentId(
-        repoSlug,
-        issueNumber,
-        user: _githubActionsUser,
-        searchTerm: _prHealthTag,
-      ),
-      logError: print,
-    );
-
-    if (existingCommentId == null) {
-      await allowFailure(
-        github.createComment(repoSlug, issueNumber, summary),
-        logError: print,
-      );
-    } else {
-      await allowFailure(
-        github.updateComment(repoSlug, existingCommentId, summary),
-        logError: print,
-      );
-    }
-
-    if (results.any((result) => result.severity == Severity.error) &&
-        exitCode == 0) {
-      exitCode = 1;
-    }
-  }
-
-  Future<VerificationResults> _validate(Github github) async {
+  Future<VerificationResults> verify(Github github) async {
     var repo = Repository();
     var packages = repo.locatePackages();
 
@@ -431,7 +198,7 @@
   Future<bool> _publish() async {
     var github = Github();
 
-    if (!_expectEnv(github.refName, 'GITHUB_REF_NAME')) return false;
+    if (!expectEnv(github.refName, 'GITHUB_REF_NAME')) return false;
 
     // Validate the git tag.
     var tag = Tag(github.refName!);
@@ -507,15 +274,6 @@
     }
     return result == 0;
   }
-
-  bool _expectEnv(String? value, String name) {
-    if (value == null) {
-      print("Expected environment variable not found: '$name'");
-      return false;
-    } else {
-      return true;
-    }
-  }
 }
 
 class VerificationResults {
@@ -530,7 +288,7 @@
 
   bool get hasError => results.any((r) => r.severity == Severity.error);
 
-  String get describeAsMarkdown {
+  String describeAsMarkdown([bool withTag = true]) {
     results.sort((a, b) => Enum.compareByIndex(a.severity, b.severity));
 
     return results.map((r) {
@@ -541,20 +299,13 @@
         tag = '[$tag]($publishReleaseUri)';
       }
 
+      var tagColumn = withTag ? ' | $tag' : '';
       return '| package:${r.package.name} | ${r.package.version} | '
-          '$sev${r.message} | $tag |';
+          '$sev${r.message}$tagColumn |';
     }).join('\n');
   }
 }
 
-class HealthCheckResult {
-  final String tag;
-  final Severity severity;
-  final String markdown;
-
-  HealthCheckResult(this.tag, this.severity, this.markdown);
-}
-
 class Result {
   final Severity severity;
   final Package package;
@@ -585,13 +336,11 @@
 }
 
 enum Severity {
-  success,
-  info,
-  error;
+  success(':heavy_check_mark:'),
+  info(':heavy_check_mark:'),
+  error(':exclamation:');
 
-  String get emoji => switch (this) {
-        Severity.info => ':heavy_check_mark:',
-        Severity.error => ':exclamation:',
-        success => ':heavy_check_mark:',
-      };
+  final String emoji;
+
+  const Severity(this.emoji);
 }
diff --git a/pkgs/firehose/lib/health.dart b/pkgs/firehose/lib/health.dart
new file mode 100644
index 0000000..a1c696f
--- /dev/null
+++ b/pkgs/firehose/lib/health.dart
@@ -0,0 +1,261 @@
+// Copyright (c) 2023, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+// ignore_for_file: always_declare_return_types
+
+import 'dart:io';
+
+import 'package:firehose/firehose.dart';
+import 'package:firehose/src/repo.dart';
+import 'package:path/path.dart' as path;
+
+import 'src/github.dart';
+import 'src/utils.dart';
+
+const String _botSuffix = '[bot]';
+
+const String _githubActionsUser = 'github-actions[bot]';
+
+const String _publishBotTag2 = '### Package publish validation';
+
+const String _licenseBotTag = '### License Headers';
+
+const String _changelogBotTag = '### Changelog Entry';
+
+const String _prHealthTag = '## PR Health';
+
+class Health {
+  final Directory directory;
+
+  Health(this.directory);
+
+  Future<void> healthCheck(List args) async {
+    var github = Github();
+
+    // Do basic validation of our expected env var.
+    if (!expectEnv(github.githubAuthToken, 'GITHUB_TOKEN')) return;
+    if (!expectEnv(github.repoSlug, 'GITHUB_REPOSITORY')) return;
+    if (!expectEnv(github.issueNumber, 'ISSUE_NUMBER')) return;
+    if (!expectEnv(github.sha, 'GITHUB_SHA')) return;
+
+    if ((github.actor ?? '').endsWith(_botSuffix)) {
+      print('Skipping package validation for ${github.actor} PRs.');
+      return;
+    }
+
+    print('Start health check for the checks $args');
+    var checks = [
+      if (args.contains('version') &&
+          !github.prLabels.contains('skip-validate-check'))
+        validateCheck,
+      if (args.contains('license') &&
+          !github.prLabels.contains('skip-license-check'))
+        licenseCheck,
+      if (args.contains('changelog') &&
+          !github.prLabels.contains('skip-changelog-check'))
+        changelogCheck,
+    ];
+
+    var checked =
+        await Future.wait(checks.map((check) => check(github)).toList());
+    await writeInComment(github, checked);
+
+    github.close();
+  }
+
+  Future<HealthCheckResult> validateCheck(Github github) async {
+    var results = await Firehose(directory).verify(github);
+
+    var markdownTable = '''
+| Package | Version | Status |
+| :--- | ---: | :--- |
+${results.describeAsMarkdown(false)}
+
+Documentation at https://github.com/dart-lang/ecosystem/wiki/Publishing-automation.
+    ''';
+
+    return HealthCheckResult(
+      _publishBotTag2,
+      results.severity,
+      markdownTable,
+    );
+  }
+
+  Future<HealthCheckResult> licenseCheck(Github github) async {
+    final license = '''
+// Copyright (c) ${DateTime.now().year}, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.''';
+
+    var filePaths = await _getFilesWithoutLicenses(github);
+
+    var markdownResult = '''
+```
+$license
+```
+
+| Files |
+| :--- |
+${filePaths.isNotEmpty ? filePaths.map((e) => '|$e|').join('\n') : '| _no missing headers_  |'}
+
+All source files should start with a [license header](https://github.com/dart-lang/ecosystem/wiki/License-Header).
+''';
+
+    return HealthCheckResult(
+      _licenseBotTag,
+      filePaths.isNotEmpty ? Severity.error : Severity.success,
+      markdownResult,
+    );
+  }
+
+  Future<HealthCheckResult> changelogCheck(Github github) async {
+    var filePaths = await _packagesWithoutChangelog(github);
+
+    final markdownResult = '''
+| Package | Changed Files |
+| :--- | :--- |
+${filePaths.entries.map((e) => '| package:${e.key.name} | ${e.value.map((e) => path.relative(e, from: Directory.current.path)).join('<br />')} |').join('\n')}
+
+Changes to files need to be [accounted for](https://github.com/dart-lang/ecosystem/wiki/Changelog) in their respective changelogs.
+''';
+
+    return HealthCheckResult(
+      _changelogBotTag,
+      filePaths.isNotEmpty ? Severity.error : Severity.success,
+      markdownResult,
+    );
+  }
+
+  Future<Map<Package, List<String>>> _packagesWithoutChangelog(
+      Github github) async {
+    final repo = Repository();
+    final packages = repo.locatePackages();
+
+    final files = await github.listFilesForPR();
+    print('Collecting packages without changed changelogs:');
+    final packagesWithoutChangedChangelog = packages.where((package) {
+      var changelogPath = package.changelog.file.path;
+      var changelog =
+          path.relative(changelogPath, from: Directory.current.path);
+      return !files.contains(changelog);
+    }).toList();
+    print('Done, found ${packagesWithoutChangedChangelog.length} packages.');
+
+    print('Collecting files without license headers in those packages:');
+    var packagesWithChanges = <Package, List<String>>{};
+    for (final file in files) {
+      for (final package in packagesWithoutChangedChangelog) {
+        if (fileNeedsEntryInChangelog(package, file)) {
+          print(file);
+          packagesWithChanges.update(
+            package,
+            (changedFiles) => [...changedFiles, file],
+            ifAbsent: () => [file],
+          );
+        }
+      }
+    }
+    print('''
+Done, found ${packagesWithChanges.length} packages with a need for a changelog.''');
+    return packagesWithChanges;
+  }
+
+  bool fileNeedsEntryInChangelog(Package package, String file) {
+    final directoryPath = package.directory.path;
+    final directory =
+        path.relative(directoryPath, from: Directory.current.path);
+    final isInPackage = path.isWithin(directory, file);
+    final isInLib = path.isWithin(path.join(directory, 'lib'), file);
+    final isInBin = path.isWithin(path.join(directory, 'bin'), file);
+    final isPubspec = file.endsWith('pubspec.yaml');
+    final isReadme = file.endsWith('README.md');
+    return isInPackage && (isInLib || isInBin || isPubspec || isReadme);
+  }
+
+  Future<List<String>> _getFilesWithoutLicenses(Github github) async {
+    var dir = Directory.current;
+    var dartFiles = await dir
+        .list(recursive: true)
+        .where((f) => f.path.endsWith('.dart'))
+        .toList();
+    print('Collecting files without license headers:');
+    var filesWithoutLicenses = dartFiles
+        .map((file) {
+          var fileContents = File(file.path).readAsStringSync();
+          var fileContainsCopyright = fileContents.contains('// Copyright (c)');
+          if (!fileContainsCopyright) {
+            var relativePath =
+                path.relative(file.path, from: Directory.current.path);
+            print(relativePath);
+            return relativePath;
+          }
+        })
+        .whereType<String>()
+        .toList();
+    print('''
+Done, found ${filesWithoutLicenses.length} files without license headers''');
+    return filesWithoutLicenses;
+  }
+
+  Future<void> writeInComment(
+    Github github,
+    List<HealthCheckResult> results,
+  ) async {
+    var commentText = results.map((e) {
+      var markdown = e.markdown;
+      var s = '''
+<details${e.severity == Severity.error ? ' open' : ''}>
+<summary>
+Details
+</summary>
+
+$markdown
+</details>
+
+''';
+      return '${e.tag} ${e.severity.emoji}\n\n$s';
+    }).join('\n');
+
+    var summary = '$_prHealthTag\n\n$commentText';
+    github.appendStepSummary(summary);
+
+    var repoSlug = github.repoSlug!;
+    var issueNumber = github.issueNumber!;
+
+    var existingCommentId = await allowFailure(
+      github.findCommentId(
+        repoSlug,
+        issueNumber,
+        user: _githubActionsUser,
+        searchTerm: _prHealthTag,
+      ),
+      logError: print,
+    );
+
+    if (existingCommentId == null) {
+      await allowFailure(
+        github.createComment(repoSlug, issueNumber, summary),
+        logError: print,
+      );
+    } else {
+      await allowFailure(
+        github.updateComment(repoSlug, existingCommentId, summary),
+        logError: print,
+      );
+    }
+
+    if (results.any((result) => result.severity == Severity.error) &&
+        exitCode == 0) {
+      exitCode = 1;
+    }
+  }
+}
+
+class HealthCheckResult {
+  final String tag;
+  final Severity severity;
+  final String markdown;
+
+  HealthCheckResult(this.tag, this.severity, this.markdown);
+}
diff --git a/pkgs/firehose/lib/src/utils.dart b/pkgs/firehose/lib/src/utils.dart
index afd3d94..7905487 100644
--- a/pkgs/firehose/lib/src/utils.dart
+++ b/pkgs/firehose/lib/src/utils.dart
@@ -78,3 +78,12 @@
     return null;
   }
 }
+
+bool expectEnv(String? value, String name) {
+  if (value == null) {
+    print("Expected environment variable not found: '$name'");
+    return false;
+  } else {
+    return true;
+  }
+}
diff --git a/pkgs/firehose/pubspec.yaml b/pkgs/firehose/pubspec.yaml
index 205f46e..5fafbc8 100644
--- a/pkgs/firehose/pubspec.yaml
+++ b/pkgs/firehose/pubspec.yaml
@@ -1,6 +1,6 @@
 name: firehose
 description: A tool to automate publishing of Pub packages from GitHub actions.
-version: 0.3.18-wip
+version: 0.3.18
 repository: https://github.com/dart-lang/ecosystem/tree/main/pkgs/firehose
 
 environment: