From 0c932c83b3fe89a0df7444ba4e0480c017b7e6ea Mon Sep 17 00:00:00 2001 From: Christopher Bartz Date: Mon, 31 Aug 2026 12:53:21 +0000 Subject: [PATCH 01/10] feat(ci): add the weekly candidate-to-stable promotion workflow --- .../promote_candidate_to_stable.yaml | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 .github/workflows/promote_candidate_to_stable.yaml diff --git a/.github/workflows/promote_candidate_to_stable.yaml b/.github/workflows/promote_candidate_to_stable.yaml new file mode 100644 index 00000000..2c618e2e --- /dev/null +++ b/.github/workflows/promote_candidate_to_stable.yaml @@ -0,0 +1,209 @@ +# 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 + +jobs: + check: + name: Check promotion eligibility + runs-on: ubuntu-latest + outputs: + eligible: ${{ steps.check.outputs.eligible }} + garm_revision: ${{ steps.check.outputs.garm_revision }} + garm_configurator_revision: ${{ steps.check.outputs.garm_configurator_revision }} + 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}.") + 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): + released = datetime.datetime.fromisoformat(released_at) + delta = NOW - released.astimezone(datetime.timezone.utc) + return delta.total_seconds() / 86400.0 + + + rows = [] + eligible = 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 candidate is None: + charm_eligible = False + candidate_age = "n/a" + remaining_soak = "n/a" + held_back.append(f"{charm_name}: no candidate release is available to promote.") + else: + candidate_age_value = age_days(candidate["channel"]["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}" + charm_eligible = ( + candidate_age_value >= SOAK_DAYS + and candidate_revision != stable_revision + ) + if not charm_eligible: + if candidate_revision == stable_revision: + held_back.append( + f"{charm_name}: candidate revision {candidate_revision} is already in stable." + ) + else: + 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." + ) + + outputs[f"{charm_name.replace('-', '_')}_revision"] = "" if candidate_revision is None else str(candidate_revision) + rows.append( + { + "charm": charm_name, + "candidate_revision": "n/a" if candidate_revision is None else str(candidate_revision), + "stable_revision": str(stable_revision), + "candidate_age": candidate_age, + "remaining_soak": remaining_soak, + "eligible": "yes" if charm_eligible else "no", + } + ) + eligible = eligible and charm_eligible + + 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("\nBoth charms cleared the soak window and their candidate revisions differ from stable.\n") + + SCRIPT + + release: + name: Release to stable + needs: [check] + 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 both charms atomically + env: + CHARMCRAFT_AUTH: ${{ secrets.CHARMHUB_TOKEN }} + GARM_REVISION: ${{ needs.check.outputs.garm_revision }} + GARM_CONFIGURATOR_REVISION: ${{ needs.check.outputs.garm_configurator_revision }} + run: | + set -euo pipefail + + # The environment is the human approval gate; if it does not exist, GitHub + # treats this as plain metadata and the release job will not pause. + released=0 + + on_error() { + status=$? + { + echo "" + echo "## Release failed" + echo "" + if [ "$released" -eq 1 ]; 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 + + charmcraft release garm --revision="$GARM_REVISION" --channel=latest/stable + echo "- garm: revision ${GARM_REVISION} -> latest/stable" >> "$GITHUB_STEP_SUMMARY" + released=1 + + charmcraft release garm-configurator --revision="$GARM_CONFIGURATOR_REVISION" --channel=latest/stable + echo "- garm-configurator: revision ${GARM_CONFIGURATOR_REVISION} -> latest/stable" >> "$GITHUB_STEP_SUMMARY" From b3c3a6fcd911436d40f36fb69943c073303befde Mon Sep 17 00:00:00 2001 From: Christopher Bartz Date: Mon, 31 Aug 2026 12:55:38 +0000 Subject: [PATCH 02/10] fix(ci): guard the stable promotion against overlapping runs A dispatch raised while the scheduled run is parked at the approval gate could otherwise be approved alongside it and release the same revisions twice. --- .github/workflows/promote_candidate_to_stable.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/promote_candidate_to_stable.yaml b/.github/workflows/promote_candidate_to_stable.yaml index 2c618e2e..c9027fb8 100644 --- a/.github/workflows/promote_candidate_to_stable.yaml +++ b/.github/workflows/promote_candidate_to_stable.yaml @@ -16,6 +16,12 @@ permissions: 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 @@ -201,6 +207,8 @@ jobs: trap on_error ERR + echo "## Released to stable" >> "$GITHUB_STEP_SUMMARY" + charmcraft release garm --revision="$GARM_REVISION" --channel=latest/stable echo "- garm: revision ${GARM_REVISION} -> latest/stable" >> "$GITHUB_STEP_SUMMARY" released=1 From 8ef93c4a7b5b59d8330a737fd562e151ee7dd2eb Mon Sep 17 00:00:00 2001 From: Christopher Bartz Date: Mon, 31 Aug 2026 13:20:23 +0000 Subject: [PATCH 03/10] fix(ci): only announce the stable release after it succeeds The step summary printed the "Released to stable" heading before the first charmcraft release ran, so a failed release produced a summary that claimed a release had happened and then contradicted itself. Emit the heading with the per-charm lines once both releases have succeeded, matching the edge-to-candidate workflow. --- .github/workflows/promote_candidate_to_stable.yaml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/promote_candidate_to_stable.yaml b/.github/workflows/promote_candidate_to_stable.yaml index c9027fb8..32b68f6e 100644 --- a/.github/workflows/promote_candidate_to_stable.yaml +++ b/.github/workflows/promote_candidate_to_stable.yaml @@ -207,11 +207,14 @@ jobs: trap on_error ERR - echo "## Released to stable" >> "$GITHUB_STEP_SUMMARY" - charmcraft release garm --revision="$GARM_REVISION" --channel=latest/stable - echo "- garm: revision ${GARM_REVISION} -> latest/stable" >> "$GITHUB_STEP_SUMMARY" released=1 charmcraft release garm-configurator --revision="$GARM_CONFIGURATOR_REVISION" --channel=latest/stable - echo "- garm-configurator: revision ${GARM_CONFIGURATOR_REVISION} -> latest/stable" >> "$GITHUB_STEP_SUMMARY" + + { + echo "## Released to stable" + echo + echo "- garm: revision ${GARM_REVISION} -> latest/stable" + echo "- garm-configurator: revision ${GARM_CONFIGURATOR_REVISION} -> latest/stable" + } >> "$GITHUB_STEP_SUMMARY" From 9869393be013484d8246286962cfc2fe36c51054 Mon Sep 17 00:00:00 2001 From: Christopher Bartz Date: Fri, 4 Sep 2026 14:30:44 +0000 Subject: [PATCH 04/10] fix(ci): use environment-scoped token and fix stable revision display Use a dedicated CHARMHUB_STABLE_TOKEN secret scoped to the charmhub-stable environment instead of the repo-level CHARMHUB_TOKEN, and fail fast if it is empty, so an unconfigured/missing environment cannot silently bypass the human approval gate. Also show n/a for stable revision in the summary table when no stable release exists yet, instead of the misleading 0. --- .../workflows/promote_candidate_to_stable.yaml | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/.github/workflows/promote_candidate_to_stable.yaml b/.github/workflows/promote_candidate_to_stable.yaml index 32b68f6e..e72841f3 100644 --- a/.github/workflows/promote_candidate_to_stable.yaml +++ b/.github/workflows/promote_candidate_to_stable.yaml @@ -133,7 +133,7 @@ jobs: { "charm": charm_name, "candidate_revision": "n/a" if candidate_revision is None else str(candidate_revision), - "stable_revision": str(stable_revision), + "stable_revision": "n/a" if stable is None else str(stable_revision), "candidate_age": candidate_age, "remaining_soak": remaining_soak, "eligible": "yes" if charm_eligible else "no", @@ -180,14 +180,24 @@ jobs: - name: Release both charms atomically env: - CHARMCRAFT_AUTH: ${{ secrets.CHARMHUB_TOKEN }} + # A dedicated environment-scoped secret, not the repo-level CHARMHUB_TOKEN used + # by the edge publish workflow: that secret exists independently of the + # charmhub-stable environment, so reusing it here would let this job release to + # stable with valid credentials even if the environment was never created and + # the approval gate silently failed to pause the run. + CHARMCRAFT_AUTH: ${{ secrets.CHARMHUB_STABLE_TOKEN }} GARM_REVISION: ${{ needs.check.outputs.garm_revision }} GARM_CONFIGURATOR_REVISION: ${{ needs.check.outputs.garm_configurator_revision }} run: | set -euo pipefail - # The environment is the human approval gate; if it does not exist, GitHub - # treats this as plain metadata and the release job will not pause. + if [ -z "$CHARMCRAFT_AUTH" ]; then + echo "::error::CHARMHUB_STABLE_TOKEN is not set on the charmhub-stable environment." \ + "Refusing to release without it, since an empty secret would otherwise be" \ + "indistinguishable from a missing/misconfigured approval gate." + exit 1 + fi + released=0 on_error() { From 12b8fb469757dec68a07ed91e49b91f88605e0b3 Mon Sep 17 00:00:00 2001 From: Christopher Bartz Date: Fri, 4 Sep 2026 14:39:33 +0000 Subject: [PATCH 05/10] fix(ci): harden Charmhub timestamp parsing in promotion check Normalize a trailing Z (RFC3339, which fromisoformat rejects) before parsing, and fail with a clear ::error:: message instead of a bare traceback if Charmhub ever returns an unparseable or timezone-naive timestamp. --- .github/workflows/promote_candidate_to_stable.yaml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/promote_candidate_to_stable.yaml b/.github/workflows/promote_candidate_to_stable.yaml index e72841f3..16172dc9 100644 --- a/.github/workflows/promote_candidate_to_stable.yaml +++ b/.github/workflows/promote_candidate_to_stable.yaml @@ -85,7 +85,15 @@ jobs: def age_days(released_at): - released = datetime.datetime.fromisoformat(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 From cb9be827fab5a0a2fc413a3ce1e23fc7cc6709e7 Mon Sep 17 00:00:00 2001 From: Christopher Bartz Date: Fri, 4 Sep 2026 14:47:28 +0000 Subject: [PATCH 06/10] fix(ci): guard against malformed Charmhub candidate entries Fail fast with a clear ::error:: if a candidate entry is missing its revision number or release timestamp, instead of letting a None revision slip through eligibility (leading to charmcraft release with an empty --revision) or a raw KeyError traceback. --- .github/workflows/promote_candidate_to_stable.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/promote_candidate_to_stable.yaml b/.github/workflows/promote_candidate_to_stable.yaml index 16172dc9..a0ee4583 100644 --- a/.github/workflows/promote_candidate_to_stable.yaml +++ b/.github/workflows/promote_candidate_to_stable.yaml @@ -117,7 +117,12 @@ jobs: remaining_soak = "n/a" held_back.append(f"{charm_name}: no candidate release is available to promote.") else: - candidate_age_value = age_days(candidate["channel"]["released-at"]) + if candidate_revision is None: + fail(f"Charmhub's candidate entry for {charm_name} is missing a revision number.") + 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}" From 05af1b19cde9a4981c18f908cd32b14464948a6b Mon Sep 17 00:00:00 2001 From: Christopher Bartz Date: Fri, 4 Sep 2026 14:56:17 +0000 Subject: [PATCH 07/10] fix(ci): fail fast on stable entry missing a revision number Mirrors the existing candidate-side guard: a None stable_revision would otherwise compare unequal to any candidate revision and let an ineligible promotion through, plus show None in the summary table. --- .github/workflows/promote_candidate_to_stable.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/promote_candidate_to_stable.yaml b/.github/workflows/promote_candidate_to_stable.yaml index a0ee4583..4160f5e3 100644 --- a/.github/workflows/promote_candidate_to_stable.yaml +++ b/.github/workflows/promote_candidate_to_stable.yaml @@ -110,6 +110,8 @@ jobs: 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.") if candidate is None: charm_eligible = False From 8479179088100fdd4581cb95f7f86430d24074de Mon Sep 17 00:00:00 2001 From: Christopher Bartz Date: Fri, 4 Sep 2026 15:05:01 +0000 Subject: [PATCH 08/10] fix(ci): handle non-JSON Charmhub responses and clarify token format Catch json.JSONDecodeError so a malformed 200 response fails with a clear ::error:: instead of a bare traceback, and note in the missing- CHARMHUB_STABLE_TOKEN error that it must hold exported charmcraft credentials, not a raw API token. --- .github/workflows/promote_candidate_to_stable.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/promote_candidate_to_stable.yaml b/.github/workflows/promote_candidate_to_stable.yaml index 4160f5e3..d88747f9 100644 --- a/.github/workflows/promote_candidate_to_stable.yaml +++ b/.github/workflows/promote_candidate_to_stable.yaml @@ -65,6 +65,8 @@ jobs: 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", []) @@ -209,7 +211,9 @@ jobs: if [ -z "$CHARMCRAFT_AUTH" ]; then echo "::error::CHARMHUB_STABLE_TOKEN is not set on the charmhub-stable environment." \ "Refusing to release without it, since an empty secret would otherwise be" \ - "indistinguishable from a missing/misconfigured approval gate." + "indistinguishable from a missing/misconfigured approval gate. The secret" \ + "must hold exported credentials from 'charmcraft login --export', not a" \ + "raw API token, matching the repo's existing CHARMHUB_TOKEN convention." exit 1 fi From e5405f880eca010a7d9c060536b35f787d4b4bf2 Mon Sep 17 00:00:00 2001 From: Christopher Bartz Date: Mon, 7 Sep 2026 08:00:20 +0000 Subject: [PATCH 09/10] fix(ci): verify charmhub-stable environment has required reviewers Reuse CHARMHUB_TOKEN instead of a dedicated environment secret, and add a verify-environment job that checks via the GitHub API that the charmhub-stable environment exists with a required_reviewers protection rule before the release job runs, failing loudly if the approval gate is missing or misconfigured. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../promote_candidate_to_stable.yaml | 50 ++++++++++++++----- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/.github/workflows/promote_candidate_to_stable.yaml b/.github/workflows/promote_candidate_to_stable.yaml index d88747f9..9fe4ce9a 100644 --- a/.github/workflows/promote_candidate_to_stable.yaml +++ b/.github/workflows/promote_candidate_to_stable.yaml @@ -185,9 +185,42 @@ jobs: 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] + needs: [check, verify-environment] if: needs.check.outputs.eligible == 'true' runs-on: ubuntu-latest environment: charmhub-stable @@ -197,23 +230,16 @@ jobs: - name: Release both charms atomically env: - # A dedicated environment-scoped secret, not the repo-level CHARMHUB_TOKEN used - # by the edge publish workflow: that secret exists independently of the - # charmhub-stable environment, so reusing it here would let this job release to - # stable with valid credentials even if the environment was never created and - # the approval gate silently failed to pause the run. - CHARMCRAFT_AUTH: ${{ secrets.CHARMHUB_STABLE_TOKEN }} + CHARMCRAFT_AUTH: ${{ secrets.CHARMHUB_TOKEN }} GARM_REVISION: ${{ needs.check.outputs.garm_revision }} GARM_CONFIGURATOR_REVISION: ${{ needs.check.outputs.garm_configurator_revision }} run: | set -euo pipefail if [ -z "$CHARMCRAFT_AUTH" ]; then - echo "::error::CHARMHUB_STABLE_TOKEN is not set on the charmhub-stable environment." \ - "Refusing to release without it, since an empty secret would otherwise be" \ - "indistinguishable from a missing/misconfigured approval gate. The secret" \ - "must hold exported credentials from 'charmcraft login --export', not a" \ - "raw API token, matching the repo's existing CHARMHUB_TOKEN convention." + 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 From 6ca59f7d9e2b2960e6d2d011ddb977a98d2f56bc Mon Sep 17 00:00:00 2001 From: Christopher Bartz Date: Mon, 7 Sep 2026 09:50:02 +0000 Subject: [PATCH 10/10] fix(ci): don't let a steady-state charm block its sibling's promotion The eligibility check previously ANDed a single charm_eligible flag across both charms, treating 'nothing pending' (candidate == stable) the same as 'pending but not yet soaked'. Since only one charm is usually bumped in a given cycle, the other sitting in steady state permanently blocked the soaked charm from ever being promoted. Track any_pending/all_pending_soaked instead, and only release charms that individually have a soaked pending candidate (garm_promote/ garm_configurator_promote outputs). Atomicity is preserved when both charms have pending candidates: an unsoaked candidate on either side still blocks both, since garm and garm-configurator are validated together as a pair with no isolated e2e coverage for garm-configurator alone. --- .../promote_candidate_to_stable.yaml | 94 ++++++++++++------- 1 file changed, 61 insertions(+), 33 deletions(-) diff --git a/.github/workflows/promote_candidate_to_stable.yaml b/.github/workflows/promote_candidate_to_stable.yaml index 9fe4ce9a..183c5898 100644 --- a/.github/workflows/promote_candidate_to_stable.yaml +++ b/.github/workflows/promote_candidate_to_stable.yaml @@ -29,7 +29,9 @@ jobs: 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 @@ -101,7 +103,16 @@ jobs: rows = [] - eligible = True + # 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 = [] @@ -115,37 +126,39 @@ jobs: 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: - charm_eligible = False - candidate_age = "n/a" - remaining_soak = "n/a" 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.") - 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}" - charm_eligible = ( - candidate_age_value >= SOAK_DAYS - and candidate_revision != stable_revision - ) - if not charm_eligible: - if candidate_revision == stable_revision: - held_back.append( - f"{charm_name}: candidate revision {candidate_revision} is already in stable." - ) - else: + 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." ) - outputs[f"{charm_name.replace('-', '_')}_revision"] = "" if candidate_revision is None else str(candidate_revision) + 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, @@ -153,10 +166,15 @@ jobs: "stable_revision": "n/a" if stable is None else str(stable_revision), "candidate_age": candidate_age, "remaining_soak": remaining_soak, - "eligible": "yes" if charm_eligible else "no", + "eligible": "yes" if should_promote else "no", } ) - eligible = eligible and charm_eligible + + # 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") @@ -181,7 +199,7 @@ jobs: for item in held_back: summary_file.write(f"- {item}\n") else: - summary_file.write("\nBoth charms cleared the soak window and their candidate revisions differ from stable.\n") + summary_file.write("\nAll pending candidate revisions cleared the soak window.\n") SCRIPT @@ -228,11 +246,13 @@ jobs: - name: Install Charmcraft run: sudo snap install charmcraft --classic - - name: Release both charms atomically + - 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 @@ -243,7 +263,7 @@ jobs: exit 1 fi - released=0 + released_charms=() on_error() { status=$? @@ -251,7 +271,7 @@ jobs: echo "" echo "## Release failed" echo "" - if [ "$released" -eq 1 ]; then + 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." @@ -262,14 +282,22 @@ jobs: trap on_error ERR - charmcraft release garm --revision="$GARM_REVISION" --channel=latest/stable - released=1 + # 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 - charmcraft release garm-configurator --revision="$GARM_CONFIGURATOR_REVISION" --channel=latest/stable + 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 - echo "- garm: revision ${GARM_REVISION} -> latest/stable" - echo "- garm-configurator: revision ${GARM_CONFIGURATOR_REVISION} -> latest/stable" + for line in "${released_charms[@]}"; do + echo "- ${line}" + done } >> "$GITHUB_STEP_SUMMARY"