diff --git a/.github/workflows/promote_candidate_to_stable.yaml b/.github/workflows/promote_candidate_to_stable.yaml new file mode 100644 index 00000000..183c5898 --- /dev/null +++ b/.github/workflows/promote_candidate_to_stable.yaml @@ -0,0 +1,303 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. +# +# Weekly promotion workflow for moving the GARM charms from candidate to stable. + +name: Promote candidate charms to stable + +on: + schedule: + - cron: "0 6 * * 1" + workflow_dispatch: + +permissions: + contents: read + +env: + SOAK_DAYS: 7 + +# A dispatch while the scheduled run is parked at the approval gate would otherwise +# leave two runs able to release the same revisions. +concurrency: + group: promote-candidate-to-stable + cancel-in-progress: false + +jobs: + check: + name: Check promotion eligibility + runs-on: ubuntu-latest + outputs: + eligible: ${{ steps.check.outputs.eligible }} + garm_revision: ${{ steps.check.outputs.garm_revision }} + garm_promote: ${{ steps.check.outputs.garm_promote }} + garm_configurator_revision: ${{ steps.check.outputs.garm_configurator_revision }} + garm_configurator_promote: ${{ steps.check.outputs.garm_configurator_promote }} + steps: + - name: Check Charmhub candidate soak + id: check + run: | + set -euo pipefail + + python3 - <<'SCRIPT' + import datetime + import json + import os + import sys + import urllib.error + import urllib.request + + + CHARM_NAMES = ("garm", "garm-configurator") + SOAK_DAYS = float(os.environ["SOAK_DAYS"]) + NOW = datetime.datetime.now(datetime.timezone.utc) + + + def fail(message): + print(f"::error::{message}") + sys.exit(1) + + + def fetch_channel_map(charm_name): + url = f"https://api.charmhub.io/v2/charms/info/{charm_name}?fields=channel-map" + request = urllib.request.Request(url) + try: + with urllib.request.urlopen(request, timeout=30) as response: + payload = json.load(response) + except urllib.error.HTTPError as exc: + fail(f"Charmhub request for {charm_name} failed with HTTP {exc.code}.") + except urllib.error.URLError as exc: + fail(f"Charmhub request for {charm_name} failed: {exc.reason}.") + except json.JSONDecodeError as exc: + fail(f"Charmhub response for {charm_name} was not valid JSON: {exc}.") + return payload.get("channel-map", []) + + + def best_entry(channel_map, risk): + entries = [] + for entry in channel_map: + channel = entry.get("channel", {}) + base = channel.get("base", {}) + if ( + channel.get("track") == "latest" + and base.get("architecture") == "amd64" + and channel.get("risk") == risk + ): + entries.append(entry) + if not entries: + return None + return max(entries, key=lambda entry: entry.get("revision", {}).get("revision", 0)) + + + def age_days(released_at): + # fromisoformat() rejects a trailing "Z"; Charmhub returns RFC3339 timestamps, + # which allow it, so normalize before parsing rather than let an unexpected + # format crash with a bare traceback instead of a clear ::error::. + try: + released = datetime.datetime.fromisoformat(released_at.replace("Z", "+00:00")) + except ValueError: + fail(f"Could not parse release timestamp {released_at!r} returned by Charmhub.") + if released.tzinfo is None: + fail(f"Release timestamp {released_at!r} from Charmhub has no timezone.") + delta = NOW - released.astimezone(datetime.timezone.utc) + return delta.total_seconds() / 86400.0 + + + rows = [] + # A charm with nothing pending (no candidate, or candidate already promoted) + # must not block its sibling: most weeks only one charm gets a version bump, + # and the other sitting in steady state isn't a coupling risk. Atomicity only + # applies when both charms have a pending candidate to promote, in which case + # a not-yet-soaked candidate on either side still holds back both, since the + # two are validated together as a pair, not independently (no isolated + # garm-configurator e2e coverage exists to justify releasing a combination + # that was never actually soaked together). + any_pending = False + all_pending_soaked = True + outputs = {} + held_back = [] + + for charm_name in CHARM_NAMES: + channel_map = fetch_channel_map(charm_name) + candidate = best_entry(channel_map, "candidate") + stable = best_entry(channel_map, "stable") + + candidate_revision = candidate.get("revision", {}).get("revision") if candidate else None + stable_revision = stable.get("revision", {}).get("revision") if stable else 0 + if stable is not None and stable_revision is None: + fail(f"Charmhub's stable entry for {charm_name} is missing a revision number.") + + should_promote = False + candidate_age = "n/a" + remaining_soak = "n/a" + + if candidate is None: + held_back.append(f"{charm_name}: no candidate release is available to promote.") + else: + if candidate_revision is None: + fail(f"Charmhub's candidate entry for {charm_name} is missing a revision number.") + if candidate_revision == stable_revision: + held_back.append( + f"{charm_name}: candidate revision {candidate_revision} is already in stable." + ) + else: + any_pending = True + released_at = candidate.get("channel", {}).get("released-at") + if released_at is None: + fail(f"Charmhub's candidate entry for {charm_name} is missing a release timestamp.") + candidate_age_value = age_days(released_at) + candidate_age = f"{candidate_age_value:.1f}" + soak_remaining = max(0.0, SOAK_DAYS - candidate_age_value) + remaining_soak = f"{soak_remaining:.1f}" + should_promote = candidate_age_value >= SOAK_DAYS + if not should_promote: + all_pending_soaked = False + held_back.append( + f"{charm_name}: candidate revision {candidate_revision} is only {candidate_age_value:.1f} days old; " + f"{soak_remaining:.1f} days of soak remain." + ) + + output_key = charm_name.replace("-", "_") + outputs[f"{output_key}_revision"] = str(candidate_revision) if should_promote else "" + outputs[f"{output_key}_promote"] = "true" if should_promote else "false" + rows.append( + { + "charm": charm_name, + "candidate_revision": "n/a" if candidate_revision is None else str(candidate_revision), + "stable_revision": "n/a" if stable is None else str(stable_revision), + "candidate_age": candidate_age, + "remaining_soak": remaining_soak, + "eligible": "yes" if should_promote else "no", + } + ) + + # Only run the release job when there's a pending candidate to promote and + # nothing pending is still holding back: "nothing pending anywhere" (an + # off week) is deliberately not eligible either, since there'd be nothing + # to release. + eligible = any_pending and all_pending_soaked + + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output_file: + output_file.write(f"eligible={'true' if eligible else 'false'}\n") + for key, value in outputs.items(): + output_file.write(f"{key}={value}\n") + + with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as summary_file: + summary_file.write("## Promotion check\n\n") + summary_file.write( + "| Charm | Candidate revision | Current stable revision | Candidate age (days) | Remaining soak (days) | Eligible |\n" + ) + summary_file.write("| --- | --- | --- | --- | --- | --- |\n") + for row in rows: + summary_file.write( + f"| {row['charm']} | {row['candidate_revision']} | {row['stable_revision']} | " + f"{row['candidate_age']} | {row['remaining_soak']} | {row['eligible']} |\n" + ) + summary_file.write("\n") + summary_file.write(f"Promotion eligible: {'true' if eligible else 'false'}\n") + if held_back: + summary_file.write("\n### Held back\n\n") + for item in held_back: + summary_file.write(f"- {item}\n") + else: + summary_file.write("\nAll pending candidate revisions cleared the soak window.\n") + + SCRIPT + + verify-environment: + name: Verify charmhub-stable approval gate + needs: [check] + if: needs.check.outputs.eligible == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + # `environment: charmhub-stable` on the release job only pauses for approval if + # that environment exists with a required-reviewers rule; a missing or + # misconfigured environment lets the job run straight through instead of + # failing, so check for it explicitly rather than relying on the job pausing. + - name: Check environment has required reviewers configured + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + response=$(gh api "repos/${{ github.repository }}/environments/charmhub-stable" 2>&1) || { + echo "::error::The 'charmhub-stable' environment does not exist. Create it in" \ + "repo Settings -> Environments with required reviewers before this" \ + "workflow can release to stable." + exit 1 + } + + has_required_reviewers=$(echo "$response" | jq '[.protection_rules[]? | select(.type == "required_reviewers")] | length > 0') + if [ "$has_required_reviewers" != "true" ]; then + echo "::error::The 'charmhub-stable' environment exists but has no required" \ + "reviewers configured. Add required reviewers before this workflow can" \ + "release to stable." + exit 1 + fi + + release: + name: Release to stable + needs: [check, verify-environment] + if: needs.check.outputs.eligible == 'true' + runs-on: ubuntu-latest + environment: charmhub-stable + steps: + - name: Install Charmcraft + run: sudo snap install charmcraft --classic + + - name: Release eligible charms + env: + CHARMCRAFT_AUTH: ${{ secrets.CHARMHUB_TOKEN }} + GARM_REVISION: ${{ needs.check.outputs.garm_revision }} + GARM_PROMOTE: ${{ needs.check.outputs.garm_promote }} + GARM_CONFIGURATOR_REVISION: ${{ needs.check.outputs.garm_configurator_revision }} + GARM_CONFIGURATOR_PROMOTE: ${{ needs.check.outputs.garm_configurator_promote }} + run: | + set -euo pipefail + + if [ -z "$CHARMCRAFT_AUTH" ]; then + echo "::error::CHARMHUB_TOKEN is not set." \ + "Refusing to release without it. The secret must hold exported" \ + "credentials from 'charmcraft login --export', not a raw API token." + exit 1 + fi + + released_charms=() + + on_error() { + status=$? + { + echo "" + echo "## Release failed" + echo "" + if [ "${#released_charms[@]}" -gt 0 ]; then + echo "The stable channels are now inconsistent and need manual repair." + else + echo "No charms were released, so stable remains consistent." + fi + } >> "$GITHUB_STEP_SUMMARY" + exit "$status" + } + + trap on_error ERR + + # Only charms with a soaked pending candidate are released; a charm with + # nothing pending this cycle is left untouched rather than re-released. + if [ "$GARM_PROMOTE" = "true" ]; then + charmcraft release garm --revision="$GARM_REVISION" --channel=latest/stable + released_charms+=("garm: revision ${GARM_REVISION} -> latest/stable") + fi + + if [ "$GARM_CONFIGURATOR_PROMOTE" = "true" ]; then + charmcraft release garm-configurator --revision="$GARM_CONFIGURATOR_REVISION" --channel=latest/stable + released_charms+=("garm-configurator: revision ${GARM_CONFIGURATOR_REVISION} -> latest/stable") + fi + + { + echo "## Released to stable" + echo + for line in "${released_charms[@]}"; do + echo "- ${line}" + done + } >> "$GITHUB_STEP_SUMMARY"