Derive the rollback target for scheduled release rehearsals

The weekly release-dry-run schedule failed at 'Resolve rehearsal
metadata' because GitHub does not apply workflow_dispatch input
defaults to schedule events, so rollback_version arrived empty and
resolve_release_promotion.py rejected the run.

Scheduled runs now pass --derive-rollback-latest-stable, which fills
an empty rollback_version with the latest stable tag preceding the
rehearsal version (currently v6.0.4 for 6.0.5-rc.3). Manual dispatches
keep the explicit rollback_version requirement; the stale prefilled
5.1.29 default is removed so operators state the target themselves.
The deployment-installability contract records the scoped scheduled
exception.
This commit is contained in:
rcourtman 2026-07-08 13:52:48 +01:00
parent 0d787a4b1f
commit 7645965afe
5 changed files with 156 additions and 7 deletions

View file

@ -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"

View file

@ -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

View file

@ -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)

View file

@ -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,

View file

@ -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",