-
Notifications
You must be signed in to change notification settings - Fork 2
feat(ci): add the weekly candidate-to-stable promotion workflow #344
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+303
−0
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
0c932c8
feat(ci): add the weekly candidate-to-stable promotion workflow
cbartz b3c3a6f
fix(ci): guard the stable promotion against overlapping runs
cbartz 8ef93c4
fix(ci): only announce the stable release after it succeeds
cbartz 9869393
fix(ci): use environment-scoped token and fix stable revision display
cbartz 12b8fb4
fix(ci): harden Charmhub timestamp parsing in promotion check
cbartz cb9be82
fix(ci): guard against malformed Charmhub candidate entries
cbartz 05af1b1
fix(ci): fail fast on stable entry missing a revision number
cbartz 8479179
fix(ci): handle non-JSON Charmhub responses and clarify token format
cbartz e5405f8
fix(ci): verify charmhub-stable environment has required reviewers
cbartz 5f449c7
Merge branch 'main' into feat/promote-candidate-to-stable
cbartz 6ca59f7
fix(ci): don't let a steady-state charm block its sibling's promotion
cbartz 2d6b91c
Merge branch 'main' into feat/promote-candidate-to-stable
cbartz File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.