mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-03 04:38:48 +00:00
Integrate reviewed post-publication watchdog observation
Change-source: pulse-maintainer
This commit is contained in:
commit
920aa2f27c
5 changed files with 204 additions and 20 deletions
67
.github/workflows/release-dry-run.yml
vendored
67
.github/workflows/release-dry-run.yml
vendored
|
|
@ -315,16 +315,33 @@ jobs:
|
|||
elif [ -n "${UNSIGNED_WINDOWS_REASON_INPUT:-}" ]; then
|
||||
HELPER_ARGS+=(--unsigned-windows-reason "${UNSIGNED_WINDOWS_REASON_INPUT}")
|
||||
fi
|
||||
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"
|
||||
if [ "${WATCHDOG_MODE}" = "true" ]; then
|
||||
# A release line can retain its published stable VERSION while
|
||||
# reviewed fixes accumulate. Observing that source is not a new
|
||||
# stable promotion: do not invent an RC, soak or exception envelope.
|
||||
# These helpers already exist on the selected release line; no
|
||||
# newer main-only helper is required after the detached checkout.
|
||||
WATCHED_VERSION="$VERSION" PYTHONPATH=scripts/release_control python3 - <<'PY' > "$RUNNER_TEMP/rehearsal-metadata.out"
|
||||
import os
|
||||
from resolve_release_promotion import (
|
||||
derive_latest_stable_rollback_tag, list_stable_tags, release_stage, tag_exists,
|
||||
)
|
||||
version = os.environ["WATCHED_VERSION"]
|
||||
stage = release_stage(version)
|
||||
rollback = derive_latest_stable_rollback_tag(version, list_stable_tags())
|
||||
if not tag_exists(rollback):
|
||||
raise ValueError("Watchdog rollback reference is unavailable")
|
||||
print("metadata_mode=watchdog")
|
||||
print(f"watched_version_stage={stage}")
|
||||
print(f"rollback_tag={rollback}")
|
||||
PY
|
||||
else
|
||||
# Candidate rehearsals retain every promotion and rollback gate.
|
||||
python3 scripts/release_control/resolve_release_promotion.py \
|
||||
"${HELPER_ARGS[@]}" > "$RUNNER_TEMP/rehearsal-metadata.out"
|
||||
echo "metadata_mode=promotion" >> "$RUNNER_TEMP/rehearsal-metadata.out"
|
||||
fi
|
||||
|
||||
python3 scripts/release_control/resolve_release_promotion.py \
|
||||
"${HELPER_ARGS[@]}" > "$RUNNER_TEMP/rehearsal-metadata.out"
|
||||
|
||||
{
|
||||
echo "version=${VERSION}"
|
||||
echo "tag=${TAG}"
|
||||
|
|
@ -482,8 +499,10 @@ jobs:
|
|||
if-no-files-found: error
|
||||
|
||||
- name: Write rehearsal summary
|
||||
id: summary
|
||||
if: always()
|
||||
env:
|
||||
WATCHDOG_MODE: ${{ steps.mode.outputs.watchdog }}
|
||||
TESTED_SHA: ${{ steps.source.outputs.tested_sha }}
|
||||
TESTED_BRANCH: ${{ steps.source.outputs.tested_branch }}
|
||||
NOTE: ${{ inputs.note }}
|
||||
|
|
@ -506,8 +525,33 @@ jobs:
|
|||
run: |
|
||||
mkdir -p release-dry-run
|
||||
SUMMARY_FILE="release-dry-run/rc-to-ga-rehearsal-summary.md"
|
||||
ARTIFACT_NAME="rc-to-ga-rehearsal-summary"
|
||||
if [ "${WATCHDOG_MODE}" = "true" ]; then
|
||||
SUMMARY_FILE="release-dry-run/watchdog-summary.md"
|
||||
ARTIFACT_NAME="release-watchdog-summary"
|
||||
fi
|
||||
{
|
||||
echo "summary_file=${SUMMARY_FILE}"
|
||||
echo "artifact_name=${ARTIFACT_NAME}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
if [ "${REHEARSAL_CONCLUSION}" != "success" ]; then
|
||||
if [ "${WATCHDOG_MODE}" = "true" ]; then
|
||||
{
|
||||
echo "# Release Watchdog Observation"
|
||||
echo ""
|
||||
echo "- Workflow run: ${RUN_URL}"
|
||||
echo "- Workflow event: ${GITHUB_REF_NAME}@${GITHUB_SHA}"
|
||||
echo "- Tested source: ${TESTED_BRANCH:-unresolved}@${TESTED_SHA:-unresolved}"
|
||||
echo "- Declared source version: ${WORKFLOW_OUTPUT_1:-unresolved}"
|
||||
echo "- Preceding stable reference: ${WORKFLOW_OUTPUT_5:-unresolved}"
|
||||
echo "- Source metadata observation: ${REHEARSAL_CONCLUSION}"
|
||||
echo "- Preflight result: ${JOB_CONCLUSION}"
|
||||
echo ""
|
||||
echo "This observes branch drift, build and integration checks, not a candidate promotion."
|
||||
echo "The definitive verdict also requires the stable demo's no-mutation verification."
|
||||
echo "It does not establish promotion readiness, soak, qualification or installed recovery."
|
||||
} > "$SUMMARY_FILE"
|
||||
elif [ "${REHEARSAL_CONCLUSION}" != "success" ]; then
|
||||
{
|
||||
echo "# Prerelease-to-GA Rehearsal Summary"
|
||||
echo ""
|
||||
|
|
@ -586,8 +630,9 @@ jobs:
|
|||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: rc-to-ga-rehearsal-summary
|
||||
path: release-dry-run/rc-to-ga-rehearsal-summary.md
|
||||
name: ${{ steps.summary.outputs.artifact_name }}
|
||||
path: ${{ steps.summary.outputs.summary_file }}
|
||||
if-no-files-found: error
|
||||
|
||||
demo_path_preflight:
|
||||
name: Verify Current Stable Demo Path (No Mutation)
|
||||
|
|
|
|||
|
|
@ -148,9 +148,24 @@ 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.
|
||||
|
||||
A release line can still declare its published stable VERSION after reviewed
|
||||
runtime fixes land. Watchdog metadata therefore observes the selected source's
|
||||
supported version stage and preceding stable tag; it does not call the
|
||||
candidate-promotion resolver, infer an RC, override soak or generate a promotion
|
||||
envelope. Candidate rehearsals still use that unchanged resolver and all its
|
||||
explicit rollback, ancestry, runtime-content and soak gates. Branch/VERSION
|
||||
equality and missing-reference failures remain fatal in both paths. Watchdog
|
||||
summaries retain actual preflight failure/success separately from the definitive
|
||||
demo verdict, use the distinct `release-watchdog-summary` artifact, and cannot
|
||||
be mistaken for the recorder's `rc-to-ga-rehearsal-summary` promotion evidence.
|
||||
|
||||
Verification: `rehearsal_source_test.py` executes the source-selection shell
|
||||
against local Git fixtures. `release_promotion_policy_test.py` pins workflow
|
||||
ordering, metadata wiring and separate source reporting. Passing local fixtures
|
||||
ordering, metadata wiring and separate source reporting. The executable fixtures
|
||||
cover a post-publication runtime change with the same stable VERSION, the
|
||||
identical candidate's continued promotion refusal, RC/missing-reference/version
|
||||
failure paths and a failed watchdog's non-promotion summary. Registered
|
||||
`build_release_assets_test.go` guards that evidence separation. Passing local fixtures
|
||||
does not establish hosted backend, integration or demo execution; those outcomes
|
||||
must be observed after landing.
|
||||
|
||||
|
|
|
|||
|
|
@ -2444,6 +2444,37 @@ func TestReleaseWatchdogIsControlBoundAndCannotBuildCandidate(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestReleaseWatchdogDoesNotManufacturePromotionReadiness(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{
|
||||
`print("metadata_mode=watchdog")`,
|
||||
`echo "metadata_mode=promotion"`,
|
||||
`derive_latest_stable_rollback_tag(version, list_stable_tags())`,
|
||||
`ARTIFACT_NAME="release-watchdog-summary"`,
|
||||
`SUMMARY_FILE="release-dry-run/watchdog-summary.md"`,
|
||||
`not a candidate promotion`,
|
||||
`does not establish promotion readiness, soak, qualification or installed recovery`,
|
||||
`name: ${{ steps.summary.outputs.artifact_name }}`,
|
||||
`path: ${{ steps.summary.outputs.summary_file }}`,
|
||||
} {
|
||||
if !strings.Contains(text, required) {
|
||||
t.Fatalf("watchdog/promotion evidence separation missing: %s", required)
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, "--derive-rollback-latest-stable") {
|
||||
t.Fatal("watchdog must not manufacture a stable-promotion envelope from the branch VERSION")
|
||||
}
|
||||
assertFileContainsAll(t, repoFile("scripts", "release_control", "rehearsal_source_test.py"),
|
||||
`test_postpublication_watchdog_does_not_pretend_to_promote_stable`,
|
||||
`test_identical_candidate_rehearsal_still_refuses_new_stable_promotion`,
|
||||
`test_watchdog_summary_cannot_be_recorded_as_promotion_readiness`,
|
||||
)
|
||||
}
|
||||
|
||||
func TestDemoMutationAndRecoverySharePhysicalTargetLock(t *testing.T) {
|
||||
updateBytes, err := os.ReadFile(repoFile(".github", "workflows", "update-demo-server.yml"))
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -37,8 +37,11 @@ class RehearsalSourceTest(unittest.TestCase):
|
|||
self.git("config", "user.email", "fixture@example.invalid")
|
||||
self.git("config", "user.name", "Fixture")
|
||||
self.git("config", "core.hooksPath", "/dev/null")
|
||||
for name in ("scripts/release_control/control_plane.py",
|
||||
# Match the real checkout's generated-file policy, including Python's
|
||||
# import cache, while still detecting any uncommitted source change.
|
||||
for name in (".gitignore", "scripts/release_control/control_plane.py",
|
||||
"scripts/release_control/repo_file_io.py",
|
||||
"scripts/release_control/resolve_release_promotion.py",
|
||||
"docs/release-control/control_plane.json"):
|
||||
target = self.repo / name
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
|
@ -194,13 +197,99 @@ 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 [ "${WATCHDOG_MODE}" = "true" ] && [ -z "${ROLLBACK_VERSION_INPUT:-}" ]; then', metadata)
|
||||
self.assertIn('--derive-rollback-latest-stable', metadata)
|
||||
self.assertIn('if [ "${WATCHDOG_MODE}" = "true" ]; then', metadata)
|
||||
self.assertIn('metadata_mode=watchdog', metadata)
|
||||
self.assertIn('metadata_mode=promotion', metadata)
|
||||
self.assertNotIn('--derive-rollback-latest-stable', metadata)
|
||||
workflow = WORKFLOW.read_text()
|
||||
self.assertIn('TESTED_SHA: ${{ needs.dry-run.outputs.tested_sha }}', workflow)
|
||||
self.assertIn('Workflow event SHA:', workflow)
|
||||
self.assertNotIn('echo "- Source SHA:', workflow)
|
||||
|
||||
def metadata(self, watchdog="true", **overrides):
|
||||
env = dict(self.env, EVENT_NAME="workflow_dispatch", WATCHDOG_MODE=watchdog,
|
||||
TESTED_BRANCH="release/v6.4", WORKFLOW_OUTPUT_1="release/v6.4",
|
||||
RUNNER_TEMP=str(self.root), GITHUB_OUTPUT=str(self.output))
|
||||
env.update(overrides)
|
||||
return subprocess.run(["bash", "-euo", "pipefail", "-c",
|
||||
step("Resolve rehearsal metadata")],
|
||||
cwd=self.repo, env=env, text=True, capture_output=True)
|
||||
|
||||
def published_stable_with_pending_fix(self):
|
||||
self.git("tag", "v6.4.1", self.main)
|
||||
self.git("checkout", "release/v6.4")
|
||||
self.version("6.4.5")
|
||||
self.git("tag", "v6.4.5-rc.5")
|
||||
self.git("tag", "v6.4.5")
|
||||
runtime = self.repo / "internal/updates/pending.go"
|
||||
runtime.parent.mkdir(parents=True)
|
||||
runtime.write_text("package updates\n")
|
||||
self.git("add", ".")
|
||||
self.git("commit", "-m", "reviewed pending fix after stable")
|
||||
|
||||
def test_postpublication_watchdog_does_not_pretend_to_promote_stable(self):
|
||||
self.published_stable_with_pending_fix()
|
||||
before = self.git("rev-parse", "HEAD")
|
||||
result = self.metadata()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
observation = self.output.read_text()
|
||||
self.assertIn("version=6.4.5\n", observation)
|
||||
self.assertIn("metadata_mode=watchdog\n", observation)
|
||||
self.assertIn("rollback_tag=v6.4.1\n", observation)
|
||||
self.assertIn("watched_version_stage=stable\n", observation)
|
||||
for candidate_field in ("promotion_mode=", "soak_hours=", "rollback_command=",
|
||||
"hotfix_exception=", "promoted_from_tag="):
|
||||
self.assertNotIn(candidate_field, observation)
|
||||
self.assertEqual(self.git("rev-parse", "HEAD"), before)
|
||||
self.assertEqual(self.git("status", "--porcelain"), "")
|
||||
|
||||
def test_identical_candidate_rehearsal_still_refuses_new_stable_promotion(self):
|
||||
self.published_stable_with_pending_fix()
|
||||
result = self.metadata("false", ROLLBACK_VERSION_INPUT="v6.4.1")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("same-version release candidates already exist", result.stderr)
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
def test_watchdog_rc_and_metadata_failures_remain_source_bound(self):
|
||||
self.git("tag", "v6.4.1", self.main)
|
||||
self.git("checkout", "release/v6.4")
|
||||
self.version("6.4.6-rc.1")
|
||||
self.assertEqual(self.metadata().returncode, 0)
|
||||
self.assertIn("watched_version_stage=rc\n", self.output.read_text())
|
||||
self.output.unlink()
|
||||
for overrides in ({"VERSION_INPUT": "6.4.5"},
|
||||
{"TESTED_BRANCH": "main"}):
|
||||
with self.subTest(overrides=overrides):
|
||||
self.assertNotEqual(self.metadata(**overrides).returncode, 0)
|
||||
self.assertFalse(self.output.exists())
|
||||
self.git("tag", "-d", "v6.4.1")
|
||||
missing = self.metadata()
|
||||
self.assertNotEqual(missing.returncode, 0)
|
||||
self.assertIn("no stable release tag precedes it", missing.stderr)
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
def test_watchdog_summary_cannot_be_recorded_as_promotion_readiness(self):
|
||||
# Execute the real summary shell without an Actions expression evaluator.
|
||||
script = step("Write rehearsal summary").replace(
|
||||
"${{ github.repository }}", "rcourtman/Pulse").replace(
|
||||
"${{ github.run_id }}", "123")
|
||||
env = dict(self.env, WATCHDOG_MODE="true", GITHUB_OUTPUT=str(self.output),
|
||||
GITHUB_STEP_SUMMARY=str(self.root / "step-summary"),
|
||||
GITHUB_REF_NAME="main", GITHUB_SHA=self.main,
|
||||
TESTED_BRANCH="release/v6.4", TESTED_SHA=self.release,
|
||||
REHEARSAL_CONCLUSION="success", JOB_CONCLUSION="failure",
|
||||
WORKFLOW_OUTPUT_1="6.4.5", WORKFLOW_OUTPUT_5="v6.4.1")
|
||||
result = subprocess.run(["bash", "-euo", "pipefail", "-c", script],
|
||||
cwd=self.repo, env=env, text=True, capture_output=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
text = (self.repo / "release-dry-run/watchdog-summary.md").read_text()
|
||||
self.assertIn("- Preflight result: failure", text)
|
||||
self.assertIn("not a candidate promotion", text)
|
||||
self.assertNotIn("Candidate stable tag:", text)
|
||||
self.assertNotIn("record_rc_to_ga_rehearsal.py", text)
|
||||
self.assertIn("artifact_name=release-watchdog-summary\n", self.output.read_text())
|
||||
self.assertFalse((self.repo / "release-dry-run/rc-to-ga-rehearsal-summary.md").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -426,9 +426,11 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
|
|||
metadata["run"])
|
||||
self.assertIn('if [ "$FILE_VERSION" != "$VERSION" ]; then', metadata["run"])
|
||||
self.assertIn(
|
||||
'if [ "${WATCHDOG_MODE}" = "true" ] && [ -z "${ROLLBACK_VERSION_INPUT:-}" ]; then',
|
||||
'if [ "${WATCHDOG_MODE}" = "true" ]; then',
|
||||
metadata["run"],
|
||||
)
|
||||
self.assertIn('metadata_mode=watchdog', metadata["run"])
|
||||
self.assertIn('metadata_mode=promotion', metadata["run"])
|
||||
self.assertEqual(metadata["env"]["WATCHDOG_MODE"], "${{ steps.mode.outputs.watchdog }}")
|
||||
self.assertEqual(jobs["build_release_candidate"]["if"],
|
||||
"${{ inputs.watchdog != true && inputs.version != '' }}")
|
||||
|
|
@ -2209,13 +2211,15 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
|
|||
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.
|
||||
# Both admitted watchdog modes observe the preceding stable reference,
|
||||
# without manufacturing a new promotion envelope for an already-shipped
|
||||
# VERSION. Candidate rehearsals still use the complete resolver.
|
||||
self.assertIn(
|
||||
'if [ "${WATCHDOG_MODE}" = "true" ] && [ -z "${ROLLBACK_VERSION_INPUT:-}" ]; then',
|
||||
'if [ "${WATCHDOG_MODE}" = "true" ]; then',
|
||||
dry_run_workflow,
|
||||
)
|
||||
self.assertIn("--derive-rollback-latest-stable", dry_run_workflow)
|
||||
self.assertIn("derive_latest_stable_rollback_tag(version, list_stable_tags())", dry_run_workflow)
|
||||
self.assertNotIn("--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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue