diff --git a/tool/dash_site/lib/src/commands/stage_preview.dart b/tool/dash_site/lib/src/commands/stage_preview.dart index 0e7f8a124d..c572bacaed 100644 --- a/tool/dash_site/lib/src/commands/stage_preview.dart +++ b/tool/dash_site/lib/src/commands/stage_preview.dart @@ -16,7 +16,7 @@ import 'build.dart'; /// Builds the selected or default site, /// deploys it to a Firebase Hosting preview channel, -/// and posts or updates a comment on the source GitHub PR when requested. +/// and publishes a deployment status on the source GitHub PR when requested. final class StagePreviewCommand extends Command { static const String _projectOption = 'project'; static const String _channelOption = 'channel'; @@ -52,22 +52,22 @@ final class StagePreviewCommand extends Command { ..addOption( _prNumberOption, help: - 'The pull request number to comment on. ' - 'Required together with --$_repoOption to post a preview comment.', + 'The pull request number to publish a deployment for. ' + 'Required together with --$_repoOption to publish a deployment.', ) ..addOption( _repoOption, help: 'The full repository name in "owner/repository" form. ' 'Required together with --$_prNumberOption to ' - 'post a preview comment.', + 'publish a deployment.', valueHelp: 'owner/repository', ) ..addOption( _commitShaOption, help: 'The commit SHA being staged. ' - 'Required to post a preview comment.', + 'Required to publish a deployment.', ) ..addOption( _headBranchOption, @@ -78,7 +78,7 @@ final class StagePreviewCommand extends Command { @override String get description => 'Build the site, deploy it to a Firebase staging channel, ' - 'and comment the preview URL on GitHub.'; + 'and publish the preview URL as a GitHub deployment.'; @override String get name => 'stage-preview'; @@ -116,7 +116,7 @@ final class StagePreviewCommand extends Command { if (commitSha == null) { stderr.writeln( 'Error: --$_commitShaOption must be set to ' - 'comment on the pull request.', + 'publish a deployment.', ); return 1; } @@ -125,7 +125,7 @@ final class StagePreviewCommand extends Command { if (githubToken == null) { stderr.writeln( 'Error: $githubPatTokenEnv must be set to ' - 'comment on the pull request.', + 'publish a deployment.', ); return 1; } @@ -139,7 +139,7 @@ final class StagePreviewCommand extends Command { if (prNumberArg != null || repoFullName != null) { stderr.writeln( 'Warning: Both --$_prNumberOption and --$_repoOption must be set ' - 'to comment on the pull request; skipping the GitHub comment.', + 'to publish a deployment; skipping the GitHub deployment.', ); } prContext = null; @@ -174,12 +174,12 @@ final class StagePreviewCommand extends Command { } if (prContext == null) { - print('No pull request context available; skipping GitHub comment.'); + print('No pull request context available; skipping GitHub deployment.'); print(stagingUrl); return 0; } - return _commentStagingUrlOnGitHub( + return _publishStagingDeploymentOnGitHub( site: selectedSite, stagingUrl: stagingUrl, context: prContext, @@ -255,52 +255,75 @@ String? _extractDeployedUrl(String firebaseJsonOutput) { return null; } -/// Posts a new preview comment on the pull request, -/// or updates the existing one identified by an HTML marker that -/// includes the [site]'s name. +/// The GitHub REST API version used for preview deployment calls. +const String _githubApiVersion = '2026-03-10'; + +/// The GitHub REST API media type used for raw deployment calls. +const String _githubJsonMimeType = 'application/vnd.github+json'; + +/// The deployment task used to distinguish site previews from other deploys. +const String _previewDeploymentTask = 'deploy:preview'; + +/// Publishes [stagingUrl] as the latest PR/site preview deployment. +/// +/// Each staging run creates a deployment for the staged commit. The success +/// status is followed by inactive statuses and deletes for older deployments +/// for the same environment. /// /// Returns `0` on success or `1` if the GitHub API call fails. -Future _commentStagingUrlOnGitHub({ +Future _publishStagingDeploymentOnGitHub({ required Site site, required String stagingUrl, required _PullRequestContext context, }) async { - final commentMarker = ''; - final commentBody = - ''' -$commentMarker -Staged preview of the updated ${site.host} site (updated for commit ${context.commitSha}): - -$stagingUrl'''; + final environment = _previewEnvironmentForSite( + site, + prNumber: context.prNumber, + ); - print('Commenting ${site.host} staging URL on the PR...'); + print( + 'Publishing ${site.host} staging URL to GitHub deployment ' + '$environment...', + ); final gitHub = github.GitHub( auth: github.Authentication.withToken(context.githubToken), + version: _githubApiVersion, ); try { final repository = github.RepositorySlug.full(context.repoFullName); - final existingCommentId = await _findExistingPreviewCommentId( + final previousDeployments = await _findPreviewDeployments( gitHub: gitHub, repository: repository, - issueNumber: context.prNumber, - commentMarker: commentMarker, + environments: _previewEnvironmentsForSite( + site, + prNumber: context.prNumber, + ), + ); + // final deploymentId = await _createPreviewDeployment( + // gitHub: gitHub, + // repository: repository, + // site: site, + // stagingUrl: stagingUrl, + // environment: environment, + // context: context, + // ); + // + // await _createPreviewDeploymentStatus( + // gitHub: gitHub, + // repository: repository, + // deploymentId: deploymentId, + // site: site, + // stagingUrl: stagingUrl, + // environment: environment, + // commitSha: context.commitSha, + // ); + await _deletePreviewDeployments( + gitHub: gitHub, + repository: repository, + deployments: previousDeployments, ); - - if (existingCommentId == null) { - await gitHub.issues.createComment( - repository, - context.prNumber, - commentBody, - ); - } else { - await gitHub.issues.updateComment( - repository, - existingCommentId, - commentBody, - ); - } } on github.GitHubError catch (error) { - stderr.writeln('Error: Failed to comment on the pull request: $error'); + stderr.writeln('Error: Failed to publish the GitHub deployment: $error'); return 1; } finally { gitHub.dispose(); @@ -309,27 +332,163 @@ $stagingUrl'''; return 0; } -/// Returns the id of the first comment on the issue whose body -/// contains the specified [commentMarker]. -/// -/// Returns `null` if no matching comment exists. -Future _findExistingPreviewCommentId({ +/// Returns existing deployments for the PR/site preview [environments]. +Future> _findPreviewDeployments({ required github.GitHub gitHub, required github.RepositorySlug repository, - required int issueNumber, - required String commentMarker, + required List environments, }) async { - await for (final comment in gitHub.issues.listCommentsByIssue( - repository, - issueNumber, - )) { - if (comment.body?.contains(commentMarker) ?? false) { - return comment.id; + final previewDeployments = <_PreviewDeployment>[]; + final seenDeploymentIds = {}; + for (final environment in environments) { + var page = 1; + while (true) { + final deployments = await gitHub.getJSON>( + '/repos/${repository.fullName}/deployments', + params: { + 'environment': environment, + 'task': _previewDeploymentTask, + 'per_page': '100', + 'page': '$page', + }, + convert: (json) => json as List, + ); + if (deployments.isEmpty) { + break; + } + for (final deployment in deployments) { + if (deployment case {'id': final num id}) { + final deploymentId = id.toInt(); + if (seenDeploymentIds.add(deploymentId)) { + previewDeployments.add(( + id: deploymentId, + environment: environment, + )); + } + } + } + page += 1; } } - return null; + return previewDeployments; +} + +/// Creates the GitHub deployment record for the staged preview. +Future _createPreviewDeployment({ + required github.GitHub gitHub, + required github.RepositorySlug repository, + required Site site, + required String stagingUrl, + required String environment, + required _PullRequestContext context, +}) async { + final response = await gitHub.postJSON>( + '/repos/${repository.fullName}/deployments', + statusCode: 201, + body: jsonEncode({ + 'ref': context.commitSha, + 'task': _previewDeploymentTask, + 'auto_merge': false, + 'required_contexts': [], + 'payload': { + 'id': _previewDeploymentPayloadId(site, prNumber: context.prNumber), + 'site': site.name, + 'site_host': site.host, + 'pull_request': context.prNumber, + 'preview_url': stagingUrl, + }, + 'environment': environment, + 'description': 'Staged preview for ${site.host} PR #${context.prNumber}.', + 'transient_environment': true, + 'production_environment': false, + }), + convert: _jsonMap, + ); + if (response case {'id': final num id}) { + return id.toInt(); + } + throw github.GitHubError( + gitHub, + 'Failed to find a deployment id in the GitHub response.', + ); +} + +/// Creates a successful deployment status with [stagingUrl] as the preview URL. +Future _createPreviewDeploymentStatus({ + required github.GitHub gitHub, + required github.RepositorySlug repository, + required int deploymentId, + required Site site, + required String stagingUrl, + required String environment, + required String commitSha, +}) async { + await gitHub.postJSON>( + '/repos/${repository.fullName}/deployments/$deploymentId/statuses', + statusCode: 201, + body: jsonEncode({ + 'state': 'success', + 'log_url': stagingUrl, + 'description': 'Staged ${site.host} preview for ${_shortSha(commitSha)}.', + 'environment': environment, + 'environment_url': stagingUrl, + 'auto_inactive': false, + }), + convert: _jsonMap, + ); +} + +/// Marks older transient preview deployments inactive and deletes them. +Future _deletePreviewDeployments({ + required github.GitHub gitHub, + required github.RepositorySlug repository, + required List<_PreviewDeployment> deployments, +}) async { + for (final deployment in deployments) { + await _createInactiveDeploymentStatus( + gitHub: gitHub, + repository: repository, + deploymentId: deployment.id, + environment: deployment.environment, + ); + await gitHub.request( + 'DELETE', + '/repos/${repository.fullName}/deployments/${deployment.id}', + statusCode: 204, + headers: _githubJsonHeaders, + ); + } +} + +/// Creates an inactive status for a superseded transient preview deployment. +Future _createInactiveDeploymentStatus({ + required github.GitHub gitHub, + required github.RepositorySlug repository, + required int deploymentId, + required String environment, +}) async { + await gitHub.postJSON>( + '/repos/${repository.fullName}/deployments/$deploymentId/statuses', + statusCode: 201, + body: jsonEncode({ + 'state': 'inactive', + 'description': 'Superseded by a newer staged preview.', + 'environment': environment, + 'auto_inactive': false, + }), + convert: _jsonMap, + ); } +Map get _githubJsonHeaders => { + 'Accept': _githubJsonMimeType, + 'X-GitHub-Api-Version': _githubApiVersion, +}; + +/// Converts a decoded GitHub JSON object to a string-keyed map. +Map _jsonMap(Object? json) => + Map.from(json as Map); + /// Builds a Firebase Hosting channel name for [site] that /// incorporates [branchOrSha] and if specified, [prNumber], /// and satisfies Firebase's naming constraints: @@ -353,13 +512,44 @@ String _firebaseChannelForSite( return channel.replaceAll(RegExp(r'-+$'), ''); } +/// Returns the GitHub environment name shown for a PR/site preview. +/// +/// The PR number keeps the deployment unique while giving GitHub's UI a +/// readable environment name. +String _previewEnvironmentForSite(Site site, {required int prNumber}) => + 'Preview: ${site.host} (PR #$prNumber)'; + +/// Returns all environment names to clean up for a PR/site preview. +List _previewEnvironmentsForSite(Site site, {required int prNumber}) { + return [ + _previewEnvironmentForSite(site, prNumber: prNumber), + _legacyPreviewEnvironmentForSite(site, prNumber: prNumber), + ]; +} + +/// Returns the old PR/site preview environment name used by earlier builds. +String _legacyPreviewEnvironmentForSite(Site site, {required int prNumber}) => + 'preview-${site.name}-pr-$prNumber'; + +/// Returns the stable payload id for a PR/site preview deployment. +String _previewDeploymentPayloadId(Site site, {required int prNumber}) => + '${site.name}-pr-$prNumber'; + +/// Returns a shortened commit SHA for status descriptions. +String _shortSha(String sha) { + if (sha.length <= 7) { + return sha; + } + return sha.substring(0, 7); +} + /// Returns [value] if it is non-null and non-empty, otherwise `null`. String? _nonEmpty(String? value) { if (value == null || value.isEmpty) return null; return value; } -/// Everything required to post a preview comment on a GitHub pull request. +/// Everything required to publish a preview deployment for a pull request. /// /// Built during option parsing so that a misconfigured Cloud Build trigger /// fails before the build and deploy runs. @@ -369,3 +559,6 @@ typedef _PullRequestContext = ({ String githubToken, String commitSha, }); + +/// A deployment that should be retired from a previous preview run. +typedef _PreviewDeployment = ({int id, String environment});