3737PRODUCTION_BLOCK_LABEL = ':android: Promote to production'
3838PRODUCTION_BLOCK_STEP_KEY = 'promote_to_production_block'
3939
40+ # Promoted-release finalize (the git ceremony after the Play submit).
41+ # A continuous build's versionCode packs the Buildkite build number in its low 6 digits (via
42+ # release-toolkit's ContinuousBuildCodeFormatter, build_digits: 6):
43+ # versionCode = (major * 10 + minor) * 1_000_000 + BUILDKITE_BUILD_NUMBER
44+ # so `versionCode % MODULO` recovers the build number and `versionCode / MODULO` the `major*10+minor`.
45+ # TODO: could move next to ContinuousBuildCodeFormatter in release-toolkit later; fine here for now.
46+ VERSION_CODE_BUILD_MODULO = 1_000_000
47+
4048# Written verbatim into the generated steps so `buildkite-agent pipeline upload` interpolates it.
4149CI_TOOLKIT_PLUGIN_REF = '$CI_TOOLKIT'
4250
177185 raise
178186 end
179187
188+ # Finalizes a promoted release after the Play submit: creates a draft GitHub release tagged at the
189+ # commit the build came from, and opens the trunk version-name bump PR. Runs as the mac-metal step
190+ # the promote-to-production pipeline appends after `promote_to_production` (git writes need the bot
191+ # identity), and is safe to re-run by hand with the same version code.
192+ #
193+ # @param [String] version_code The version code that was released, e.g. `270084231`.
194+ # @called_by CI (`.buildkite/commands/finalize-promoted-release.sh`)
195+ desc 'Finalize a promoted release: draft GitHub release + trunk version-name bump PR'
196+ lane :finalize_promoted_release do |version_code : nil |
197+ ensure_promotion_on_trunk!
198+ # Fail loudly up front if the environment isn't configured. SLACK_WEBHOOK backs only the failure
199+ # notification (the rescue below — there's no success post). BUILDKITE_TOKEN is required, not a
200+ # degradable deep-link nicety: the released commit is resolved from the Buildkite build, and the
201+ # GitHub release can't be tagged without it.
202+ get_required_env ( 'SLACK_WEBHOOK' )
203+ get_required_env ( 'GITHUB_TOKEN' )
204+ get_required_env ( 'BUILDKITE_TOKEN' )
205+
206+ version_code = version_code . to_s . strip
207+ UI . user_error! ( '`version_code` is required, e.g. `version_code:270084231`' ) if version_code . empty?
208+ UI . user_error! ( "`version_code` must be an integer, got #{ version_code . inspect } " ) unless version_code . match? ( /\A \d +\z / )
209+ # Base 10 so a value with a leading zero (e.g. `0270084231`) isn't parsed as octal.
210+ version_code = Integer ( version_code , 10 )
211+
212+ released_version = marketing_version_for ( version_code : version_code )
213+ UI . important ( "Finalizing promoted release #{ released_version } (build #{ version_code } )" )
214+
215+ release_url = create_draft_github_release ( version_code : version_code , version_name : released_version )
216+ bump_url = open_version_bump_pull_request ( released_version : released_version )
217+
218+ # No success post to Slack on purpose: the Play-submit step already announced the release, the
219+ # draft GitHub release is published later, and the version-bump PR pings its reviewers itself.
220+ # Only failures notify (the rescue below) — anything else would just be noise.
221+ UI . success ( "Finalized #{ released_version } : release #{ release_url } ; version bump #{ bump_url || 'skipped' } " )
222+ rescue StandardError => e
223+ notify_slack ( ":x: *Promoted-release finalize* failed — #{ e . message } " )
224+ raise
225+ end
226+
180227 #################################################
181228 # Candidate discovery
182229 #################################################
@@ -408,6 +455,136 @@ def promote_version_code_to_production(package_name:, version_code:)
408455 end
409456 end
410457
458+ #################################################
459+ # Promoted-release finalize
460+ #################################################
461+
462+ # The marketing version (e.g. `26.9`) encoded in a continuous build's versionCode — `major.minor`,
463+ # read straight from the packed `major*10 + minor` prefix. Hotfix patch numbers aren't encoded, but
464+ # hotfixes don't flow through this trunk-only path, so `major.minor` is exact here.
465+ def marketing_version_for ( version_code :)
466+ prefix = version_code / VERSION_CODE_BUILD_MODULO
467+ "#{ prefix / 10 } .#{ prefix % 10 } "
468+ end
469+
470+ # Creates a DRAFT GitHub release for the released marketing version, tagged at the commit the build
471+ # came from. It stays a draft for now — a developer publishes it from the GitHub UI until the whole
472+ # flow is switched to publishing outright (mirrors the draft Play release). GitHub creates the tag
473+ # from `target` when the draft is published. The Play-signed universal APKs are attached
474+ # best-effort. Reuses an existing release for the same commit rather than creating a second draft on
475+ # a re-run. Returns the release URL.
476+ def create_draft_github_release ( version_code :, version_name :)
477+ build_number = version_code % VERSION_CODE_BUILD_MODULO
478+ sha = commit_sha_for_build_number ( build_number : build_number )
479+
480+ existing_url = existing_github_release_url ( commit_sha : sha )
481+ if existing_url
482+ UI . important ( "A GitHub release already targets #{ sha } (#{ existing_url } ); skipping creation." )
483+ return existing_url
484+ end
485+
486+ create_github_release (
487+ repository : GITHUB_REPO ,
488+ version : version_name ,
489+ target : sha ,
490+ release_assets : signed_universal_apks ( version_code : version_code , version_name : version_name ) ,
491+ prerelease : false ,
492+ is_draft : true
493+ )
494+ end
495+
496+ # The URL of an existing GitHub release targeting the given commit, or nil — so a re-run reuses the
497+ # release for this exact build instead of creating a second draft. Matches on the commit (the build's
498+ # identity) rather than the marketing version, which many builds share. `client.releases` includes
499+ # drafts (whose `target_commitish` is the commit we set).
500+ def existing_github_release_url ( commit_sha :)
501+ github = Fastlane ::Helper ::GithubHelper . new ( github_token : get_required_env ( 'GITHUB_TOKEN' ) )
502+ release = github . client . releases ( GITHUB_REPO ) . find { |candidate | candidate . target_commitish == commit_sha }
503+ release &.html_url
504+ end
505+
506+ # Resolves the trunk commit a continuous build ran against, via the Buildkite build whose number is
507+ # packed into the versionCode. Raises rather than returns nil — the GitHub release needs a target.
508+ def commit_sha_for_build_number ( build_number :)
509+ org = ENV . fetch ( 'BUILDKITE_ORGANIZATION_SLUG' , BUILDKITE_ORGANIZATION )
510+ pipeline = ENV . fetch ( 'BUILDKITE_PIPELINE_SLUG' , BUILDKITE_PIPELINE )
511+ uri = URI ( "https://api.buildkite.com/v2/organizations/#{ org } /pipelines/#{ pipeline } /builds/#{ build_number } " )
512+
513+ response = buildkite_api_get ( uri )
514+ UI . user_error! ( "Unable to look up Buildkite build ##{ build_number } (HTTP #{ response . code } )." ) unless response . is_a? ( Net ::HTTPSuccess )
515+
516+ build = JSON . parse ( response . body )
517+ sha = build [ 'commit' ]
518+ UI . user_error! ( "Buildkite build ##{ build_number } has no commit SHA." ) if sha . nil? || sha . to_s . empty?
519+
520+ UI . message ( "Build ##{ build_number } → #{ sha } (branch #{ build [ 'branch' ] . inspect } )" )
521+ sha
522+ end
523+
524+ # Downloads the Play-signed universal APK for each app at the given version code, best-effort. The
525+ # original AAB was built in a past CI job and is long gone, but Play still serves a generated
526+ # universal APK by version code. A download failure just yields fewer/no assets rather than aborting
527+ # the release. Returns the paths that downloaded.
528+ def signed_universal_apks ( version_code :, version_name :)
529+ %i[ wordpress jetpack ] . filter_map do |app |
530+ destination = signed_apk_path ( app . to_s , version_name )
531+ download_universal_apk_from_google_play (
532+ package_name : APP_SPECIFIC_VALUES [ app ] [ :package_name ] ,
533+ version_code : version_code ,
534+ destination : destination ,
535+ json_key : UPLOAD_TO_PLAY_STORE_JSON_KEY
536+ )
537+ File . exist? ( destination ) ? destination : nil
538+ rescue StandardError => e
539+ UI . important ( "Could not download the #{ app } universal APK for #{ version_code } : #{ e . message } ; the release will omit it." )
540+ nil
541+ end
542+ end
543+
544+ # Opens (or reuses) a PR bumping trunk's marketing version to the next line after the released one —
545+ # e.g. after releasing `26.9`, trunk moves to `27.0` so later builds carry the new line. No-ops when
546+ # trunk is already at or past that line (a re-run, or a release of an older line). Returns the PR
547+ # URL, or nil when skipped.
548+ def open_version_bump_pull_request ( released_version :)
549+ next_version = VERSION_FORMATTER . release_version (
550+ VERSION_CALCULATOR . next_release_version ( version : VERSION_FORMATTER . parse ( released_version ) )
551+ )
552+
553+ UI . user_error! ( "Could not check out and pull #{ DEFAULT_BRANCH } ." ) unless Fastlane ::Helper ::GitHelper . checkout_and_pull ( DEFAULT_BRANCH )
554+
555+ if version_at_or_past? ( current_version_name , next_version )
556+ UI . important ( "Trunk is already at #{ current_version_name } ; skipping the bump to #{ next_version } ." )
557+ return nil
558+ end
559+
560+ branch = "release/version-bump-#{ next_version } "
561+ Fastlane ::Helper ::GitHelper . delete_local_branch_if_exists! ( branch )
562+ Fastlane ::Helper ::GitHelper . create_branch ( branch , from : DEFAULT_BRANCH )
563+
564+ # Only the marketing name advances; the (legacy, unused-on-trunk) version code is written back as-is.
565+ VERSION_FILE . write_version ( version_name : next_version , version_code : current_build_code )
566+ Fastlane ::Helper ::GitHelper . commit ( message : "Bump version name to #{ next_version } " , files : VERSION_PROPERTIES_PATH )
567+
568+ push_to_git_remote ( remote_branch : branch , tags : false , force : true , set_upstream : true )
569+
570+ find_or_create_pull_request (
571+ repository : GITHUB_REPO ,
572+ title : "Bump version name to #{ next_version } " ,
573+ body : "Advances the marketing version on `#{ DEFAULT_BRANCH } ` to `#{ next_version } `, following the `#{ released_version } ` production release." ,
574+ head : branch ,
575+ base : DEFAULT_BRANCH ,
576+ labels : [ 'Releases' ]
577+ )
578+ end
579+
580+ # Whether `current_name` is at or past `target_name`, compared on `major.minor` (Array#<=> is
581+ # element-wise; Array has no `>=`, so compare the spaceship result).
582+ def version_at_or_past? ( current_name , target_name )
583+ current = VERSION_FORMATTER . parse ( current_name )
584+ target = VERSION_FORMATTER . parse ( target_name )
585+ ( [ current . major , current . minor ] <=> [ target . major , target . minor ] ) >= 0
586+ end
587+
411588 #################################################
412589 # Buildkite block step
413590 #################################################
@@ -448,37 +625,17 @@ def write_beta_promotion_steps_file(candidates:)
448625 UI . message ( "Wrote promotion steps for #{ candidates . count } build(s) to #{ PROMOTION_STEPS_FILE } " )
449626 end
450627
451- # Writes the confirmation block step + the release step . There's no picker — the candidate is baked
452- # into the release command ; the block step only gates on a Yes/No confirmation.
628+ # Writes the confirm → promote → finalize steps . There's no picker — the candidate is baked into the
629+ # commands ; the block step only gates on a Yes/No confirmation.
453630 def write_production_release_steps_file ( version_code :)
454631 steps = {
455632 'steps' => [
456- {
457- 'block' => PRODUCTION_BLOCK_LABEL ,
458- 'key' => PRODUCTION_BLOCK_STEP_KEY ,
459- 'prompt' => "Release build #{ version_code } to production? This releases the matching WordPress and Jetpack builds." ,
460- # Keep the build "running" (not green) while it waits for a human.
461- 'blocked_state' => 'running' ,
462- 'fields' => [
463- {
464- 'select' => 'Release to production?' ,
465- 'key' => PRODUCTION_CONFIRM_META_DATA_KEY ,
466- # Required, no default: an un-actioned unblock can't silently release a build.
467- 'required' => true ,
468- 'options' => [
469- { 'label' => 'Yes' , 'value' => 'yes' } ,
470- { 'label' => 'No' , 'value' => 'no' }
471- ]
472- }
473- ]
474- } ,
475- {
476- 'label' => ':rocket: Release build to production' ,
477- # The candidate rides along as an argument; the confirmation Yes/No comes from meta-data.
478- 'command' => ".buildkite/commands/promote-to-production.sh #{ version_code } " ,
479- 'plugins' => [ CI_TOOLKIT_PLUGIN_REF ] ,
480- 'agents' => { 'queue' => 'android' }
481- }
633+ production_confirm_block_step ( version_code : version_code ) ,
634+ promote_to_production_step ( version_code : version_code ) ,
635+ # Barrier: finalize only after the promote step succeeds — a failed promote halts the build
636+ # here. On a "no" confirmation the promote step no-ops and finalize's own guard no-ops too.
637+ 'wait' ,
638+ finalize_promoted_release_step ( version_code : version_code )
482639 ]
483640 }
484641
@@ -488,6 +645,51 @@ def write_production_release_steps_file(version_code:)
488645 UI . message ( "Wrote production release steps for build #{ version_code } to #{ PROMOTION_STEPS_FILE } " )
489646 end
490647
648+ # The Yes/No confirmation block step that gates the production release.
649+ def production_confirm_block_step ( version_code :)
650+ {
651+ 'block' => PRODUCTION_BLOCK_LABEL ,
652+ 'key' => PRODUCTION_BLOCK_STEP_KEY ,
653+ 'prompt' => "Release build #{ version_code } to production? This releases the matching WordPress and Jetpack builds." ,
654+ # Keep the build "running" (not green) while it waits for a human.
655+ 'blocked_state' => 'running' ,
656+ 'fields' => [
657+ {
658+ 'select' => 'Release to production?' ,
659+ 'key' => PRODUCTION_CONFIRM_META_DATA_KEY ,
660+ # Required, no default: an un-actioned unblock can't silently release a build.
661+ 'required' => true ,
662+ 'options' => [
663+ { 'label' => 'Yes' , 'value' => 'yes' } ,
664+ { 'label' => 'No' , 'value' => 'no' }
665+ ]
666+ }
667+ ]
668+ }
669+ end
670+
671+ # The command step that promotes the confirmed build to production (WordPress + Jetpack).
672+ def promote_to_production_step ( version_code :)
673+ {
674+ 'label' => ':rocket: Release build to production' ,
675+ # The candidate rides along as an argument; the confirmation Yes/No comes from meta-data.
676+ 'command' => ".buildkite/commands/promote-to-production.sh #{ version_code } " ,
677+ 'plugins' => [ CI_TOOLKIT_PLUGIN_REF ] ,
678+ 'agents' => { 'queue' => 'android' }
679+ }
680+ end
681+
682+ # The command step that finalizes the release (draft GitHub release + trunk version-bump PR). Runs
683+ # on mac-metal for the git push identity.
684+ def finalize_promoted_release_step ( version_code :)
685+ {
686+ 'label' => ':android: Finalize promoted release' ,
687+ 'command' => ".buildkite/commands/finalize-promoted-release.sh #{ version_code } " ,
688+ 'plugins' => [ CI_TOOLKIT_PLUGIN_REF ] ,
689+ 'agents' => { 'queue' => 'mac-metal' }
690+ }
691+ end
692+
491693 # Source `shared-pipeline-vars` first so `$CI_TOOLKIT` is interpolated.
492694 def upload_promotion_steps
493695 sh ( 'bash' , '-c' , "cd '#{ PROJECT_ROOT_FOLDER } ' && source .buildkite/shared-pipeline-vars && buildkite-agent pipeline upload '#{ PROMOTION_STEPS_RELATIVE_PATH } '" )
0 commit comments