|
57 | 57 | BUILDKITE_ORGANIZATION = 'automattic' |
58 | 58 | BUILDKITE_PIPELINE = 'wordpress-android' |
59 | 59 |
|
| 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 | + |
60 | 75 | platform :android do |
61 | 76 | # Lists the promotable builds — version codes in both apps' Play libraries above the current |
62 | 77 | # `beta` release — and opens a Buildkite block step for a developer to pick one. |
|
224 | 239 | raise |
225 | 240 | end |
226 | 241 |
|
| 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 | + |
227 | 307 | ################################################# |
228 | 308 | # Candidate discovery |
229 | 309 | ################################################# |
@@ -455,6 +535,172 @@ def promote_version_code_to_production(package_name:, version_code:) |
455 | 535 | end |
456 | 536 | end |
457 | 537 |
|
| 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 | + |
458 | 704 | ################################################# |
459 | 705 | # Promoted-release finalize |
460 | 706 | ################################################# |
|
0 commit comments