Skip to content

Commit 0f35eb9

Browse files
committed
Add production staged-rollout growth lane and schedule
Add the `advance_production_rollout` fastlane lane, which reads the current production rollout back from Play and advances WordPress and Jetpack one step: for an `inProgress` release it raises `user_fraction` to the next value in `PRODUCTION_ROLLOUT_STEPS` ([1, 2, 5, 10, 20, 50]%), and finalizes to `completed` past the top step. It acts only on an `inProgress` release, leaving `draft`, `halted`, and `completed` releases untouched, and errors when the two apps' rollout states differ. Add `.buildkite/commands/advance-production-rollout.sh` and `.buildkite/schedules/advance-production-rollout.yml` to run the lane.
1 parent 9fb294d commit 0f35eb9

3 files changed

Lines changed: 285 additions & 0 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
#!/usr/bin/env bash
2+
3+
set -eu
4+
5+
# Advances the live production staged rollout one step — reads the current rollout percentage back
6+
# from Play and bumps WordPress + Jetpack to the next step, finalizing to 100% past the top. The lane
7+
# discovers everything from Play, so there are no arguments. No build — just gems + secrets.
8+
9+
echo "--- :rubygems: Setting up Gems"
10+
install_gems
11+
12+
echo "--- :closed_lock_with_key: Installing Secrets"
13+
bundle exec fastlane run configure_apply
14+
15+
echo "--- :chart_with_upwards_trend: Advancing the production rollout"
16+
bundle exec fastlane advance_production_rollout
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# yaml-language-server: $schema=https://raw.githubusercontent.com/buildkite/pipeline-schema/main/schema.json
2+
---
3+
4+
# Advances the live production staged rollout one step per run — reads the current rollout percentage
5+
# back from Play and bumps WordPress & Jetpack to the next step, finalizing to 100% past the top. Only
6+
# ever advances an `inProgress` rollout, so it never resumes one a developer paused (a paused rollout
7+
# is `halted`). Runs on a daily schedule (configured in the Buildkite UI); also triggerable manually
8+
# via the `PIPELINE` env var for testing.
9+
10+
agents:
11+
queue: "android"
12+
13+
steps:
14+
- label: ":chart_with_upwards_trend: Advance production rollout"
15+
command: ".buildkite/commands/advance-production-rollout.sh"
16+
plugins: [$CI_TOOLKIT]
17+
18+
# Slack backstop for any failed build — its unique value is failures that abort before the lane runs
19+
# (gems/secrets) and can't self-report; on a lane failure it may also accompany the lane's own
20+
# message. Channel intentionally stays the test channel for now (see send_slack_message).
21+
notify:
22+
- slack: "#test-wpmobile-slack-integration"
23+
if: build.state == "failed"

fastlane/lanes/promote.rb

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,21 @@
5757
BUILDKITE_ORGANIZATION = 'automattic'
5858
BUILDKITE_PIPELINE = 'wordpress-android'
5959

60+
# Production staged-rollout growth (scheduled bump job).
61+
# The rollout ladder the scheduled job walks — it advances the live production rollout to the next
62+
# step each run. These are a starting point (Play accepts any fraction); adjust freely. "Next step" is
63+
# the smallest value strictly above the current fraction, so an off-ladder manual % still moves
64+
# forward. Past the top step the release is finalized to 100% (status `completed`).
65+
PRODUCTION_ROLLOUT_STEPS = [0.01, 0.02, 0.05, 0.10, 0.20, 0.50].freeze
66+
67+
# Play `TrackRelease.status` values. A paused rollout — the Play Console "pause", or a Play auto-halt
68+
# (newer draft upload, policy/vitals) — is `halted`; there is no separate "paused" status. The growth
69+
# job only ever advances an `inProgress` release, so a scheduled run can never resume a paused one.
70+
ROLLOUT_STATUS_IN_PROGRESS = 'inProgress'
71+
ROLLOUT_STATUS_HALTED = 'halted'
72+
ROLLOUT_STATUS_DRAFT = 'draft'
73+
ROLLOUT_STATUS_COMPLETED = 'completed'
74+
6075
platform :android do
6176
# Lists the promotable builds — version codes in both apps' Play libraries above the current
6277
# `beta` release — and opens a Buildkite block step for a developer to pick one.
@@ -224,6 +239,71 @@
224239
raise
225240
end
226241

242+
# Advances the live production staged rollout one step — reads the current rollout percentage back
243+
# from Play and bumps WordPress + Jetpack to the next ladder step, finalizing to 100% once past the
244+
# top. Runs on a daily schedule; stateless (one step per run). Pause-safe: it only ever advances an
245+
# `inProgress` release, so it never resumes a rollout a developer paused (a paused rollout is
246+
# `halted`). WordPress and Jetpack must be in the same rollout state — a mismatch stops for a
247+
# developer to reconcile rather than guessing.
248+
#
249+
# @called_by CI (`.buildkite/commands/advance-production-rollout.sh`)
250+
desc 'Advance the production staged rollout one step (WordPress + Jetpack)'
251+
lane :advance_production_rollout do
252+
# Set once the per-app result has been posted, so the rescue doesn't double-report it.
253+
result_posted = false
254+
ensure_promotion_on_trunk!
255+
# Fail loudly up front if Slack isn't configured (see gather_beta_candidates).
256+
get_required_env('SLACK_WEBHOOK')
257+
258+
states = %i[wordpress jetpack].to_h do |app|
259+
[app, current_production_rollout(package_name: APP_SPECIFIC_VALUES[app][:package_name])]
260+
end
261+
UI.message("Production rollout states: #{states.inspect}")
262+
263+
# Both apps promote together, so they must be in the same rollout state. A mismatch means a prior
264+
# step only half-applied, or one app's rollout was paused/changed — stop and reconcile by hand.
265+
unless rollout_signature(states[:wordpress]) == rollout_signature(states[:jetpack])
266+
UI.user_error!(
267+
'WordPress and Jetpack production rollouts differ ' \
268+
"(WP=#{states[:wordpress].inspect}, JP=#{states[:jetpack].inspect}); reconcile by hand."
269+
)
270+
end
271+
272+
# Both apps share this state now; act on either.
273+
state = states[:wordpress]
274+
275+
if state.nil?
276+
UI.important('No production rollout in flight (the track is at steady state); nothing to advance.')
277+
next
278+
end
279+
280+
unless state[:status] == ROLLOUT_STATUS_IN_PROGRESS
281+
# `draft` = not started yet; `halted` = paused (by a developer or auto-halted). Either way, leave
282+
# it — advancing a non-`inProgress` release is the footgun we refuse (it would resume a pause).
283+
UI.important("Production rollout is `#{state[:status]}` (not in progress); leaving it untouched.")
284+
next
285+
end
286+
287+
target = next_production_rollout_target(current_fraction: state[:user_fraction].to_f)
288+
UI.important(
289+
"Advancing production rollout #{state[:version_code]} from #{state[:user_fraction]} " \
290+
"to #{rollout_target_label(target)}"
291+
)
292+
293+
results = distribute_rollout_advance(target: target)
294+
295+
post_rollout_result_to_slack(version_code: state[:version_code], target: target, results: results)
296+
result_posted = true
297+
298+
failed = results.reject { |_, result| result[:ok] }.keys
299+
UI.user_error!("Production rollout advance failed for: #{failed.join(', ')}") unless failed.empty?
300+
301+
UI.success("Advanced production rollout #{state[:version_code]} to #{rollout_target_label(target)}")
302+
rescue StandardError => e
303+
notify_slack(":x: *Production rollout* failed — #{e.message}") unless result_posted
304+
raise
305+
end
306+
227307
#################################################
228308
# Candidate discovery
229309
#################################################
@@ -455,6 +535,172 @@ def promote_version_code_to_production(package_name:, version_code:)
455535
end
456536
end
457537

538+
#################################################
539+
# Production rollout growth
540+
#################################################
541+
542+
# Reads the in-flight production release for one app — the release still rolling out (`inProgress`,
543+
# `halted`, or `draft`), or nil when the track is in steady state (only a `completed` release, or
544+
# none). Opens a throwaway Play edit and aborts it, so this only reads. Returns
545+
# `{ status:, user_fraction:, version_code: }`.
546+
def current_production_rollout(package_name:)
547+
require 'supply'
548+
require 'supply/options'
549+
550+
Supply.config = FastlaneCore::Configuration.create(
551+
Supply::Options.available_options,
552+
{ json_key: UPLOAD_TO_PLAY_STORE_JSON_KEY, package_name: package_name, track: PRODUCTION_TRACK }
553+
)
554+
555+
release = with_play_edit_retries("Reading the production rollout for #{package_name}") do
556+
client = Supply::Client.make_from_config
557+
client.begin_edit(package_name: package_name)
558+
begin
559+
in_flight_release(client.tracks(PRODUCTION_TRACK).first)
560+
ensure
561+
# Always discard the throwaway edit, even if the read raises.
562+
client.abort_current_edit
563+
end
564+
end
565+
return nil if release.nil?
566+
567+
{
568+
status: release.status,
569+
user_fraction: release.user_fraction,
570+
# The real code is the largest; `.max` ignores the pinned legacy code kept on the release.
571+
version_code: Array(release.version_codes).map(&:to_i).max
572+
}
573+
rescue StandardError => e
574+
# Raise rather than return nil: an errored read must not read as "nothing rolling out".
575+
UI.user_error!("Unable to read the production rollout for #{package_name}: #{e.message}")
576+
end
577+
578+
# The release still rolling out on a track — preferring `inProgress`, then a paused `halted`, then a
579+
# `draft`. nil when the track has only a `completed` release (steady state) or no releases.
580+
def in_flight_release(track)
581+
releases = track&.releases || []
582+
[ROLLOUT_STATUS_IN_PROGRESS, ROLLOUT_STATUS_HALTED, ROLLOUT_STATUS_DRAFT]
583+
.filter_map { |status| releases.find { |release| release.status == status } }
584+
.first
585+
end
586+
587+
# A comparable signature of an app's rollout state, so WordPress and Jetpack can be checked for
588+
# lockstep. nil (no in-flight release) collapses to `:steady`; the fraction is rounded so a float
589+
# round-tripping through JSON can't spuriously fail the comparison.
590+
def rollout_signature(state)
591+
return :steady if state.nil?
592+
593+
[state[:status], state[:version_code], state[:user_fraction]&.round(4)]
594+
end
595+
596+
# The next rollout target from the current fraction: the smallest ladder step strictly above it as an
597+
# `inProgress` bump, or a finalize to 100% (`completed`, no `user_fraction`) once past the top step.
598+
def next_production_rollout_target(current_fraction:)
599+
next_step = PRODUCTION_ROLLOUT_STEPS.find { |step| step > current_fraction }
600+
return { status: ROLLOUT_STATUS_IN_PROGRESS, user_fraction: next_step } if next_step
601+
602+
{ status: ROLLOUT_STATUS_COMPLETED, user_fraction: nil }
603+
end
604+
605+
# A readable percentage label for a rollout target, for logs and Slack (e.g. `5%`, `100% (complete)`).
606+
def rollout_target_label(target)
607+
return '100% (complete)' if target[:status] == ROLLOUT_STATUS_COMPLETED
608+
609+
percent = (target[:user_fraction] * 100).round(2)
610+
percent = percent.to_i if percent == percent.to_i
611+
"#{percent}%"
612+
end
613+
614+
# Applies `target` to each app's in-flight production release, returning a per-app `{ ok:, error: }`
615+
# result. A failure for one app doesn't stop the other (mirrors distribute_to_production).
616+
def distribute_rollout_advance(target:)
617+
%i[wordpress jetpack].to_h do |app|
618+
result =
619+
begin
620+
set_production_rollout(package_name: APP_SPECIFIC_VALUES[app][:package_name], target: target)
621+
{ ok: true }
622+
rescue StandardError => e
623+
UI.error("Failed to advance #{app} rollout: #{e.message}")
624+
{ ok: false, error: e.message }
625+
end
626+
[app, result]
627+
end
628+
end
629+
630+
# Applies `target` to the app's in-flight production release via the Play API. Re-reads the track
631+
# inside the edit and mutates the `inProgress` release: a bump only raises `user_fraction`, leaving
632+
# the rest of the track (the previous version still serving the rollout remainder) intact; a finalize
633+
# replaces the releases with the single `completed` one, which supersedes the previous version. Bails
634+
# without committing if the rollout is no longer `inProgress` (e.g. paused between read and write) —
635+
# never resurrecting a paused rollout.
636+
def set_production_rollout(package_name:, target:)
637+
require 'supply'
638+
require 'supply/options'
639+
640+
Supply.config = FastlaneCore::Configuration.create(
641+
Supply::Options.available_options,
642+
{ json_key: UPLOAD_TO_PLAY_STORE_JSON_KEY, package_name: package_name, track: PRODUCTION_TRACK }
643+
)
644+
645+
with_play_edit_retries("Advancing #{package_name} production rollout") do
646+
client = Supply::Client.make_from_config
647+
client.begin_edit(package_name: package_name)
648+
649+
committed = false
650+
begin
651+
track = client.tracks(PRODUCTION_TRACK).first
652+
release = (track&.releases || []).find { |candidate| candidate.status == ROLLOUT_STATUS_IN_PROGRESS }
653+
UI.user_error!("#{package_name} has no in-progress production rollout to advance.") if release.nil?
654+
655+
if target[:status] == ROLLOUT_STATUS_COMPLETED
656+
# Finalize to 100%: a single `completed` release supersedes the previous version.
657+
release.status = ROLLOUT_STATUS_COMPLETED
658+
release.user_fraction = nil
659+
track.releases = [release]
660+
else
661+
# Bump: only raise the fraction; leave the rest of the track (the remainder) intact.
662+
release.user_fraction = target[:user_fraction]
663+
end
664+
665+
client.update_track(PRODUCTION_TRACK, track)
666+
client.commit_current_edit!
667+
committed = true
668+
ensure
669+
# Discard the edit if we bailed before committing (a committed edit can't be aborted).
670+
client.abort_current_edit unless committed
671+
end
672+
end
673+
end
674+
675+
# Posts the per-app outcome of a rollout advance.
676+
def post_rollout_result_to_slack(version_code:, target:, results:)
677+
label = rollout_target_label(target)
678+
status_lines = results.map do |app, result|
679+
next "• #{app}: :x: #{result[:error]}" unless result[:ok]
680+
681+
"• #{app}: :white_check_mark: rollout at #{label}"
682+
end
683+
684+
all_ok = results.values.all? { |result| result[:ok] }
685+
completed = target[:status] == ROLLOUT_STATUS_COMPLETED
686+
header =
687+
if all_ok && completed
688+
':checkered_flag: *Production rollout complete — now at 100%*'
689+
elsif all_ok
690+
":chart_with_upwards_trend: *Production rollout advanced to #{label}*"
691+
else
692+
':warning: *Production rollout advance finished with errors*'
693+
end
694+
695+
notify_slack(
696+
<<~MSG
697+
#{header} — `#{version_code}`
698+
699+
#{status_lines.join("\n")}
700+
MSG
701+
)
702+
end
703+
458704
#################################################
459705
# Promoted-release finalize
460706
#################################################

0 commit comments

Comments
 (0)