mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
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:
parent
0d787a4b1f
commit
7645965afe
5 changed files with 156 additions and 7 deletions
23
.github/workflows/release-dry-run.yml
vendored
23
.github/workflows/release-dry-run.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue