Soak release candidates for 24 hours before stable

The resolver refused a stable promotion until its candidate had soaked for
72 hours, or 168 for a minor. Few installs run previews: 35 of about 7,900
active installs ran v6.4.5-rc.3 in the week to 2026-09-28, so a longer
soak saw little the first day did not, while 75 percent of installs sat on
a stable build a month old. Richard set a 24 hour soak for every release
on 2026-09-28.

The minimums drop to 24 hours for minor and patch alike, the error text
reads the constants instead of repeating numbers, and the workflow and
trigger prompts, the promotion policy and the deployment contract say the
same. The content-drift check and the hotfix exception are unchanged.
This commit is contained in:
Richard Courtman
2026-09-28 16:42:46 +01:00
parent ad15366f3d
commit 042b01a953
11 changed files with 45 additions and 30 deletions
+1 -1
View File
@@ -194,7 +194,7 @@ Use this decision sequence within the existing release authority:
This is supersession, not evidence that the old publication was successful
or that customers pinned to its container version have migrated.
3. Keep patch scope to named regression/security fixes. A changed patch RC
restarts 72 hours of clean soak; an older immutable RC or green main cannot
restarts 24 hours of clean soak; an older immutable RC or green main cannot
lend its qualification to changed bytes. Retain the exact candidate packet
for release-steward judgment and exact-source admission before stable
publication. Under standing authority granted on 8 September 2026, the
+1 -1
View File
@@ -46,7 +46,7 @@ on:
required: false
type: string
hotfix_exception:
description: 'Stable only: bypass the 72-hour prerelease soak for urgent customer harm'
description: 'Stable only: bypass the 24-hour prerelease soak for urgent customer harm'
required: false
type: boolean
default: false
+1 -1
View File
@@ -34,7 +34,7 @@ on:
required: false
type: string
hotfix_exception:
description: 'Stable rehearsal only: bypass 72-hour prerelease soak for urgent customer harm'
description: 'Stable rehearsal only: bypass 24-hour prerelease soak for urgent customer harm'
required: false
type: boolean
default: false
@@ -566,7 +566,7 @@ Companion drill:
2. Confirm the candidate commit has already shipped on `rc` through a real
release-pipeline run, not only workflow lint or static YAML validation.
Accidental prerelease git tags do not count as shipped prerelease lineage.
3. Confirm the candidate satisfies the minimum 72-hour prerelease soak or that a
3. Confirm the candidate satisfies the minimum 24-hour prerelease soak or that a
hotfix exception and reason are recorded explicitly before promotion.
4. Confirm the previous stable rollback target and exact reinstall or pin
command are recorded in the release notes or release ticket.
@@ -278,9 +278,12 @@ without the other lanes changing the candidate underneath it.
upgrade guide, release records) unless `hotfix_exception` names active
customer harm. The v6.4.0 promotion, which shipped 64 changed files that
`v6.4.0-rc.12` had not soaked, is the case this rule prevents.
4. A minor release (`X.Y.0`) requires a seven day soak of the promoted
candidate; patch releases keep the 72 hour minimum. Patch releases are for
a named regression or security issue only.
4. A minor release (`X.Y.0`) and a patch release both require a 24 hour soak
of the promoted candidate (founder direction of 28 September 2026; it was
seven days for a minor and 72 hours for a patch). Few installs run
previews, so the longer soak saw little beyond the first day while stable
users waited. Patch releases are for a named regression or security issue
only.
5. "Soaked clean" means all of: the soak has elapsed since the candidate's
release was published; no open issue reports an unresolved regression,
security or data-loss problem in the candidate, including any issue
@@ -435,7 +438,7 @@ without the other lanes changing the candidate underneath it.
- The locked 90-day v5 maintenance-only policy in
`V5_MAINTENANCE_SUPPORT_POLICY.md` and the exact end-of-support notice
ready to publish with the promotion.
7. RC-derived stable promotions require a minimum 72-hour prerelease soak after
7. RC-derived stable promotions require a minimum 24-hour prerelease soak after
the candidate is available to internal or staging-like users.
8. Hotfix exception:
- Bypassing an RC requirement or shortening an RC soak is allowed only for
@@ -5601,12 +5601,19 @@ in release metadata: `VERSION`, the Helm chart version and README, the compose
default image, `docs/RELEASE_NOTES.md`, `docs/UPGRADE_v6.md` and its shipped
docs mirror, `docs/releases/`, release-control records, this contract, and
`status.json`. Any other path refuses the promotion unless `hotfix_exception`
names active customer harm, because that content was never soaked. Minor
releases (`X.Y.0`) additionally require a seven day soak; patches keep 72
hours. `docs/release-control/control_plane.json` declares `release/v6.5` for
names active customer harm, because that content was never soaked. Since
the founder direction of 28 September 2026 minor releases (`X.Y.0`) and
patches both require a 24 hour soak (`MIN_STABLE_SOAK_HOURS` and
`MIN_MINOR_STABLE_SOAK_HOURS`); it was seven days and 72 hours. The workflow
and trigger prompts describe the hotfix exception against the same 24 hours.
A release line cut earlier keeps its own resolver's longer minimum, which the
maintainer's release admission relaxes to the release plan's soak only
through that exception and only once the stable source is shown to add
nothing beyond release metadata. `docs/release-control/control_plane.json` declares `release/v6.5` for
the first release train so the workflow refuses a v6.5 dispatch from any other
branch. `scripts/release_control/resolve_release_promotion_test.py` pins the
allowlist, the drift refusal, the hotfix path, and the minor soak;
allowlist, the drift refusal, the hotfix path, and the 24 hour minor and
patch soak;
`release_promotion_policy_test.py` pins the policy's Release Train section.
### Provider docs name the Patrol weekly summary schedule kind
@@ -1767,7 +1767,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
self.assertIn("release-dry-run.yml", dry_run_trigger)
self.assertIn("gh workflow run release-dry-run.yml", dry_run_trigger)
self.assertIn("Release Dry Run executes the selected remote ref", dry_run_trigger)
self.assertIn("Hotfix exception to bypass 72-hour prerelease soak? [y/N]", dry_run_trigger)
self.assertIn("Hotfix exception to bypass 24-hour prerelease soak? [y/N]", dry_run_trigger)
self.assertIn("blank only for approved hotfix", dry_run_trigger)
self.assertIn('if [ -z "$PROMOTED_FROM_TAG" ] && [ "$HOTFIX_EXCEPTION" != "true" ]; then', dry_run_trigger)
self.assertNotIn("Continue anyway?", dry_run_trigger)
@@ -1863,7 +1863,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
self.assertIn(promotion_metadata_envelope(), normalize_ws(policy))
self.assertIn("recorded rollback target plus exact", source_of_truth)
self.assertIn("hours of prerelease soak", resolver)
self.assertIn("minimum is 72 hours unless hotfix_exception is true", resolver)
self.assertIn("minimum is {MIN_STABLE_SOAK_HOURS} hours unless hotfix_exception is true", resolver)
self.assertIn("MIN_PRERELEASE_OBSERVATION_HOURS = 24", resolver)
self.assertIn("cohort checkpoint, not a delivery vehicle", normalize_ws(policy))
self.assertIn("at least 24 hours of public observation", normalize_ws(source_of_truth))
@@ -24,10 +24,12 @@ SEMVER_PUBLISHED_PRERELEASE_RE = re.compile(
)
SEMVER_RC_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)-rc\.(\d+)$")
MIN_PRERELEASE_OBSERVATION_HOURS = 24
# Release train (RELEASE_PROMOTION_POLICY.md, "Release Train"): a patch keeps
# the 72 hour candidate soak; a minor release soaks its candidate for a week.
MIN_STABLE_SOAK_HOURS = 72
MIN_MINOR_STABLE_SOAK_HOURS = 168
# Release train (RELEASE_PROMOTION_POLICY.md, "Release Train"): since the
# founder direction of 28 September 2026 every candidate, minor or patch, soaks
# for 24 hours. Few installs run previews, so a longer soak saw little more
# than the first day while keeping stable users on an older build.
MIN_STABLE_SOAK_HOURS = 24
MIN_MINOR_STABLE_SOAK_HOURS = 24
RELEASE_TRAIN_MIN_VERSION = (6, 5, 0)
# Paths a stable promotion may change relative to its promoted candidate.
# Everything else is content the candidate never soaked, so the resolver
@@ -586,11 +588,12 @@ def resolve_metadata(
raise ValueError(
f"Minor stable promotion {tag} has only {soak_hours_value} hours of prerelease soak since "
f"{promoted_from_tag}; the release train requires {MIN_MINOR_STABLE_SOAK_HOURS} hours "
"(seven days) for a minor release unless hotfix_exception is true."
"for a minor release unless hotfix_exception is true."
)
elif soak_hours_value < MIN_STABLE_SOAK_HOURS:
raise ValueError(
f"Stable promotion {tag} has only {soak_hours_value} hours of prerelease soak since {promoted_from_tag}; minimum is 72 hours unless hotfix_exception is true."
f"Stable promotion {tag} has only {soak_hours_value} hours of prerelease soak since "
f"{promoted_from_tag}; minimum is {MIN_STABLE_SOAK_HOURS} hours unless hotfix_exception is true."
)
if version == "6.0.0":
@@ -1003,7 +1003,7 @@ class ReleaseTrainPromotionTest(unittest.TestCase):
self.assertEqual(metadata["hotfix_exception"], "true")
def test_repaired_candidate_restarts_full_publication_soak(self) -> None:
for version, hours in (("6.5.0", 168), ("6.5.1", 72), ("6.4.5", 72)):
for version, hours in (("6.5.0", 24), ("6.5.1", 24), ("6.4.5", 24)):
tag = f"v{version}-rc.2"
observed = []
def publication(candidate):
@@ -1023,13 +1023,15 @@ class ReleaseTrainPromotionTest(unittest.TestCase):
self.assertEqual(metadata["soak_hours"], str(hours))
self.assertEqual(observed, [tag, tag])
def test_minor_releases_soak_seven_days_and_patches_seventy_two_hours(self) -> None:
with self.assertRaisesRegex(ValueError, "release train requires 168 hours"):
self.promote("6.5.0", now_unix_fn=lambda: 100 + (100 * 3600))
metadata = self.promote("6.5.1", now_unix_fn=lambda: 100 + (73 * 3600))
self.assertEqual(metadata["soak_hours"], "73")
with self.assertRaisesRegex(ValueError, "minimum is 72 hours"):
self.promote("6.5.1", now_unix_fn=lambda: 100 + (71 * 3600))
def test_minor_and_patch_releases_soak_twenty_four_hours(self) -> None:
with self.assertRaisesRegex(ValueError, "release train requires 24 hours"):
self.promote("6.5.0", now_unix_fn=lambda: 100 + (23 * 3600))
metadata = self.promote("6.5.0", now_unix_fn=lambda: 100 + (24 * 3600))
self.assertEqual(metadata["soak_hours"], "24")
metadata = self.promote("6.5.1", now_unix_fn=lambda: 100 + (25 * 3600))
self.assertEqual(metadata["soak_hours"], "25")
with self.assertRaisesRegex(ValueError, "minimum is 24 hours"):
self.promote("6.5.1", now_unix_fn=lambda: 100 + (23 * 3600))
if __name__ == "__main__":
+1 -1
View File
@@ -146,7 +146,7 @@ fi
if [ "$IS_PRERELEASE" != "true" ]; then
echo ""
read -r -p "Hotfix exception to bypass 72-hour prerelease soak? [y/N] " HOTFIX_REPLY
read -r -p "Hotfix exception to bypass 24-hour prerelease soak? [y/N] " HOTFIX_REPLY
if [[ "$HOTFIX_REPLY" =~ ^[Yy]$ ]]; then
HOTFIX_EXCEPTION="true"
read -r -p "Hotfix reason: " HOTFIX_REASON
+1 -1
View File
@@ -280,7 +280,7 @@ echo "Derived rollback command: ${ROLLBACK_COMMAND}"
if [ "$IS_PRERELEASE" != "true" ]; then
echo ""
read -r -p "Hotfix exception to bypass 72-hour prerelease soak? [y/N] " HOTFIX_REPLY
read -r -p "Hotfix exception to bypass 24-hour prerelease soak? [y/N] " HOTFIX_REPLY
if [[ "$HOTFIX_REPLY" =~ ^[Yy]$ ]]; then
HOTFIX_EXCEPTION="true"
read -r -p "Hotfix reason: " HOTFIX_REASON