Skip to content

Commit ee60eb5

Browse files
authored
Add promoted-release finalize to the production pipeline (#23119)
* Add promoted-release finalize step to the production pipeline After the Play submit, resolve the released build's commit from the versionCode's Buildkite build number, create a draft GitHub release tagged at that commit (signed universal APKs attached best-effort), and open a PR bumping trunk's marketing version to the next line. Runs on mac-metal as a step appended after promote_to_production; only failures notify Slack. * Extract production pipeline steps into per-step helpers Splits the block, promote, and finalize steps out of write_production_release_steps_file so it reads as a clean list and stays under rubocop's MethodLength after the finalize step was added. * Parse version_code as base 10 in finalize_promoted_release Avoids Integer() treating a leading-zero value as octal. * Skip creating a GitHub release when one already exists for the commit finalize_promoted_release can be re-run for the same build; reuse the existing release (matched by target_commitish) instead of creating a second draft.
1 parent 9e9541e commit ee60eb5

2 files changed

Lines changed: 267 additions & 28 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
#!/usr/bin/env bash
2+
3+
set -eu
4+
5+
# Finalizes a promoted release once the developer has confirmed the block step and the preceding
6+
# promote step submitted the build to Play: creates a draft GitHub release tagged at the commit the
7+
# build came from, and opens the trunk version-name bump PR. The version code is passed as the first
8+
# argument; the Yes/No confirmation is read from Buildkite meta-data. Runs on `mac-metal` because it
9+
# writes git (tag + PR) and needs the bot identity from `use-bot-for-git`.
10+
11+
VERSION_CODE="${1:-}"
12+
13+
# `production_ready_to_release` must stay in sync with PRODUCTION_CONFIRM_META_DATA_KEY in
14+
# fastlane/lanes/promote.rb, which is the key the block-step confirmation field writes.
15+
READY="$(buildkite-agent meta-data get "production_ready_to_release" --default "no")"
16+
17+
if [[ "${READY}" != "yes" ]]; then
18+
echo "--- :no_entry_sign: Release not confirmed (production_ready_to_release=${READY}); skipping finalize."
19+
exit 0
20+
fi
21+
22+
if [[ -z "${VERSION_CODE}" ]]; then
23+
echo "+++ :x: No version code was provided to finalize."
24+
exit 1
25+
fi
26+
27+
echo "--- :robot_face: Use bot for git operations"
28+
source use-bot-for-git
29+
30+
echo "--- :rubygems: Setting up Gems"
31+
install_gems
32+
33+
echo "--- :closed_lock_with_key: Installing Secrets"
34+
bundle exec fastlane run configure_apply
35+
36+
echo "--- :checkered_flag: Finalizing promoted release for ${VERSION_CODE}"
37+
bundle exec fastlane finalize_promoted_release version_code:"${VERSION_CODE}"

fastlane/lanes/promote.rb

Lines changed: 230 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@
3737
PRODUCTION_BLOCK_LABEL = ':android: Promote to production'
3838
PRODUCTION_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.
4149
CI_TOOLKIT_PLUGIN_REF = '$CI_TOOLKIT'
4250

@@ -177,6 +185,45 @@
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

Comments
 (0)