diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml index 9ee6dbffb..57d49f511 100644 --- a/.github/workflows/release-dry-run.yml +++ b/.github/workflows/release-dry-run.yml @@ -1,11 +1,13 @@ name: Release Dry Run on: - # Weekly drift watchdog: fires every Tuesday 07:00 UTC against pulse/v6-release - # so fixture, manifest, and load-calibration drift surfaces a week at a time - # instead of piling up until an RC publish. Scheduled runs use the input - # defaults below. Update rollback_version once v6 GA ships and a different - # stable line becomes the rollback target. + # Weekly drift watchdog: fires every Tuesday 07:00 UTC against the governed + # release branch so fixture, manifest, and load-calibration drift surfaces a + # week at a time instead of piling up until an RC publish. Scheduled runs + # carry no workflow_dispatch inputs (GitHub does not apply input defaults to + # schedule events), so the rehearsal step derives the rollback target as the + # latest stable tag preceding VERSION. Manual dispatches must still supply + # rollback_version explicitly. schedule: - cron: '0 7 * * 2' workflow_dispatch: @@ -19,10 +21,9 @@ on: required: false type: string rollback_version: - description: 'Required rollback stable version to rehearse (for example 5.1.14 or v5.1.14)' + description: 'Required rollback stable version to rehearse (for example 6.0.4 or v6.0.4)' required: true type: string - default: '5.1.29' ga_date: description: 'Stable v6.0.0 rehearsal only: planned GA publish date (YYYY-MM-DD)' required: false @@ -107,6 +108,7 @@ jobs: - name: Resolve rehearsal metadata id: rehearsal env: + EVENT_NAME: ${{ github.event_name }} VERSION_INPUT: ${{ inputs.version }} PROMOTED_FROM_TAG_INPUT: ${{ inputs.promoted_from_tag }} ROLLBACK_VERSION_INPUT: ${{ inputs.rollback_version }} @@ -154,6 +156,13 @@ jobs: if [ "${HOTFIX_EXCEPTION_INPUT:-false}" = "true" ]; then HELPER_ARGS+=(--hotfix-exception) fi + if [ "${EVENT_NAME}" = "schedule" ] && [ -z "${ROLLBACK_VERSION_INPUT:-}" ]; then + # Scheduled watchdog runs carry no dispatch inputs; derive the + # rollback target instead of failing. Manual dispatches never get + # this flag, so their explicit-rollback requirement stands. + HELPER_ARGS+=(--derive-rollback-latest-stable) + echo "[OK] Scheduled rehearsal: deriving rollback target from the latest preceding stable tag" + fi python3 scripts/release_control/resolve_release_promotion.py \ "${HELPER_ARGS[@]}" > "$RUNNER_TEMP/rehearsal-metadata.out" diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 6a4ee61b0..d12e1fe2d 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -1057,6 +1057,15 @@ for stable-versus-prerelease metadata validation shared by `.github/workflows/re and `.github/workflows/create-release.yml`. Promotion rollback targets, promoted prerelease lineage, soak checks, and GA/v5 notice metadata may not drift between those two workflows through duplicated inline shell validation. +One scoped exception keeps the weekly drift watchdog viable: scheduled +`release-dry-run.yml` runs carry no `workflow_dispatch` inputs (GitHub does +not apply input defaults to `schedule` events), so the rehearsal step passes +`--derive-rollback-latest-stable` and the resolver fills the empty +`rollback_version` with the latest stable repository tag preceding the +rehearsal version. The derivation flag is gated on the `schedule` event in +the workflow; manual rehearsal dispatches and real promotions must still +supply an explicit stable `rollback_version`, and the resolver still fails +closed when the input is empty and the flag is absent. `scripts/release_control/validate_artifact_release_line.py` is the canonical owner for follow-on artifact workflow release-line validation shared by Docker publication, floating-tag promotion, Helm chart publication, and Helm Pages diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index c5d21d651..0dfb829a5 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -689,6 +689,16 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("Required rollback stable version to rehearse", dry_run_workflow) self.assertIn("rollback_version:\n description: 'Required rollback stable version to rehearse", dry_run_workflow) self.assertIn("required: true", dry_run_workflow) + # Scheduled watchdog runs carry no dispatch inputs, so the rehearsal + # step must derive the rollback target; the derive flag stays gated on + # the schedule event so manual dispatches keep explicit rollback. + self.assertIn( + 'if [ "${EVENT_NAME}" = "schedule" ] && [ -z "${ROLLBACK_VERSION_INPUT:-}" ]; then', + dry_run_workflow, + ) + self.assertIn("--derive-rollback-latest-stable", dry_run_workflow) + self.assertIn("--derive-rollback-latest-stable", resolver) + self.assertIn("derive_latest_stable_rollback_tag", resolver) self.assertIn("Required: prior stable version to pin for rollback", content) self.assertIn("rollback_version:\n description: 'Required: prior stable version to pin for rollback", content) self.assertIn("check-workflow-dispatch-inputs.py", helper) diff --git a/scripts/release_control/resolve_release_promotion.py b/scripts/release_control/resolve_release_promotion.py index 5811bfb41..407ac195c 100644 --- a/scripts/release_control/resolve_release_promotion.py +++ b/scripts/release_control/resolve_release_promotion.py @@ -14,6 +14,7 @@ from repo_file_io import REPO_ROOT, git_env SEMVER_STABLE_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") +SEMVER_STABLE_TAG_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$") SEMVER_PRERELEASE_RE = re.compile(r"-(?:[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)(?:\+[0-9A-Za-z.-]+)?$") @@ -86,6 +87,38 @@ def normalize_whitespace(value: str) -> str: return " ".join((value or "").split()) +def list_stable_tags() -> list[str]: + result = subprocess.run( + ["git", "tag", "--list", "v*"], + cwd=REPO_ROOT, + env=git_env(), + check=True, + capture_output=True, + text=True, + ) + return [tag for tag in result.stdout.split() if SEMVER_STABLE_TAG_RE.match(tag)] + + +def derive_latest_stable_rollback_tag(version: str, stable_tags: list[str]) -> str: + base_match = re.match(r"^(\d+)\.(\d+)\.(\d+)", (version or "").strip()) + if not base_match: + raise ValueError(f"Cannot derive a rollback target from unparseable version {version!r}.") + base = tuple(int(part) for part in base_match.groups()) + candidates: list[tuple[tuple[int, int, int], str]] = [] + for tag in stable_tags: + match = SEMVER_STABLE_TAG_RE.match(tag) + if not match: + continue + numbers = tuple(int(part) for part in match.groups()) + if numbers < base: + candidates.append((numbers, tag)) + if not candidates: + raise ValueError( + f"Cannot derive a rollback target for {version}: no stable release tag precedes it." + ) + return max(candidates)[1] + + def resolve_metadata( *, version: str, @@ -96,6 +129,8 @@ def resolve_metadata( hotfix_exception: bool, hotfix_reason_input: str, release_notes_input: str, + derive_rollback_when_missing: bool = False, + list_stable_tags_fn: Callable[[], list[str]] = list_stable_tags, tag_exists_fn: Callable[[str], bool] = tag_exists, tag_commit_fn: Callable[[str], str] = tag_commit, head_descends_from_fn: Callable[[str], bool] = head_descends_from, @@ -110,6 +145,8 @@ def resolve_metadata( release_notes = release_notes_input or "" is_prerelease = is_prerelease_version(version) + if not rollback_tag and derive_rollback_when_missing: + rollback_tag = derive_latest_stable_rollback_tag(version, list_stable_tags_fn()) if not rollback_tag: raise ValueError( "rollback_version is required for every release rehearsal and promotion so rollback can be executed explicitly." @@ -206,6 +243,14 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--version", required=True) parser.add_argument("--promoted-from-tag", default="") parser.add_argument("--rollback-version", default="") + parser.add_argument( + "--derive-rollback-latest-stable", + action="store_true", + help=( + "Scheduled rehearsals only: when --rollback-version is empty, derive the rollback " + "target as the latest stable tag preceding the rehearsal version instead of failing." + ), + ) parser.add_argument("--ga-date", default="") parser.add_argument("--v5-eos-date", default="") parser.add_argument("--hotfix-exception", action="store_true") @@ -224,6 +269,7 @@ def main() -> int: version=args.version, promoted_from_tag_input=args.promoted_from_tag, rollback_version_input=args.rollback_version, + derive_rollback_when_missing=args.derive_rollback_latest_stable, ga_date_input=args.ga_date, v5_eos_date_input=args.v5_eos_date, hotfix_exception=args.hotfix_exception, diff --git a/scripts/release_control/resolve_release_promotion_test.py b/scripts/release_control/resolve_release_promotion_test.py index c01ef6484..50731e621 100644 --- a/scripts/release_control/resolve_release_promotion_test.py +++ b/scripts/release_control/resolve_release_promotion_test.py @@ -45,6 +45,81 @@ class ResolveReleasePromotionTest(unittest.TestCase): self.assertEqual(metadata["promoted_from_tag"], "") self.assertEqual(metadata["soak_hours"], "") + def test_missing_rollback_is_rejected_without_derivation(self) -> None: + with self.assertRaisesRegex( + ValueError, + "rollback_version is required for every release rehearsal and promotion", + ): + resolver.resolve_metadata( + version="6.0.5-rc.3", + promoted_from_tag_input="", + rollback_version_input="", + ga_date_input="", + v5_eos_date_input="", + hotfix_exception=False, + hotfix_reason_input="", + release_notes_input="", + tag_exists_fn=lambda tag: True, + ) + + def test_scheduled_rehearsal_derives_latest_preceding_stable_rollback(self) -> None: + metadata = resolver.resolve_metadata( + version="6.0.5-rc.3", + promoted_from_tag_input="", + rollback_version_input="", + ga_date_input="", + v5_eos_date_input="", + hotfix_exception=False, + hotfix_reason_input="", + release_notes_input="", + derive_rollback_when_missing=True, + list_stable_tags_fn=lambda: ["v5.1.35", "v6.0.1", "v6.0.4", "v6.0.2"], + tag_exists_fn=lambda tag: True, + ) + self.assertEqual(metadata["rollback_tag"], "v6.0.4") + self.assertEqual(metadata["rollback_command"], "./scripts/install.sh --version v6.0.4") + + def test_explicit_rollback_input_wins_over_derivation(self) -> None: + metadata = resolver.resolve_metadata( + version="6.0.5-rc.3", + promoted_from_tag_input="", + rollback_version_input="6.0.3", + ga_date_input="", + v5_eos_date_input="", + hotfix_exception=False, + hotfix_reason_input="", + release_notes_input="", + derive_rollback_when_missing=True, + list_stable_tags_fn=lambda: ["v6.0.4"], + tag_exists_fn=lambda tag: tag == "v6.0.3", + ) + self.assertEqual(metadata["rollback_tag"], "v6.0.3") + + def test_derivation_never_selects_the_rehearsal_version_or_prereleases(self) -> None: + self.assertEqual( + resolver.derive_latest_stable_rollback_tag( + "6.0.5", + ["v6.0.5", "v6.0.5-rc.3", "v6.0.4", "v5.1.35"], + ), + "v6.0.4", + ) + + def test_derivation_requires_a_preceding_stable_tag(self) -> None: + with self.assertRaisesRegex(ValueError, "no stable release tag precedes"): + resolver.resolve_metadata( + version="6.0.5-rc.3", + promoted_from_tag_input="", + rollback_version_input="", + ga_date_input="", + v5_eos_date_input="", + hotfix_exception=False, + hotfix_reason_input="", + release_notes_input="", + derive_rollback_when_missing=True, + list_stable_tags_fn=lambda: [], + tag_exists_fn=lambda tag: True, + ) + def test_stable_requires_matching_promoted_rc_and_soak(self) -> None: metadata = resolver.resolve_metadata( version="6.0.0",