Integrate reviewed exact-source release watchdog

Preserve the fixed no-publication envelope, component verdicts and reviewed Delivery commit identities.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-01 00:28:33 +01:00
5 changed files with 228 additions and 26 deletions
+84 -11
View File
@@ -1,5 +1,5 @@
name: Release Dry Run
run-name: Release Dry Run v${{ inputs.version || 'scheduled' }}
run-name: ${{ inputs.watchdog && format('Release Watchdog at {0}', inputs.expected_workflow_sha) || format('Release Dry Run v{0}', inputs.version || 'scheduled') }}
on:
# Weekly drift watchdog: starts on main every Tuesday 07:00 UTC and selects the governed
@@ -7,12 +7,21 @@ on:
# 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.
# latest stable tag preceding VERSION. The fixed on-demand watchdog uses the
# same envelope; ordinary manual rehearsals still supply rollback explicitly.
schedule:
- cron: '0 7 * * 2'
workflow_dispatch:
inputs:
watchdog:
description: 'Run the scheduled no-publication watchdog now, from exact reviewed main'
required: false
type: boolean
default: false
expected_workflow_sha:
description: 'Required exact main workflow SHA for watchdog dispatch; forbidden for candidate rehearsals'
required: false
type: string
version:
description: 'Optional version under rehearsal (e.g. 6.0.0-rc.2 or 6.0.0)'
required: false
@@ -23,7 +32,9 @@ on:
type: string
rollback_version:
description: 'Required rollback stable version to rehearse (for example 6.0.4 or v6.0.4)'
required: true
# Candidate rehearsals still enforce this before checkout. Only the
# fixed watchdog envelope derives the preceding stable rollback.
required: false
type: string
ga_date:
description: 'Stable v6.0.0 rehearsal only: planned GA publish date (YYYY-MM-DD)'
@@ -70,7 +81,7 @@ permissions:
jobs:
build_release_candidate:
name: Build Immutable Release Candidate
if: ${{ inputs.version != '' }}
if: ${{ inputs.watchdog != true && inputs.version != '' }}
permissions:
actions: write
attestations: write
@@ -115,11 +126,67 @@ jobs:
steps:
- name: Validate release ref
id: mode
env:
EVENT_NAME: ${{ github.event_name }}
WATCHDOG_INPUT: ${{ inputs.watchdog }}
EXPECTED_WORKFLOW_SHA_INPUT: ${{ inputs.expected_workflow_sha }}
VERSION_INPUT: ${{ inputs.version }}
PROMOTED_FROM_TAG_INPUT: ${{ inputs.promoted_from_tag }}
ROLLBACK_VERSION_INPUT: ${{ inputs.rollback_version }}
GA_DATE_INPUT: ${{ inputs.ga_date }}
V5_EOS_DATE_INPUT: ${{ inputs.v5_eos_date }}
HOTFIX_EXCEPTION_INPUT: ${{ inputs.hotfix_exception }}
HOTFIX_REASON_INPUT: ${{ inputs.hotfix_reason }}
UNSIGNED_WINDOWS_EXCEPTION_INPUT: ${{ inputs.unsigned_windows_exception }}
UNSIGNED_WINDOWS_REASON_INPUT: ${{ inputs.unsigned_windows_reason }}
NOTE_INPUT: ${{ inputs.note }}
MOBILE_RELEASE_DECISION: ${{ inputs.mobile_release_decision }}
MOBILE_RELEASE_EVIDENCE: ${{ inputs.mobile_release_evidence }}
run: |
set -euo pipefail
if [[ "${GITHUB_REF}" != refs/heads/* ]]; then
echo "::error::Release dry run must be executed from a branch ref. Current ref: ${GITHUB_REF}"
exit 1
fi
WATCHDOG=false
case "${EVENT_NAME}:${WATCHDOG_INPUT:-false}" in
schedule:false) WATCHDOG=true ;;
workflow_dispatch:true)
if [ "${GITHUB_REF}" != "refs/heads/main" ] || \
[[ ! "${EXPECTED_WORKFLOW_SHA_INPUT:-}" =~ ^[0-9a-f]{40}$ ]] || \
[ "${EXPECTED_WORKFLOW_SHA_INPUT}" != "${GITHUB_SHA}" ]; then
echo "::error::Watchdog dispatch requires the exact reviewed main workflow SHA."
exit 1
fi
WATCHDOG=true
;;
workflow_dispatch:false)
if [ -n "${EXPECTED_WORKFLOW_SHA_INPUT:-}" ] || [ -z "${ROLLBACK_VERSION_INPUT:-}" ]; then
echo "::error::Candidate rehearsal requires explicit rollback and no watchdog SHA."
exit 1
fi
;;
*) echo "::error::Unsupported rehearsal event or watchdog mode."; exit 1 ;;
esac
if [ "${WATCHDOG}" = "true" ]; then
for name in VERSION_INPUT PROMOTED_FROM_TAG_INPUT ROLLBACK_VERSION_INPUT \
GA_DATE_INPUT V5_EOS_DATE_INPUT HOTFIX_REASON_INPUT \
UNSIGNED_WINDOWS_REASON_INPUT NOTE_INPUT \
MOBILE_RELEASE_DECISION MOBILE_RELEASE_EVIDENCE; do
if [ -n "${!name:-}" ]; then
echo "::error::Watchdog refuses candidate input ${name}."
exit 1
fi
done
for name in HOTFIX_EXCEPTION_INPUT UNSIGNED_WINDOWS_EXCEPTION_INPUT; do
if [ "${!name:-false}" != "false" ]; then
echo "::error::Watchdog refuses exception input ${name}."
exit 1
fi
done
fi
echo "watchdog=${WATCHDOG}" >> "$GITHUB_OUTPUT"
echo "[OK] Dry run executing on branch ${GITHUB_REF_NAME}"
- name: Checkout repository
@@ -145,11 +212,16 @@ jobs:
id: source
env:
EVENT_NAME: ${{ github.event_name }}
WATCHDOG_MODE: ${{ steps.mode.outputs.watchdog }}
REQUIRED_BRANCH: ${{ steps.branch_policy.outputs.required_branch }}
run: |
set -euo pipefail
TESTED_BRANCH="${GITHUB_REF_NAME}"
if [ "${EVENT_NAME}" = "schedule" ]; then
if [ "${WATCHDOG_MODE}" = "true" ]; then
if [ "${EVENT_NAME}" != "schedule" ] && [ "${EVENT_NAME}" != "workflow_dispatch" ]; then
echo "::error::Unsupported watchdog event."
exit 1
fi
# Resolve once; never test a moving branch or rewrite GITHUB_SHA.
git check-ref-format "refs/heads/${REQUIRED_BRANCH}"
git fetch --no-tags origin "refs/heads/${REQUIRED_BRANCH}"
@@ -183,6 +255,7 @@ jobs:
id: rehearsal
env:
EVENT_NAME: ${{ github.event_name }}
WATCHDOG_MODE: ${{ steps.mode.outputs.watchdog }}
VERSION_INPUT: ${{ inputs.version }}
PROMOTED_FROM_TAG_INPUT: ${{ inputs.promoted_from_tag }}
ROLLBACK_VERSION_INPUT: ${{ inputs.rollback_version }}
@@ -242,10 +315,9 @@ jobs:
elif [ -n "${UNSIGNED_WINDOWS_REASON_INPUT:-}" ]; then
HELPER_ARGS+=(--unsigned-windows-reason "${UNSIGNED_WINDOWS_REASON_INPUT}")
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.
if [ "${WATCHDOG_MODE}" = "true" ] && [ -z "${ROLLBACK_VERSION_INPUT:-}" ]; then
# Both admitted watchdog paths have no candidate inputs. Ordinary
# candidate rehearsals retain their explicit-rollback requirement.
HELPER_ARGS+=(--derive-rollback-latest-stable)
echo "[OK] Scheduled rehearsal: deriving rollback target from the latest preceding stable tag"
fi
@@ -266,6 +338,7 @@ jobs:
id: mobile_release
env:
EVENT_NAME: ${{ github.event_name }}
WATCHDOG_MODE: ${{ steps.mode.outputs.watchdog }}
MOBILE_RELEASE_DECISION: ${{ inputs.mobile_release_decision }}
MOBILE_RELEASE_EVIDENCE: ${{ inputs.mobile_release_evidence }}
WORKFLOW_OUTPUT_1: ${{ steps.rehearsal.outputs.version }}
@@ -273,7 +346,7 @@ jobs:
set -euo pipefail
DECISION="${MOBILE_RELEASE_DECISION:-}"
EVIDENCE="${MOBILE_RELEASE_EVIDENCE:-}"
if [ -z "${DECISION}" ] && [ "${EVENT_NAME}" = "schedule" ]; then
if [ -z "${DECISION}" ] && [ "${WATCHDOG_MODE}" = "true" ]; then
DECISION="no-mobile-impact"
EVIDENCE="Scheduled release dry-run watchdog; no mobile release packet is being dispatched."
fi
@@ -131,11 +131,22 @@ product checks use the selected checkout; a missing branch or policy mismatch
fails closed rather than falling back to main.
Manual dispatch retains its governed event branch and exact event SHA, VERSION
equality and explicit rollback requirement. Only scheduled runs may derive the
preceding stable rollback target. Summaries distinguish workflow event revision
equality and explicit rollback requirement. The one additional dispatch mode
is a fixed no-publication watchdog: `watchdog=true` plus the exact reviewed
main `expected_workflow_sha`, with no candidate, exception, mobile or note
inputs. It checks the main ref and event SHA before checkout, then uses the
same one-fetch governed-source selection and preceding stable rollback
derivation as the schedule. A moving control head fails closed. Ordinary
candidate dispatches cannot use the watchdog SHA or omit explicit rollback.
Summaries distinguish workflow event revision
from tested branch and revision; unresolved selection is not test evidence.
This watchdog is not an admitted release candidate and cannot qualify or
replace the fixed release packet.
replace the fixed release packet. Its distinct `Release Watchdog at <sha>`
title cannot satisfy an exact-version candidate rehearsal. The candidate-build
job is always skipped in watchdog mode; actual preflight, demo resolver,
no-mutation verification and definitive verdict must all succeed. Delivery's
reviewed dispatcher fixes repository, workflow, main and both inputs, retains
intent before POST and never replays a refused or uncertain watchdog attempt.
Verification: `rehearsal_source_test.py` executes the source-selection shell
against local Git fixtures. `release_promotion_policy_test.py` pins workflow
@@ -3869,13 +3880,14 @@ 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
One scoped exception keeps the 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
rehearsal version. The derivation flag is gated on admitted watchdog mode
(schedule or the exact-main, candidate-input-free dispatch envelope described
above); ordinary candidate 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
@@ -2415,6 +2415,35 @@ func TestUpdateDemoResolverChecksOutOutputHelperBeforeUse(t *testing.T) {
}
}
func TestReleaseWatchdogIsControlBoundAndCannotBuildCandidate(t *testing.T) {
workflow, err := os.ReadFile(repoFile(".github", "workflows", "release-dry-run.yml"))
if err != nil {
t.Fatal(err)
}
text := string(workflow)
for _, required := range []string{
"Release Watchdog at {0}",
"if: ${{ inputs.watchdog != true && inputs.version != '' }}",
`[ "${EXPECTED_WORKFLOW_SHA_INPUT}" != "${GITHUB_SHA}" ]`,
`[ "${GITHUB_REF}" != "refs/heads/main" ]`,
`Candidate rehearsal requires explicit rollback and no watchdog SHA.`,
`Watchdog refuses candidate input ${name}.`,
`Watchdog refuses exception input ${name}.`,
"WATCHDOG_MODE: ${{ steps.mode.outputs.watchdog }}",
"verify_only: true",
`require_result "stable demo no-mutation verification" "$DEMO_RESULT" success`,
} {
if !strings.Contains(text, required) {
t.Fatalf("fixed release watchdog missing safety contract: %s", required)
}
}
guard := strings.Index(text, "- name: Validate release ref")
checkout := strings.Index(text, "- name: Checkout repository")
if guard < 0 || checkout <= guard {
t.Fatal("watchdog control identity and envelope must be checked before checkout")
}
}
func TestDemoMutationAndRecoverySharePhysicalTargetLock(t *testing.T) {
updateBytes, err := os.ReadFile(repoFile(".github", "workflows", "update-demo-server.yml"))
if err != nil {
@@ -64,9 +64,10 @@ class RehearsalSourceTest(unittest.TestCase):
self.git("add", ".")
self.git("commit", "-m", "fixture")
def select(self, event="schedule", branch="main", sha=None, required="release/v6.4"):
def select(self, event="schedule", branch="main", sha=None, required="release/v6.4", watchdog=None):
env = dict(self.env, EVENT_NAME=event, GITHUB_REF_NAME=branch,
GITHUB_SHA=sha or self.main, REQUIRED_BRANCH=required,
WATCHDOG_MODE=watchdog or ("true" if event == "schedule" else "false"),
GITHUB_OUTPUT=str(self.output))
return subprocess.run(
["bash", "-euo", "pipefail", "-c", step("Select exact rehearsal source")],
@@ -83,6 +84,69 @@ class RehearsalSourceTest(unittest.TestCase):
self.assertIn(self.main, result.stdout)
self.assertNotEqual(self.main, self.release)
def mode(self, **overrides):
env = dict(self.env, EVENT_NAME="workflow_dispatch", WATCHDOG_INPUT="true",
EXPECTED_WORKFLOW_SHA_INPUT=self.main, GITHUB_SHA=self.main,
GITHUB_REF="refs/heads/main", GITHUB_REF_NAME="main",
GITHUB_OUTPUT=str(self.output))
env.update(overrides)
return subprocess.run(["bash", "-euo", "pipefail", "-c", step("Validate release ref")],
cwd=self.repo, env=env, text=True, capture_output=True)
def test_fixed_watchdog_dispatch_selects_same_source_as_schedule(self):
admitted = self.mode()
self.assertEqual(admitted.returncode, 0, admitted.stderr)
self.assertEqual(self.output.read_text(), "watchdog=true\n")
result = self.select("workflow_dispatch", watchdog="true")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(self.git("rev-parse", "HEAD"), self.release)
self.assertIn(self.main, result.stdout)
self.assertIn("tested_sha=" + self.release, self.output.read_text())
def test_watchdog_rejects_moved_control_wrong_ref_and_unknown_event_before_selection(self):
for overrides in (
{"EXPECTED_WORKFLOW_SHA_INPUT": "a" * 40},
{"EXPECTED_WORKFLOW_SHA_INPUT": "not-a-sha"},
{"EXPECTED_WORKFLOW_SHA_INPUT": ""},
{"GITHUB_REF": "refs/heads/release/v6.4"},
{"GITHUB_REF": "refs/tags/v6.4.5"},
{"EVENT_NAME": "push"}, {"WATCHDOG_INPUT": "yes"},
):
with self.subTest(overrides=overrides):
result = self.mode(**overrides)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertFalse(self.output.exists())
self.assertEqual(self.git("rev-parse", "HEAD"), self.main)
def test_watchdog_rejects_every_candidate_and_exception_input(self):
for name in (
"VERSION_INPUT", "PROMOTED_FROM_TAG_INPUT", "ROLLBACK_VERSION_INPUT",
"GA_DATE_INPUT", "V5_EOS_DATE_INPUT", "HOTFIX_REASON_INPUT",
"UNSIGNED_WINDOWS_REASON_INPUT", "NOTE_INPUT", "MOBILE_RELEASE_DECISION",
"MOBILE_RELEASE_EVIDENCE", "HOTFIX_EXCEPTION_INPUT", "UNSIGNED_WINDOWS_EXCEPTION_INPUT",
):
with self.subTest(name=name):
self.assertNotEqual(self.mode(**{name: "true"}).returncode, 0)
self.assertFalse(self.output.exists())
self.assertEqual(self.mode(HOTFIX_EXCEPTION_INPUT="false",
UNSIGNED_WINDOWS_EXCEPTION_INPUT="false").returncode, 0)
def test_schedule_and_manual_candidate_envelopes_remain_separate(self):
result = self.mode(EVENT_NAME="schedule", WATCHDOG_INPUT="", EXPECTED_WORKFLOW_SHA_INPUT="")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(self.output.read_text(), "watchdog=true\n")
self.output.unlink()
for overrides in ({}, {"ROLLBACK_VERSION_INPUT": "v6.4.1", "EXPECTED_WORKFLOW_SHA_INPUT": self.main}):
with self.subTest(overrides=overrides):
env = {"WATCHDOG_INPUT": "false", "EXPECTED_WORKFLOW_SHA_INPUT": ""}
env.update(overrides)
self.assertNotEqual(self.mode(**env).returncode, 0)
self.assertFalse(self.output.exists())
result = self.mode(WATCHDOG_INPUT="false", EXPECTED_WORKFLOW_SHA_INPUT="",
ROLLBACK_VERSION_INPUT="v6.4.1")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(self.output.read_text(), "watchdog=false\n")
def test_manual_wrong_branch_rejected_without_switching(self):
result = self.select("workflow_dispatch")
self.assertNotEqual(result.returncode, 0)
@@ -104,6 +168,17 @@ class RehearsalSourceTest(unittest.TestCase):
self.assertEqual(self.git("rev-parse", "HEAD"), self.main)
self.assertFalse(self.output.exists())
def test_explicit_watchdog_missing_branch_and_policy_drift_fail_closed(self):
self.assertNotEqual(self.select("workflow_dispatch", watchdog="true",
required="release/missing").returncode, 0)
self.assertFalse(self.output.exists())
self.git("checkout", "release/v6.4")
self.version("6.6.0-beta.1")
self.git("checkout", "main")
self.assertNotEqual(self.select("workflow_dispatch", watchdog="true").returncode, 0)
self.assertEqual(self.git("rev-parse", "HEAD"), self.main)
self.assertFalse(self.output.exists())
def test_selected_version_must_still_belong_to_governed_branch(self):
self.git("checkout", "release/v6.4")
self.version("6.6.0-beta.1")
@@ -119,7 +194,7 @@ class RehearsalSourceTest(unittest.TestCase):
metadata = step("Resolve rehearsal metadata")
self.assertIn('if [ "${TESTED_BRANCH}" != "$REQUIRED_BRANCH" ]; then', metadata)
self.assertIn('if [ "$FILE_VERSION" != "$VERSION" ]; then', metadata)
self.assertIn('if [ "${EVENT_NAME}" = "schedule" ] && [ -z "${ROLLBACK_VERSION_INPUT:-}" ]; then', metadata)
self.assertIn('if [ "${WATCHDOG_MODE}" = "true" ] && [ -z "${ROLLBACK_VERSION_INPUT:-}" ]; then', metadata)
self.assertIn('--derive-rollback-latest-stable', metadata)
workflow = WORKFLOW.read_text()
self.assertIn('TESTED_SHA: ${{ needs.dry-run.outputs.tested_sha }}', workflow)
@@ -408,6 +408,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
dry_run = jobs["dry-run"]
steps = dry_run["steps"]
names = [step["name"] for step in steps]
self.assertLess(names.index("Validate release ref"), names.index("Checkout repository"))
self.assertLess(names.index("Resolve required release branch"),
names.index("Select exact rehearsal source"))
self.assertLess(names.index("Select exact rehearsal source"),
@@ -415,6 +416,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
source = next(step for step in steps if step.get("id") == "source")
self.assertEqual(source["env"]["REQUIRED_BRANCH"],
"${{ steps.branch_policy.outputs.required_branch }}")
self.assertEqual(source["env"]["WATCHDOG_MODE"], "${{ steps.mode.outputs.watchdog }}")
self.assertIn('git checkout --detach "${TESTED_SHA}"', source["run"])
self.assertIn('"${TESTED_SHA}" != "${GITHUB_SHA}"', source["run"])
metadata = next(step for step in steps if step.get("id") == "rehearsal")
@@ -424,9 +426,13 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
metadata["run"])
self.assertIn('if [ "$FILE_VERSION" != "$VERSION" ]; then', metadata["run"])
self.assertIn(
'if [ "${EVENT_NAME}" = "schedule" ] && [ -z "${ROLLBACK_VERSION_INPUT:-}" ]; then',
'if [ "${WATCHDOG_MODE}" = "true" ] && [ -z "${ROLLBACK_VERSION_INPUT:-}" ]; then',
metadata["run"],
)
self.assertEqual(metadata["env"]["WATCHDOG_MODE"], "${{ steps.mode.outputs.watchdog }}")
self.assertEqual(jobs["build_release_candidate"]["if"],
"${{ inputs.watchdog != true && inputs.version != '' }}")
self.assertIn("Release Watchdog at {0}", yaml.safe_load(workflow)["run-name"])
for key in ("tested_sha", "tested_branch"):
self.assertEqual(dry_run["outputs"][key],
"${{ steps.source.outputs." + key + " }}")
@@ -1707,7 +1713,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
self.assertIn(promotion_metadata_envelope(), normalize_ws(template))
self.assertIn("rc-to-ga-rehearsal-summary", workflow)
self.assertIn("build_release_candidate:", workflow)
self.assertIn("if: ${{ inputs.version != '' }}", workflow)
self.assertIn("if: ${{ inputs.watchdog != true && inputs.version != '' }}", workflow)
self.assertIn("require_macos_signing: true", workflow)
self.assertIn(
"require_windows_signing: false",
@@ -2194,12 +2200,19 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
dry_run_workflow = read(".github/workflows/release-dry-run.yml")
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.
# The fixed watchdog has no rollback override, so its dispatch schema
# must permit omission. Ordinary manual rehearsals still require an
# explicit rollback in the pre-checkout admission shell.
dry_run = yaml.safe_load(dry_run_workflow)
inputs = dry_run.get("on", dry_run.get(True))["workflow_dispatch"]["inputs"]
self.assertIs(inputs["rollback_version"]["required"], False)
self.assertIn('workflow_dispatch:false)', dry_run_workflow)
self.assertIn('[ -z "${ROLLBACK_VERSION_INPUT:-}" ]', dry_run_workflow)
self.assertIn('Candidate rehearsal requires explicit rollback and no watchdog SHA.', dry_run_workflow)
# Both admitted watchdog modes derive rollback; a candidate rehearsal
# can never reach that derivation, including when the input is absent.
self.assertIn(
'if [ "${EVENT_NAME}" = "schedule" ] && [ -z "${ROLLBACK_VERSION_INPUT:-}" ]; then',
'if [ "${WATCHDOG_MODE}" = "true" ] && [ -z "${ROLLBACK_VERSION_INPUT:-}" ]; then',
dry_run_workflow,
)
self.assertIn("--derive-rollback-latest-stable", dry_run_workflow)