From f47ac1f0203e2abff7d9ed659c570710210298a4 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:37:25 +0100 Subject: [PATCH] Define beta and RC release maturity --- .github/workflows/create-release.yml | 11 ++- .../HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md | 27 ++++++ .../v6/internal/PRE_RELEASE_CHECKLIST.md | 13 +++ .../v6/internal/RELEASE_PROMOTION_POLICY.md | 96 ++++++++++-------- docs/release-control/v6/internal/status.json | 38 ++++++++ .../subsystems/deployment-installability.md | 24 +++-- .../subsystems/frontend-primitives.md | 10 ++ frontend-modern/browser-verification.json | 41 ++++---- .../__tests__/settingsArchitecture.test.ts | 14 +++ ...updatesSettingsModel.branchcov0712.test.ts | 6 +- .../Settings/updatesSettingsModel.ts | 4 +- frontend-modern/src/content/help/updates.ts | 11 ++- .../__tests__/updatesPresentation.test.ts | 8 +- .../src/utils/updatesPresentation.ts | 8 +- internal/updates/manager.go | 9 +- internal/updates/version.go | 97 ++++++++++++++++--- internal/updates/version_test.go | 28 ++++++ scripts/generate-release-notes.sh | 39 +++++--- .../installtests/build_release_assets_test.go | 24 ++++- .../release_promotion_policy_test.py | 17 +++- .../resolve_release_promotion.py | 44 +++++++-- .../resolve_release_promotion_test.py | 67 ++++++++++--- scripts/trigger-release-dry-run.sh | 15 ++- scripts/trigger-release.sh | 29 +++++- 24 files changed, 528 insertions(+), 152 deletions(-) diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index f2e4d3190d..e1a840e843 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -1,5 +1,6 @@ name: Pulse Release Pipeline -# Optimized: parallel jobs, fast prerelease path +# Alpha and beta use the fast preview path. RCs run the stable-depth release +# checks because an RC is a build the maintainer believes can become stable. on: workflow_dispatch: @@ -88,6 +89,7 @@ jobs: version: ${{ steps.extract.outputs.version }} tag: ${{ steps.extract.outputs.tag }} is_prerelease: ${{ steps.extract.outputs.is_prerelease }} + release_stage: ${{ steps.promotion.outputs.release_stage }} source_branch: ${{ steps.extract.outputs.source_branch }} required_branch: ${{ steps.branch_policy.outputs.required_branch }} promoted_from_tag: ${{ steps.promotion.outputs.promoted_from_tag }} @@ -480,12 +482,13 @@ jobs: run: ./scripts/run-release-backend-tests.sh --data-root "$RUNNER_TEMP/pulse-test-data" - # Integration tests - skipped for prereleases (they've been tested in CI) + # Alpha and beta builds are feedback checkpoints. RC and stable publication + # run the deeper integration gate because an RC must be promotable in intent. integration_tests: needs: - prepare - frontend_bundle - if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' && needs.prepare.outputs.is_prerelease != 'true' }} + if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' && needs.prepare.outputs.release_stage != 'alpha' && needs.prepare.outputs.release_stage != 'beta' }} runs-on: ubuntu-24.04 timeout-minutes: 45 env: @@ -869,7 +872,7 @@ jobs: --release-notes-file "$NOTES_FILE" \ --release-visuals-file "$VISUAL_MARKDOWN_FILE" \ --output "$RENDERED_NOTES_FILE" \ - --promotion-channel "${{ needs.prepare.outputs.is_prerelease == 'true' && 'rc' || 'stable' }}" \ + --promotion-channel "${{ needs.prepare.outputs.release_stage }}" \ --candidate-tag "${{ needs.prepare.outputs.tag }}" \ --promoted-prerelease-tag "${{ needs.prepare.outputs.promoted_from_tag }}" \ --rollback-target "${{ needs.prepare.outputs.rollback_tag }}" \ diff --git a/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md b/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md index 162af30d6c..e1163e1f37 100644 --- a/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md +++ b/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md @@ -1043,6 +1043,33 @@ Companion drill: fails when rehearsed, demo deployment is detached from the release DAG, or routine mode can bypass a same-version RC or an RC-required runtime change. +## Gate: `prerelease-maturity-integrity` + +- Owner lanes: `L1` +- Risk covered: + User-validation builds can be mislabeled as release candidates, beta tags can + be treated as stable by update identity, or RC publication can take the + reduced beta test path. Any of those failures makes the version label a false + readiness claim. +- Minimum evidence tier: `test-proof` +- Canonical proof commands: + 1. `cd scripts/release_control && python3 -m unittest resolve_release_promotion_test release_promotion_policy_test` + 2. `go test ./internal/updates -count=1` + 3. `go test ./scripts/installtests -run TestReleaseNotesGeneratorResolvesChannelSpecificComparisonRanges -count=1` + 4. `cd frontend-modern && npx vitest run src/components/Settings/__tests__/updatesSettingsModel.branchcov0712.test.ts src/utils/__tests__/updatesPresentation.test.ts` +- Pass when: + Published version validation accepts only stable, `alpha.N`, `beta.N`, and + `rc.N`; beta is the documented user-testing stage; only RC provides normal + stable-promotion lineage; RC runs the stable-depth integration job; release + notes advance across maturity stages; runtime identity and ordering treat + alpha, beta, and RC as published previews; and user-facing update copy names + the channel Preview while retaining `rc` only as the compatibility wire value. +- Block release if: + A beta is presented as stable or as a release candidate, an RC skips the + stable-depth integration gate, stable promotion accepts alpha or beta + lineage, prerelease ordering is not SemVer-correct, or the product copy + teaches users that every preview is an RC. + ## Gate: `single-build-release-promotion-path` - Owner lanes: `L1` diff --git a/docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md b/docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md index cb3615b5e1..96a08f0910 100644 --- a/docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md +++ b/docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md @@ -10,6 +10,19 @@ Use this as the final gate before cutting a Pulse v6 pre-release. - Use `HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md` for the trust-critical hosted, relay, mobile, entitlement, org-scope, and API-token checks that must be manually confirmed before release. - Follow `RELEASE_PROMOTION_POLICY.md` for channel routing, prerelease soak, stable promotion, and rollback expectations. +## Maturity Decision + +- [ ] Use `alpha.N` only for incomplete or experimental internal evaluation. +- [ ] Use `beta.N` for the normal user-testing phase when known gaps or planned + product changes mean the build is not a stable candidate. +- [ ] Use `rc.N` only when the release owner believes the build can become + stable without product changes. RC publication runs the stable-depth + integration gate and is the only normal prerelease lineage eligible for + stable promotion. +- [ ] Keep the existing `rc` update-channel wire value as the compatibility + identifier for all published previews. User-facing copy calls this the + Preview channel and the version tag communicates alpha, beta, or RC maturity. + ## Current Status - Automated command-driven checks completed on 2026-03-06 are marked `[x]` below. - `status.json` is no longer overclaiming threshold-unmet gates as passed. diff --git a/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md b/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md index bb8ceeac83..f8ee50aef8 100644 --- a/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md +++ b/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md @@ -17,12 +17,15 @@ Cloud, and self-hosted production users. - The only recommended channel for paid and production environments. - Publishes only non-prerelease tags. - The only channel eligible for unattended broad rollout. -2. `rc` - - Opt-in preview channel for internal use, staging-like environments, and - explicitly willing preview users. - - Publishes prerelease tags such as `6.0.0-rc.1`. +2. `preview` (stored as `rc` in the existing update-channel API and persisted + settings for backward compatibility) + - Opt-in channel for internal use, staging-like environments, and explicitly + willing preview users. + - Publishes governed prerelease tags such as `6.5.0-alpha.1`, + `6.5.0-beta.1`, and `6.5.0-rc.1`. - Must never be the default channel. - - In v6, `rc` affects manual and in-app update selection; unattended + - In v6, the legacy `rc` wire value affects manual and in-app preview update + selection; unattended systemd auto-updates remain `stable`-only. 3. Source builds - Are not a customer-facing release channel. @@ -32,11 +35,12 @@ Cloud, and self-hosted production users. A reporter test image is an issue-scoped diagnostic source build for one or a few explicitly participating Docker users. It exists to validate a reviewed -fix quickly without manufacturing an RC or exposing the broader preview +fix quickly without publishing a prerelease or exposing the broader preview cohort to an unqualified candidate. -It is not a fourth release channel. It does not count as `rc`, `stable`, a -published prerelease, release qualification, or stable-promotion lineage. +It is not a fourth release channel. It does not count as alpha, beta, RC, +stable, a published prerelease, release qualification, or stable-promotion +lineage. ### Eligibility @@ -54,7 +58,9 @@ Use a reporter test image only when all of these conditions hold: handling, or a widened authentication, authorization, tenant, billing, licensing, relay, or other trust boundary -If any condition is false or uncertain, use the governed RC path. +If any condition is false or uncertain, use the governed prerelease path. Use +beta while user validation or planned product changes remain. Use RC only when +the build is believed capable of becoming stable without product changes. ### Build And Publication @@ -105,7 +111,7 @@ If any condition is false or uncertain, use the governed RC path. paid runtime, cross-repo compatibility, migrations, security boundaries, or the wider release. 5. A successful test does not force an immediate patch release. Schedule the - fix by severity and active customer harm. Any later RC, stable, or patch + fix by severity and active customer harm. Any later prerelease, stable, or patch must rebuild and qualify through the canonical release workflow rather than promote the reporter image. @@ -113,9 +119,9 @@ If any condition is false or uncertain, use the governed RC path. 1. A reporter test image must never create a GitHub release or git tag, update release notes or update feeds, publish Helm, move Docker aliases, update the - demo, stage or promote private paid artifacts, or satisfy an RC or stable + demo, stage or promote private paid artifacts, or satisfy a prerelease or stable release gate. -2. Never attach `latest`, a stable version, an RC version, or another mutable +2. Never attach `latest`, a stable version, an alpha, beta, or RC version, or another mutable alias to the image. 3. Failed validation is fixed forward from a new reviewed commit and published under a new commit-derived tag. Shared tags and digests are immutable. @@ -129,7 +135,7 @@ If any condition is false or uncertain, use the governed RC path. work. 2. Do not move directly from "issue fixed" to "all customers updated". 3. Channel promotion is the primary customer-safety boundary. -4. Branch topology may change over time; the `stable` versus `rc` customer +4. Branch topology may change over time; the `stable` versus `preview` customer contract must not. 5. The active release profile in `docs/release-control/control_plane.json` owns the governed prerelease and stable release branches for the current @@ -194,9 +200,17 @@ TLS-unverified receipts leave the claim at `implemented` or ## Prerelease Rules -1. Every candidate intended for broad customer use must ship to `rc` before it - is eligible for `stable`. -2. Each published prerelease must have: +1. Published prereleases use one of three maturity stages: + - `alpha.N` is incomplete or experimental and intended for internal or + tightly controlled evaluation. + - `beta.N` is the normal user-testing stage. It may contain known gaps and + planned product changes and is not represented as a possible stable build. + - `rc.N` is reserved for a build the release owner believes can become + stable without product changes. RC publication runs the stable-depth + integration gate in addition to the common release checks. +2. Stable promotion lineage must come from a published `rc.N`. Alpha and beta + evidence inform the release, but neither can be promoted directly to stable. +3. Each published prerelease must have: - Targeted automated checks for touched release surfaces. - A smoke install on a fresh or staging-like environment. - Release notes plus the rollback target and exact reinstall command recorded before publish. @@ -204,17 +218,19 @@ TLS-unverified receipts leave the claim at `implemented` or only structural workflow validation. - A governed prerelease publication record; an accidental git tag by itself does not count as a shipped prerelease. -3. A published release candidate is a cohort checkpoint, not a delivery vehicle - for each individual fix. After the first RC on a version line, at least 24 - hours of public observation must elapse before another RC on that line may be - published. Accumulate compatible fixes and release-note outcomes during the - window. Candidate preparation, draft creation, and Release Dry Run remain - available throughout it because they do not replace the public cohort. -4. Use an immutable issue-and-commit reporter test image for narrow confirmation +4. A published alpha, beta, or RC is a cohort checkpoint, not a delivery + vehicle for each individual fix. After the first checkpoint at a maturity + stage on a version line, at least 24 hours of public observation must elapse + before another checkpoint at that same stage may be published. Moving from + alpha to beta or beta to RC is a maturity decision and starts a new stage. + Accumulate compatible fixes and release-note outcomes during the window. + Candidate preparation, draft creation, and Release Dry Run remain available + throughout it because they do not replace the public cohort. +5. Use an immutable issue-and-commit reporter test image for narrow confirmation that cannot justify a new public cohort. Broad release qualification remains - on the RC path, but successful narrow validation does not bypass the 24-hour - publication boundary. -5. Failed prereleases are fixed forward and replaced with a new prerelease. They are never + on the governed prerelease path, but successful narrow validation does not + bypass the 24-hour publication boundary. +6. Failed prereleases are fixed forward and replaced with a new prerelease. They are never promoted as-is to `stable`. ## Paid Pro Artifact Lineage @@ -222,9 +238,10 @@ TLS-unverified receipts leave the claim at `implemented` or 1. Customer-facing private Pulse Pro archives and private Pulse Pro Docker images must track the same immutable release checkpoint as the public Pulse release they support. -2. During the v6 RC phase, private Pro artifacts must be built from the exact - public RC tag, use the same RC version, and publish under RC-shaped artifact - names, R2 prefixes, and Docker tags such as `6.0.0-rc.5`. +2. During a v6 prerelease phase, private Pro artifacts must be built from the + exact public alpha, beta, or RC tag, use that same version, and publish under + matching artifact names, R2 prefixes, and Docker tags such as + `6.5.0-beta.1`. 3. Do not build or advertise `license.pulserelay.pro/pulse-pro:6.0.0`, a `pulse-pro-v6.0.0-...` private archive, or a GA-shaped private R2 prefix until the intentional v6 GA publish. @@ -267,7 +284,8 @@ TLS-unverified receipts leave the claim at `implemented` or it validates the proof packet signatures, installs the exact manifest on `pulse-license`, runs the live customer-path proof, and restores the previous remote manifest if the gate fails. Do not send customer instructions from a - customer-facing private Pro RC/GA release until that command passes. + customer-facing private Pro prerelease or stable release until that command + passes. ## v5 Maintenance Policy @@ -298,10 +316,11 @@ TLS-unverified receipts leave the claim at `implemented` or 1. A first stable release, a stable minor release, and every patch that crosses one of the RC-required risk boundaries below must be promoted from a commit - that has already been exercised as a published prerelease. -2. A prerelease git tag counts as stable-promotion lineage only if that prerelease was - actually published through the governed prerelease path; accidental or abandoned git - tags do not satisfy the stable-promotion requirement. + that has already been exercised as a published RC. +2. An RC git tag counts as stable-promotion lineage only if that RC was actually + published through the governed prerelease path. Alpha tags, beta tags, and + accidental or abandoned RC tags do not satisfy the stable-promotion + requirement. 3. For v6 GA, do not promote to `stable` until the active control-plane target is the GA-promotion target and satisfies its `release_ready` completion rule. @@ -505,7 +524,7 @@ TLS-unverified receipts leave the claim at `implemented` or ## Single-Build Release Path -1. Every normal RC, stable, and patch release is initiated once through +1. Every normal alpha, beta, RC, stable, and patch release is initiated once through `create-release.yml`. The workflow builds one exact-SHA candidate with the native signing lanes required by that version's governed policy while frontend, backend, Docker, Helm, and integration checks run in parallel. No @@ -600,7 +619,8 @@ TLS-unverified receipts leave the claim at `implemented` or 1. Default installs stay on `stable`. 2. Broad customer announcements and unattended updates target `stable` only. -3. `rc` enrollment must be explicit and reversible. +3. Preview enrollment must be explicit and reversible. The persisted and API + wire value remains `rc` until a separately governed compatibility migration. 4. Paid production tenants should remain on `stable` unless they are knowingly participating in preview validation. @@ -608,8 +628,8 @@ TLS-unverified receipts leave the claim at `implemented` or 1. Never delete or rewrite shipped tags to hide a bad release; supersede them with a newer release and explicit guidance. -2. If a prerelease is bad, hold it in `rc`, fix forward, and cut the next prerelease. Do not - promote it. +2. If a prerelease is bad, hold it in the Preview channel, fix forward, and cut + the next prerelease. Do not promote it. 3. If a stable release is bad: - Pause further promotion or auto-update exposure. - Direct affected users to the prior stable pin. diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index 1f8de77655..2eccdf11fe 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -9014,6 +9014,44 @@ } ] }, + { + "id": "prerelease-maturity-integrity", + "summary": "Confirm published previews use alpha for incomplete evaluation, beta for user testing with expected product changes, and RC only for promotable-intent builds; only RC provides normal stable lineage, RC runs stable-depth integration, and runtime identity keeps every published prerelease on the Preview channel.", + "owner": "project-owner", + "blocking_level": "rc-ready", + "minimum_evidence_tier": "test-proof", + "status": "passed", + "verification_doc": "docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md", + "lane_ids": [ + "L1" + ], + "evidence": [ + { + "repo": "pulse", + "path": "internal/updates/version_test.go", + "kind": "file", + "evidence_tier": "test-proof" + }, + { + "repo": "pulse", + "path": "scripts/installtests/build_release_assets_test.go", + "kind": "file", + "evidence_tier": "test-proof" + }, + { + "repo": "pulse", + "path": "scripts/release_control/release_promotion_policy_test.py", + "kind": "file", + "evidence_tier": "test-proof" + }, + { + "repo": "pulse", + "path": "scripts/release_control/resolve_release_promotion_test.py", + "kind": "file", + "evidence_tier": "test-proof" + } + ] + }, { "id": "pulse-intelligence-rg-01-rg-12", "summary": "The closed RG-01 through RG-12 Pulse Intelligence matrix reached independent GO for bounded limited autonomy at core a63b3eae2b5a62ee6803bfb4eee8fadbbba8e449 and Pulse Mobile a1ddb451618664025d22a986fbd3fd837e4ffe97. Exact-SHA unit/integration, Docker and Debian/Ubuntu Colima, clean Product Trust browser, and physical-iPad/live-Relay proof are sealed by /Volumes/Development/.pulse-proof-artifacts/rg12-a63b3eae-a1ddb451-final-20260712T234230Z with SHA256SUMS hash 58778244cb370be4bf7bd74a8316a5bf4e8ec4dca23f08e45c5bc8cfec152397 and aggregate evidence /Volumes/Development/.pulse-proof-artifacts/pulse-intelligence-evidence-a63b3eae2b5a62ee6803bfb4eee8fadbbba8e449-final.json. This gate does not certify arbitrary infrastructure mutation or general MSP-scale autonomy.", diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index b589f30175..f696fa23b8 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -29,20 +29,24 @@ The next-candidate release notes and changelog must also describe newly stable integration fields when external receivers need them to consume the release; packet proof keeps the customer-facing summary and detailed changelog aligned. -A published release candidate is a cohort checkpoint rather than a delivery -vehicle for each individual fix. After the first RC on a version line, public -RC publications on that line are separated by at least 24 hours of observation. +A published alpha, beta, or RC is a cohort checkpoint rather than a delivery +vehicle for each individual fix. Public checkpoints at the same maturity stage +on one version line are separated by at least 24 hours of observation. Compatible fixes and packet evidence may accumulate during that window, while draft creation and Release Dry Run remain available. Narrow reporter validation uses immutable issue-and-commit test images and does not force or bypass a new -public RC. +public preview. Beta is the normal user-validation stage. RC is reserved for a +build believed capable of becoming stable without product changes and must run +the stable-depth integration gate. -Release-note comparison ranges are channel-specific. Each RC compares against -the immediately preceding RC on the same version line, with RC1 comparing -against the previous stable release. A stable GA release compares against the -previous stable release, not the final RC, so it can retell the complete release -train as a small number of user-recognizable themes. Stable notes must synthesize -the final product outcomes instead of concatenating RC notes or enumerating the +Release-note comparison ranges are maturity-specific. Each prerelease compares +against the immediately preceding checkpoint at the same stage. A first beta +compares against the latest alpha when one exists, and a first RC compares +against the latest beta or alpha. The first alpha falls back to the previous +stable release. A stable GA release compares against the previous stable +release, not the final RC, so it can retell the complete release train as a +small number of user-recognizable themes. Stable notes must synthesize the final +product outcomes instead of concatenating prerelease notes or enumerating the underlying commits. Release-note synthesis is model-led. The harness supplies the comparison range, diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index a033f29585..b669a79b4b 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -278,6 +278,16 @@ The single-column narrow layout must keep labels, help, values, percent suffixes and the full scroll journey inside the app scroll shell without horizontal overflow. +The System Updates channel selector presents `Stable` and `Preview` as the two +operator choices. `Preview` is the user-facing umbrella for governed alpha, +beta, and release-candidate publications: its guidance must say that beta is +for user testing while product changes are still expected, and that an RC is +only appropriate when the build could become stable without product changes. +The historical `rc` settings value remains a compatibility identifier, not a +display label or permission to describe every preview as release-ready. +Automatic stable updates remain unavailable while Preview is selected, and +that manual-channel consequence must stay visible at desktop and narrow widths. + ## Canonical Files 1. `frontend-modern/src/components/shared/` diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index cd1f65ca9d..4ef6e8c6f2 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,27 +1,25 @@ { "version": 1, - "base_sha": "bad717f16eb7620dfd9c9f3f135be95f9a2f9642", - "verified_at": "2026-08-31T05:57:10Z", + "base_sha": "70c1d0a178f5c093a74c99000dc18e91d11e301f", + "verified_at": "2026-08-31T10:34:29Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts", - "frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts", - "frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx", - "frontend-modern/src/types/api.ts" + "frontend-modern/src/components/Settings/updatesSettingsModel.ts", + "frontend-modern/src/content/help/updates.ts", + "frontend-modern/src/utils/updatesPresentation.ts" ], "content_sha256": { - "frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts": "ba9e8a4eb3588bc7486d58f98342e9f5bf53a3f8f8247cb4c5597ebce31b41e5", - "frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts": "83fb8ae7e26900368b8413bcf771b2b44d3dc32134996db2ebe058378468521a", - "frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx": "254da9ce1fe77adf5c28d9c6f690cc0940d1236337e5d2f24acf810f07108eea", - "frontend-modern/src/types/api.ts": "e69bd7171e43f92d75e844cd138fbd2f7a11b24dea2cb3a118dfdfffa505fba1" + "frontend-modern/src/components/Settings/updatesSettingsModel.ts": "c574825033e3a90459b7acadd4bff81b109dfbdfa1e4e83ba58dc6a138471d06", + "frontend-modern/src/content/help/updates.ts": "c08f943d9f8db7b33d6f6ab5212d6d2dbe29eda07d07b4f2749426f3b03d6fb3", + "frontend-modern/src/utils/updatesPresentation.ts": "13a9254170cde398994443de7f1555e7120d4e5a1151ff76174b80d66a729f8c" }, "routes": [ - "/proxmox/backups/date" + "/settings/system-updates" ], "viewports": [ { "width": 1440, - "height": 1000 + "height": 900 }, { "width": 390, @@ -29,16 +27,17 @@ } ], "states": [ - "incomplete PBS snapshot with authoritative active writer rendered Running", - "terminally incomplete PBS snapshot with authoritative no-writer observation rendered Failed in danger tone", - "completed PBS snapshots retained Verified state below incomplete artifacts", - "failed/incomplete search result and restored unfiltered Running plus Failed results", - "full desktop and compact narrow recovery-table layouts with complete unclipped state badges" + "Stable selected with production-ready guidance and automatic stable updates available", + "Preview selected with beta-versus-release-candidate guidance and manual-channel notice", + "Preview selected with automatic stable updates disabled and the reason visible", + "saved Preview selection retained after a full browser reload", + "Stable and Preview cards, guidance, and update history visible at desktop and phone widths" ], "interactions": [ - "signed in to the final production build and navigated to the Proxmox By date backups route", - "scrolled the terminally incomplete artifact into view and inspected its Failed badge placement and clipping", - "entered failed incomplete in the backup search with the keyboard and verified only the failed artifact remained", - "selected and cleared the search with the keyboard and verified Running and Failed artifacts both returned" + "navigated from Settings to Pulse server updates", + "changed the release channel from Stable to Preview and saved it", + "reloaded the page and confirmed the saved Preview selection persisted", + "changed back to Stable and confirmed the stable controls and copy returned", + "verified the complete update-channel surface at desktop and 390px phone widths" ] } diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index c6928f1912..3a5cec67b4 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -40,6 +40,7 @@ import securityAuthPanelSource from '../SecurityAuthPanel.tsx?raw'; import securityOverviewPanelSource from '../SecurityOverviewPanel.tsx?raw'; import systemLogsPanelSource from '../SystemLogsPanel.tsx?raw'; import updatesSettingsPanelSource from '../UpdatesSettingsPanel.tsx?raw'; +import updatesSettingsModelSource from '../updatesSettingsModel.ts?raw'; import updateHistorySectionSource from '../UpdateHistorySection.tsx?raw'; import updateInstallGuideSource from '../UpdateInstallGuide.tsx?raw'; import updatesPresentationSource from '../../../utils/updatesPresentation.ts?raw'; @@ -169,6 +170,19 @@ describe('settings architecture guardrails', () => { expect(settingsDialogsSource).toContain('export const SettingsDialogs'); }); + it('keeps preview release maturity explicit in the shared update settings surface', () => { + expect(updatesSettingsModelSource).toContain("title: 'Preview'"); + expect(updatesSettingsModelSource).toContain( + 'Beta user-testing builds and release candidates for opt-in validation', + ); + expect(updatesPresentationSource).toContain( + 'Use beta builds for user testing and release candidates only when they may become stable without product changes.', + ); + expect(updatesPresentationSource).toContain( + 'Automatic stable updates are unavailable while the Preview channel is selected.', + ); + }); + it('keeps infrastructure onboarding query-backed under the shared settings shell', () => { expect(settingsHeaderMetaSource).toContain("'infrastructure-systems': {"); expect(settingsHeaderMetaSource).toContain( diff --git a/frontend-modern/src/components/Settings/__tests__/updatesSettingsModel.branchcov0712.test.ts b/frontend-modern/src/components/Settings/__tests__/updatesSettingsModel.branchcov0712.test.ts index 8e34f4895c..ef808ed5a4 100644 --- a/frontend-modern/src/components/Settings/__tests__/updatesSettingsModel.branchcov0712.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/updatesSettingsModel.branchcov0712.test.ts @@ -97,8 +97,8 @@ describe('getUpdateChannelCardOptions', () => { }, { value: 'rc', - title: 'Pre-release', - description: 'Early preview builds for staging, internal validation, and opt-in testers', + title: 'Preview', + description: 'Beta user-testing builds and release candidates for opt-in validation', tone: 'accent', disabled: undefined, }, @@ -128,7 +128,7 @@ describe('getUpdateChannelCardOptions', () => { expect(options[1]?.disabled).toBe(false); // Titles/descriptions are the constant copy. expect(options[0]?.title).toBe('Stable'); - expect(options[1]?.title).toBe('Pre-release'); + expect(options[1]?.title).toBe('Preview'); }); }); diff --git a/frontend-modern/src/components/Settings/updatesSettingsModel.ts b/frontend-modern/src/components/Settings/updatesSettingsModel.ts index f4aa73b907..4efc9ca918 100644 --- a/frontend-modern/src/components/Settings/updatesSettingsModel.ts +++ b/frontend-modern/src/components/Settings/updatesSettingsModel.ts @@ -38,8 +38,8 @@ export function getUpdateChannelCardOptions( }, { value: 'rc', - title: 'Pre-release', - description: 'Early preview builds for staging, internal validation, and opt-in testers', + title: 'Preview', + description: 'Beta user-testing builds and release candidates for opt-in validation', tone: 'accent', disabled: versionInfo?.isSourceBuild, }, diff --git a/frontend-modern/src/content/help/updates.ts b/frontend-modern/src/content/help/updates.ts index c66fab8f23..6ed80d3a34 100644 --- a/frontend-modern/src/content/help/updates.ts +++ b/frontend-modern/src/content/help/updates.ts @@ -28,11 +28,14 @@ export const updatesHelpContent: HelpContent[] = [ description: 'Choose which Pulse releases to be notified about:\n\n' + '- Stable: Production-ready releases only\n' + - '- Pre-release: Early preview builds before stable release\n\n' + - 'Pre-release builds can contain bugs, regressions, and migration issues. Use stable for production environments.\n\n' + - 'For major version pre-releases (e.g. v6.0.0-rc.1), we strongly recommend installing ' + + '- Preview: Beta user-testing builds and release candidates\n\n' + + 'Beta builds are expected to change after user feedback. A release candidate is a build that may become stable without product changes. Both can contain bugs, regressions, and migration issues. Use stable for production environments.\n\n' + + 'For major version previews (e.g. v6.5.0-beta.1), we strongly recommend installing ' + 'as a separate instance rather than upgrading your production installation.', - examples: ['stable - v6.1.1, v6.1.2, etc.', 'pre-release - v6.2.0-rc.1, v6.2.0-rc.2, etc.'], + examples: [ + 'stable - v6.4.0, v6.4.1, etc.', + 'preview - v6.5.0-beta.1, then v6.5.0-rc.1 when it is a release candidate', + ], addedInVersion: 'v4.0.0', }, { diff --git a/frontend-modern/src/utils/__tests__/updatesPresentation.test.ts b/frontend-modern/src/utils/__tests__/updatesPresentation.test.ts index db0f884675..79d86f77b6 100644 --- a/frontend-modern/src/utils/__tests__/updatesPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/updatesPresentation.test.ts @@ -29,12 +29,12 @@ describe('updatesPresentation', () => { updatePreferencesTitle: 'Update Preferences', autoUpdateTitle: 'Automatic Stable Updates', autoUpdateDescription: - 'Supported host installs can automatically apply stable releases. Pre-release testing always stays manual.', - previewChannelTitle: 'Pre-release builds stay on a manual preview channel.', + 'Supported host installs can automatically apply stable releases. Preview testing always stays manual.', + previewChannelTitle: 'Preview builds stay on a manual channel.', previewChannelDescription: - 'Use this on staging or internal validation environments. Automatic stable updates stay disabled on pre-release builds so preview installs do not drift between channels unattended.', + 'Use beta builds for user testing and release candidates only when they may become stable without product changes. Keep preview installs on staging or internal validation environments.', previewChannelAutoUpdateNotice: - 'Automatic stable updates are unavailable while the pre-release preview channel is selected.', + 'Automatic stable updates are unavailable while the Preview channel is selected.', autoUpdateScheduleNote: 'The host checks for stable releases once a day overnight and applies them automatically.', }); diff --git a/frontend-modern/src/utils/updatesPresentation.ts b/frontend-modern/src/utils/updatesPresentation.ts index dd9a6d34a1..2967c2456c 100644 --- a/frontend-modern/src/utils/updatesPresentation.ts +++ b/frontend-modern/src/utils/updatesPresentation.ts @@ -16,12 +16,12 @@ export const UPDATES_PANEL_COPY = { updatePreferencesTitle: 'Update Preferences', autoUpdateTitle: 'Automatic Stable Updates', autoUpdateDescription: - 'Supported host installs can automatically apply stable releases. Pre-release testing always stays manual.', - previewChannelTitle: 'Pre-release builds stay on a manual preview channel.', + 'Supported host installs can automatically apply stable releases. Preview testing always stays manual.', + previewChannelTitle: 'Preview builds stay on a manual channel.', previewChannelDescription: - 'Use this on staging or internal validation environments. Automatic stable updates stay disabled on pre-release builds so preview installs do not drift between channels unattended.', + 'Use beta builds for user testing and release candidates only when they may become stable without product changes. Keep preview installs on staging or internal validation environments.', previewChannelAutoUpdateNotice: - 'Automatic stable updates are unavailable while the pre-release preview channel is selected.', + 'Automatic stable updates are unavailable while the Preview channel is selected.', // The host systemd timer owns the schedule (daily overnight window with a // randomized delay, see install.sh); it is not configurable from the UI. autoUpdateScheduleNote: diff --git a/internal/updates/manager.go b/internal/updates/manager.go index 6bb60d23be..e5989ee0fc 100644 --- a/internal/updates/manager.go +++ b/internal/updates/manager.go @@ -927,7 +927,8 @@ func (m *Manager) getLatestReleaseForChannel(ctx context.Context, channel string // list position: GitHub returns releases sorted by created_at, and v5-line // maintenance releases interleave with v6 releases in the same repo, so // the most recently published stable is not necessarily the highest one. - // Prerelease channel: newest release (prerelease or stable), even if not newer than current + // Preview channel: newest published alpha, beta, RC, or stable release, even + // if not newer than current. The historical "rc" value remains the wire key. // Stable channel: newest stable release, even if not newer than current // The caller will determine if it's actually an update by comparing versions var newestRC, newestStable *ReleaseInfo @@ -962,9 +963,9 @@ func (m *Manager) getLatestReleaseForChannel(ctx context.Context, channel string } if channel == "rc" { - // For the prerelease channel: return the highest version among both - // candidates, so prerelease users are moved forward onto stable once - // the rc line lands (6.0.5 > 6.0.0-rc.7). + // For the preview channel: return the highest version among both + // candidates, so preview users are moved forward onto stable once the + // prerelease line lands (6.0.5 > 6.0.0-rc.7). if newestStable != nil && (newestRC == nil || newestStableVer.IsNewerThan(newestRCVer)) { if newestStableVer.IsNewerThan(currentVer) { log.Info().Str("version", newestStable.TagName).Msg("Found stable update for prerelease user") diff --git a/internal/updates/version.go b/internal/updates/version.go index 04b907ce6d..b0706a5204 100644 --- a/internal/updates/version.go +++ b/internal/updates/version.go @@ -16,9 +16,10 @@ import ( // Pre-compiled regexes for performance (avoid recompilation on each call) var ( - semverRe = regexp.MustCompile(`^(\d+)\.(\d+)\.(\d+)(?:-([^+]+))?(?:\+(.+))?$`) - rcNumRe = regexp.MustCompile(`rc\.?(\d+)`) - gitHashRe = regexp.MustCompile(`^([0-9a-fA-F]{7,})(-dirty)?$`) + semverRe = regexp.MustCompile(`^(\d+)\.(\d+)\.(\d+)(?:-([^+]+))?(?:\+(.+))?$`) + rcNumRe = regexp.MustCompile(`rc\.?(\d+)`) + publishedPreviewRe = regexp.MustCompile(`^(alpha|beta|rc)\.([1-9][0-9]*)$`) + gitHashRe = regexp.MustCompile(`^([0-9a-fA-F]{7,})(-dirty)?$`) ) // BuildVersion is the version string injected at build time via ldflags. @@ -42,6 +43,24 @@ func (v *Version) IsRCPrerelease() bool { return rcNumRe.MatchString(strings.ToLower(strings.TrimSpace(v.Prerelease))) } +// PreviewStage returns the governed published prerelease stage. The update +// channel retains the historical "rc" wire value for compatibility, but it +// carries alpha, beta, and release-candidate builds. +func (v *Version) PreviewStage() string { + if v == nil { + return "" + } + matches := publishedPreviewRe.FindStringSubmatch(strings.ToLower(strings.TrimSpace(v.Prerelease))) + if len(matches) == 0 { + return "" + } + return matches[1] +} + +func (v *Version) IsPreviewPrerelease() bool { + return v.PreviewStage() != "" +} + func (v *Version) IsPublishedReleaseAssetVersion() bool { if v == nil { return false @@ -49,7 +68,7 @@ func (v *Version) IsPublishedReleaseAssetVersion() bool { if v.Build != "" { return false } - return v.Prerelease == "" || v.IsRCPrerelease() + return v.Prerelease == "" || v.IsPreviewPrerelease() } // VersionInfo contains detailed version information @@ -137,18 +156,65 @@ func (v *Version) Compare(other *Version) int { return -1 // v is prerelease, other is release } if v.Prerelease != other.Prerelease { - // Try to parse RC numbers for comparison - vRC := extractRCNumber(v.Prerelease) - otherRC := extractRCNumber(other.Prerelease) - if vRC >= 0 && otherRC >= 0 { - return compareInts(vRC, otherRC) - } - return strings.Compare(v.Prerelease, other.Prerelease) + return comparePrerelease(v.Prerelease, other.Prerelease) } return 0 } +func comparePrerelease(left string, right string) int { + leftIdentifiers := strings.Split(left, ".") + rightIdentifiers := strings.Split(right, ".") + limit := min(len(leftIdentifiers), len(rightIdentifiers)) + + for index := 0; index < limit; index++ { + leftIdentifier := leftIdentifiers[index] + rightIdentifier := rightIdentifiers[index] + leftNumber, leftNumeric := numericPrereleaseIdentifier(leftIdentifier) + rightNumber, rightNumeric := numericPrereleaseIdentifier(rightIdentifier) + + switch { + case leftNumeric && rightNumeric: + if comparison := compareNumericPrereleaseIdentifiers(leftNumber, rightNumber); comparison != 0 { + return comparison + } + case leftNumeric: + return -1 + case rightNumeric: + return 1 + default: + if comparison := strings.Compare(leftIdentifier, rightIdentifier); comparison != 0 { + return comparison + } + } + } + + return compareInts(len(leftIdentifiers), len(rightIdentifiers)) +} + +func numericPrereleaseIdentifier(identifier string) (string, bool) { + if identifier == "" { + return "", false + } + for _, character := range identifier { + if character < '0' || character > '9' { + return "", false + } + } + normalized := strings.TrimLeft(identifier, "0") + if normalized == "" { + normalized = "0" + } + return normalized, true +} + +func compareNumericPrereleaseIdentifiers(left string, right string) int { + if len(left) != len(right) { + return compareInts(len(left), len(right)) + } + return strings.Compare(left, right) +} + // IsNewerThan returns true if v is newer than other func (v *Version) IsNewerThan(other *Version) bool { return v.Compare(other) > 0 @@ -230,8 +296,7 @@ func DescribeUsageDataVersion(raw string) UsageDataVersionIdentity { identity.Build = parsed.Build identity.Channel = usageDataVersionChannel(parsed) identity.IsDevelopment = identity.Channel == "dev" - identity.IsPublishedRelease = (identity.Channel == "stable" || identity.Channel == "rc") && - parsed.IsPublishedReleaseAssetVersion() + identity.IsPublishedRelease = parsed.IsPublishedReleaseAssetVersion() return identity } @@ -358,7 +423,7 @@ func sanitizePrereleaseIdentifier(raw string) string { func detectChannelFromVersion(version string) string { trimmed := strings.TrimSpace(version) if parsed, err := ParseVersion(trimmed); err == nil { - if parsed.IsRCPrerelease() { + if parsed.IsPreviewPrerelease() { return "rc" } return "stable" @@ -373,8 +438,8 @@ func usageDataVersionChannel(version *Version) string { switch { case version.Build != "": return "dev" - case version.IsRCPrerelease(): - return "rc" + case version.IsPreviewPrerelease(): + return version.PreviewStage() case version.Prerelease == "": return "stable" case strings.EqualFold(version.Prerelease, "dev"), strings.HasPrefix(strings.ToLower(version.Prerelease), "dev."): diff --git a/internal/updates/version_test.go b/internal/updates/version_test.go index 5ea4feaf6c..769d32b50f 100644 --- a/internal/updates/version_test.go +++ b/internal/updates/version_test.go @@ -146,6 +146,12 @@ func TestDetectChannelFromVersion(t *testing.T) { if got := detectChannelFromVersion("4.24.0-rc.3"); got != "rc" { t.Fatalf("detectChannelFromVersion rc = %s, expected rc", got) } + if got := detectChannelFromVersion("6.5.0-beta.2"); got != "rc" { + t.Fatalf("detectChannelFromVersion beta = %s, expected preview wire channel rc", got) + } + if got := detectChannelFromVersion("6.5.0-alpha.1"); got != "rc" { + t.Fatalf("detectChannelFromVersion alpha = %s, expected preview wire channel rc", got) + } if got := detectChannelFromVersion("6.0.0-dev+git.45.gabcdef"); got != "stable" { t.Fatalf("detectChannelFromVersion dev = %s, expected stable", got) @@ -205,6 +211,8 @@ func TestVersionIsPublishedReleaseAssetVersion(t *testing.T) { expected bool }{ {name: "stable release", version: "6.0.0", expected: true}, + {name: "alpha prerelease", version: "6.5.0-alpha.1", expected: true}, + {name: "beta prerelease", version: "6.5.0-beta.2", expected: true}, {name: "rc prerelease", version: "6.0.0-rc.1", expected: true}, {name: "dev prerelease", version: "6.0.0-dev", expected: false}, {name: "build metadata", version: "6.0.0+git.1.gabcdef", expected: false}, @@ -289,6 +297,14 @@ func TestDescribeUsageDataVersion(t *testing.T) { wantDevelopment bool wantPublished bool }{ + { + name: "published beta keeps its maturity stage", + input: "v6.5.0-beta.2", + wantVersion: "6.5.0-beta.2", + wantRaw: "v6.5.0-beta.2", + wantChannel: "beta", + wantPublished: true, + }, { name: "published rc strips accidental v prefix", input: "v6.0.0-rc.1", @@ -453,6 +469,18 @@ func TestVersionCompare(t *testing.T) { v2: Version{Major: 4, Minor: 24, Patch: 0, Prerelease: "alpha"}, expected: 1, // "beta" > "alpha" lexically }, + { + name: "beta sequence uses numeric SemVer precedence", + v1: Version{Major: 6, Minor: 5, Patch: 0, Prerelease: "beta.10"}, + v2: Version{Major: 6, Minor: 5, Patch: 0, Prerelease: "beta.2"}, + expected: 1, + }, + { + name: "release candidate follows beta", + v1: Version{Major: 6, Minor: 5, Patch: 0, Prerelease: "rc.1"}, + v2: Version{Major: 6, Minor: 5, Patch: 0, Prerelease: "beta.12"}, + expected: 1, + }, } for _, tc := range tests { diff --git a/scripts/generate-release-notes.sh b/scripts/generate-release-notes.sh index 727200a178..a5ea317a47 100755 --- a/scripts/generate-release-notes.sh +++ b/scripts/generate-release-notes.sh @@ -44,7 +44,7 @@ if [ -z "$VERSION" ]; then echo "Usage: $0 [comparison-tag]" >&2 echo " $0 --resolve-base " >&2 echo " $0 --visual-plan [comparison-tag]" >&2 - echo "Example: $0 6.4.0-rc.6" >&2 + echo "Example: $0 6.5.0-beta.1" >&2 exit 1 fi @@ -71,22 +71,38 @@ latest_stable_before() { resolve_comparison_tag() { local version=$1 - local base rc expected + local base stage sequence expected candidate prior_stage - if [[ "$version" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-rc\.([0-9]+)$ ]]; then + if [[ "$version" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-(alpha|beta|rc)\.([1-9][0-9]*)$ ]]; then base=${BASH_REMATCH[1]} - rc=${BASH_REMATCH[2]} - if (( rc > 1 )); then - expected="v${base}-rc.$((rc - 1))" + stage=${BASH_REMATCH[2]} + sequence=${BASH_REMATCH[3]} + if (( sequence > 1 )); then + expected="v${base}-${stage}.$((sequence - 1))" if ! git merge-base --is-ancestor "$expected" HEAD 2>/dev/null; then - echo "Expected immediately preceding RC tag '$expected' is not an ancestor of HEAD" >&2 + echo "Expected immediately preceding ${stage} tag '$expected' is not an ancestor of HEAD" >&2 return 1 fi printf '%s\n' "$expected" return fi + + case "$stage" in + beta) prior_stage=alpha ;; + rc) prior_stage='beta|alpha' ;; + alpha) prior_stage='' ;; + esac + if [ -n "$prior_stage" ]; then + while IFS= read -r candidate; do + if [[ "$candidate" =~ ^v${base}-(${prior_stage})\.[1-9][0-9]*$ ]] && \ + git merge-base --is-ancestor "$candidate" HEAD 2>/dev/null; then + printf '%s\n' "$candidate" + return + fi + done < <(git tag --list "v${base}-*" --sort=-version:refname) + fi elif [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Unsupported release version '$version'; expected X.Y.Z or X.Y.Z-rc.N" >&2 + echo "Unsupported release version '$version'; expected X.Y.Z or X.Y.Z-(alpha|beta|rc).N" >&2 return 1 fi @@ -116,10 +132,11 @@ if [ "$MODE" = "resolve-base" ]; then exit 0 fi -if [[ "$VERSION" == *-rc.* ]]; then - RELEASE_RANGE_GUIDANCE="This RC covers ${PREVIOUS_TAG}..HEAD." +if [[ "$VERSION" =~ -((alpha|beta|rc))\. ]]; then + RELEASE_STAGE=${BASH_REMATCH[1]} + RELEASE_RANGE_GUIDANCE="This ${RELEASE_STAGE} preview covers ${PREVIOUS_TAG}..HEAD." else - RELEASE_RANGE_GUIDANCE="This stable release covers ${PREVIOUS_TAG}..HEAD. The same-version RC packets under docs/releases/ are available evidence." + RELEASE_RANGE_GUIDANCE="This stable release covers ${PREVIOUS_TAG}..HEAD. The same-version prerelease packets under docs/releases/ are available evidence." fi if [ "$MODE" = "generate" ]; then diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 45912d077e..2914c26465 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -3792,6 +3792,14 @@ func TestReleaseNotesGeneratorResolvesChannelSpecificComparisonRanges(t *testing commit("stable 6.3.2 hotfix") runGit("tag", "v6.3.2") runGit("checkout", "main") + commit("alpha 1") + runGit("tag", "v6.4.0-alpha.1") + commit("alpha 2") + runGit("tag", "v6.4.0-alpha.2") + commit("beta 1") + runGit("tag", "v6.4.0-beta.1") + commit("beta 2") + runGit("tag", "v6.4.0-beta.2") for rc := 1; rc <= 10; rc++ { commit("release candidate " + strconv.Itoa(rc)) runGit("tag", "v6.4.0-rc."+strconv.Itoa(rc)) @@ -3850,8 +3858,20 @@ func TestReleaseNotesGeneratorResolvesChannelSpecificComparisonRanges(t *testing if got := resolve("6.4.0-rc.11"); got != "v6.4.0-rc.10" { t.Fatalf("RC comparison base = %q, want v6.4.0-rc.10", got) } - if got := resolve("6.4.0-rc.1"); got != "v6.3.2" { - t.Fatalf("RC1 comparison base = %q, want v6.3.2", got) + if got := resolve("6.4.0-alpha.1"); got != "v6.3.2" { + t.Fatalf("alpha.1 comparison base = %q, want v6.3.2", got) + } + if got := resolve("6.4.0-alpha.2"); got != "v6.4.0-alpha.1" { + t.Fatalf("alpha.2 comparison base = %q, want v6.4.0-alpha.1", got) + } + if got := resolve("6.4.0-beta.1"); got != "v6.4.0-alpha.2" { + t.Fatalf("beta.1 comparison base = %q, want v6.4.0-alpha.2", got) + } + if got := resolve("6.4.0-beta.2"); got != "v6.4.0-beta.1" { + t.Fatalf("beta.2 comparison base = %q, want v6.4.0-beta.1", got) + } + if got := resolve("6.4.0-rc.1"); got != "v6.4.0-beta.2" { + t.Fatalf("rc.1 comparison base = %q, want v6.4.0-beta.2", got) } if got := resolve("6.4.0"); got != "v6.3.2" { t.Fatalf("GA comparison base = %q, want v6.3.2", got) diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index cada48c400..a4212e3548 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -924,7 +924,9 @@ class ReleasePromotionPolicyTest(unittest.TestCase): def test_release_promotion_policy_requires_live_rc_and_v5_policy(self) -> None: content = read("docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md") - self.assertIn("Every candidate intended for broad customer use must ship to `rc`", content) + self.assertIn("`beta.N` is the normal user-testing stage", content) + self.assertIn("`rc.N` is reserved for a build the release owner believes", content) + self.assertIn("Stable promotion lineage must come from a published `rc.N`", content) self.assertIn("live run of the release pipeline for the prerelease tag itself", content) self.assertIn("an accidental git tag by itself", content) self.assertIn("does not count as a shipped prerelease", content) @@ -936,7 +938,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("Exact v6 GA and v5 end-of-support dates locked before GA publish", content) self.assertIn("governed prerelease and stable release branches", content) self.assertIn("Customer-facing private Pulse Pro archives", content) - self.assertIn("public RC tag", content) + self.assertIn("public alpha, beta, or RC tag", content) self.assertIn("license.pulserelay.pro/pulse-pro:6.0.0", content) self.assertIn("moving branch", content) self.assertIn("`implemented`", content) @@ -1489,12 +1491,18 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn('git fetch --prune origin main "${REQUIRED_BRANCH}" --tags', content) self.assertIn('REQUIRED_BRANCH: ${{ steps.branch_policy.outputs.required_branch }}', content) self.assertIn("resolve_release_promotion.py", content) + self.assertIn("release_stage: ${{ steps.promotion.outputs.release_stage }}", content) self.assertIn("--enforce-prerelease-observation-window", content) self.assertIn("DRAFT_ONLY_INPUT", content) self.assertIn("--enforce-prerelease-observation-window", helper) self.assertNotIn("--enforce-prerelease-observation-window", dry_run_workflow) self.assertNotIn("--enforce-prerelease-observation-window", dry_run_helper) self.assertIn("render_release_body.py", content) + self.assertIn('--promotion-channel "${{ needs.prepare.outputs.release_stage }}"', content) + self.assertIn( + "needs.prepare.outputs.release_stage != 'alpha' && needs.prepare.outputs.release_stage != 'beta'", + content, + ) self.assertIn("build_rollback_section", renderer) self.assertIn("uses: ./.github/workflows/publish-docker.yml", content) self.assertIn("release-convergence.yml/dispatches", content) @@ -1515,10 +1523,10 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("hours of prerelease soak", resolver) self.assertIn("minimum is 72 hours unless hotfix_exception is true", resolver) self.assertIn("MIN_PRERELEASE_OBSERVATION_HOURS = 24", resolver) - self.assertIn("cohort checkpoint, not a delivery vehicle", policy) + 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)) self.assertIn( - "public RC publications on that line are separated by at least 24 hours", + "Public checkpoints at the same maturity stage on one version line are separated by at least 24 hours", normalize_ws(contract), ) self.assertIn("build_rollback_section", renderer) @@ -2176,6 +2184,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("Assemble exact-candidate runtime and agent images", qualifier_workflow) self.assertIn('kind load docker-image "${SMOKE_IMAGE_REPOSITORY}:${SMOKE_IMAGE_TAG}" --name pulse-test', qualifier_workflow) self.assertIn('--set image.repository="${SMOKE_IMAGE_REPOSITORY}"', qualifier_workflow) + self.assertIn('--set image.pullPolicy=Never', qualifier_workflow) self.assertNotIn("needs.docker_build.result", release_workflow) self.assertNotIn("needs.helm_smoke.result", release_workflow) diff --git a/scripts/release_control/resolve_release_promotion.py b/scripts/release_control/resolve_release_promotion.py index bc72ff5dbb..2be08882fa 100644 --- a/scripts/release_control/resolve_release_promotion.py +++ b/scripts/release_control/resolve_release_promotion.py @@ -19,6 +19,9 @@ 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.-]+)?$") +SEMVER_PUBLISHED_PRERELEASE_RE = re.compile( + r"^(\d+)\.(\d+)\.(\d+)-(alpha|beta|rc)\.([1-9]\d*)$" +) SEMVER_RC_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)-rc\.(\d+)$") MIN_PRERELEASE_OBSERVATION_HOURS = 24 WINDOWS_AUTHENTICODE_AVAILABLE = False @@ -100,6 +103,21 @@ def is_prerelease_version(version: str) -> bool: return bool(SEMVER_PRERELEASE_RE.search(version)) +def release_stage(version: str) -> str: + """Return the governed publication stage for a release version.""" + + normalized = (version or "").strip().removeprefix("v") + if SEMVER_STABLE_RE.fullmatch(normalized): + return "stable" + match = SEMVER_PUBLISHED_PRERELEASE_RE.fullmatch(normalized) + if match: + return match.group(4) + raise ValueError( + f"Unsupported release version {version!r}. Published versions must use " + "X.Y.Z, X.Y.Z-alpha.N, X.Y.Z-beta.N, or X.Y.Z-rc.N." + ) + + def is_stable_patch_version(version: str) -> bool: match = SEMVER_STABLE_RE.match(version) return bool(match and int(match.group(3)) > 0) @@ -209,21 +227,26 @@ def list_published_prereleases() -> list[tuple[str, int]]: return published -def latest_same_version_rc_publication( +def latest_same_stage_prerelease_publication( version: str, candidate_tag: str, published_prereleases: list[tuple[str, int]], ) -> tuple[str, int] | None: - candidate_match = SEMVER_RC_RE.match(version) + candidate_match = SEMVER_PUBLISHED_PRERELEASE_RE.match(version) if not candidate_match: return None version_base = candidate_match.groups()[:3] + candidate_stage = candidate_match.group(4) matches: list[tuple[str, int]] = [] for release_tag, published_unix in published_prereleases: - release_match = re.match(r"^v(\d+)\.(\d+)\.(\d+)-rc\.(\d+)$", release_tag) + release_match = re.match( + r"^v(\d+)\.(\d+)\.(\d+)-(alpha|beta|rc)\.([1-9]\d*)$", + release_tag, + ) if ( release_match and release_match.groups()[:3] == version_base + and release_match.group(4) == candidate_stage and release_tag != candidate_tag ): matches.append((release_tag, published_unix)) @@ -297,6 +320,7 @@ def resolve_metadata( tag_created_unix_fn: Callable[[str], int] = tag_created_unix, now_unix_fn: Callable[[], int] = lambda: int(time.time()), ) -> dict[str, str]: + stage = release_stage(version) tag = normalize_tag(version) rollback_tag = normalize_tag(rollback_version_input) ga_date = (ga_date_input or "").strip() @@ -304,7 +328,7 @@ def resolve_metadata( hotfix_reason = normalize_whitespace(hotfix_reason_input) unsigned_windows_reason = normalize_whitespace(unsigned_windows_reason_input) release_notes = release_notes_input or "" - is_prerelease = is_prerelease_version(version) + is_prerelease = stage != "stable" stable_patch = is_stable_patch_version(version) promotion_mode = "prerelease" if is_prerelease else "stable-rc-promotion" stable_version_match = SEMVER_STABLE_RE.match(version) @@ -373,7 +397,7 @@ def resolve_metadata( if hotfix_exception: raise ValueError("hotfix_exception applies only to stable promotions.") if enforce_prerelease_observation_window: - previous_publication = latest_same_version_rc_publication( + previous_publication = latest_same_stage_prerelease_publication( version, tag, list_published_prereleases_fn(), @@ -391,8 +415,8 @@ def resolve_metadata( ).isoformat().replace("+00:00", "Z") raise ValueError( f"Prerelease {tag} would replace {previous_prerelease_tag} after only " - f"{observation_hours} hours of public observation. Same-version release " - f"candidates require {MIN_PRERELEASE_OBSERVATION_HOURS} hours between " + f"{observation_hours} hours of public observation. Same-version {stage} " + f"checkpoints require {MIN_PRERELEASE_OBSERVATION_HOURS} hours between " f"publications. Accumulate fixes or use an issue-scoped reporter test image " f"until {next_publish_at}." ) @@ -501,6 +525,7 @@ def resolve_metadata( ) return { + "release_stage": stage, "promotion_mode": promotion_mode, "is_stable_patch": "true" if stable_patch else "false", "promoted_from_tag": promoted_from_tag, @@ -542,7 +567,10 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "--enforce-prerelease-observation-window", action="store_true", - help="Reject public same-version RC publication less than 24 hours after the prior RC.", + help=( + "Reject a public alpha, beta, or RC publication less than 24 hours after " + "the prior same-version checkpoint at the same maturity stage." + ), ) return parser.parse_args() diff --git a/scripts/release_control/resolve_release_promotion_test.py b/scripts/release_control/resolve_release_promotion_test.py index f8517fb40d..e3dcb7bab2 100644 --- a/scripts/release_control/resolve_release_promotion_test.py +++ b/scripts/release_control/resolve_release_promotion_test.py @@ -12,21 +12,20 @@ REPO_ROOT = Path(__file__).resolve().parents[2] class ResolveReleasePromotionTest(unittest.TestCase): - def test_dev_prerelease_uses_prerelease_path(self) -> None: - metadata = resolver.resolve_metadata( - version="6.0.0-dev", - promoted_from_tag_input="", - rollback_version_input="5.1.14", - ga_date_input="", - v5_eos_date_input="", - hotfix_exception=False, - hotfix_reason_input="", - release_notes_input="", - tag_exists_fn=lambda tag: tag == "v5.1.14", - ) - self.assertEqual(metadata["rollback_tag"], "v5.1.14") - self.assertEqual(metadata["promoted_from_tag"], "") - self.assertEqual(metadata["soak_hours"], "") + def test_published_release_versions_use_explicit_maturity_stages(self) -> None: + for version, expected in ( + ("6.5.0-alpha.1", "alpha"), + ("6.5.0-beta.2", "beta"), + ("6.5.0-rc.3", "rc"), + ("6.5.0", "stable"), + ): + with self.subTest(version=version): + self.assertEqual(resolver.release_stage(version), expected) + + for unsupported in ("6.5.0-dev", "6.5.0-preview.1", "6.5.0-beta.0"): + with self.subTest(unsupported=unsupported): + with self.assertRaisesRegex(ValueError, "Unsupported release version"): + resolver.release_stage(unsupported) def test_prerelease_requires_explicit_stable_rollback(self) -> None: metadata = resolver.resolve_metadata( @@ -44,6 +43,23 @@ class ResolveReleasePromotionTest(unittest.TestCase): self.assertEqual(metadata["rollback_command"], "sudo /bin/update --version v5.1.14") self.assertEqual(metadata["promoted_from_tag"], "") self.assertEqual(metadata["soak_hours"], "") + self.assertEqual(metadata["release_stage"], "rc") + + def test_beta_uses_the_published_prerelease_path(self) -> None: + metadata = resolver.resolve_metadata( + version="6.5.0-beta.1", + promoted_from_tag_input="", + rollback_version_input="6.4.1", + ga_date_input="", + v5_eos_date_input="", + hotfix_exception=False, + hotfix_reason_input="", + release_notes_input="", + tag_exists_fn=lambda tag: tag == "v6.4.1", + ) + self.assertEqual(metadata["release_stage"], "beta") + self.assertEqual(metadata["promotion_mode"], "prerelease") + self.assertEqual(metadata["promoted_from_tag"], "") def test_first_rc_has_no_observation_window_to_wait_for(self) -> None: metadata = resolver.resolve_metadata( @@ -110,6 +126,27 @@ class ResolveReleasePromotionTest(unittest.TestCase): self.assertEqual(metadata["previous_prerelease_tag"], "v6.4.0-rc.12") self.assertEqual(metadata["prerelease_observation_hours"], "24") + def test_observation_window_is_scoped_to_the_same_maturity_stage(self) -> None: + metadata = resolver.resolve_metadata( + version="6.5.0-rc.1", + promoted_from_tag_input="", + rollback_version_input="6.4.1", + ga_date_input="", + v5_eos_date_input="", + hotfix_exception=False, + hotfix_reason_input="", + release_notes_input="", + enforce_prerelease_observation_window=True, + list_published_prereleases_fn=lambda: [ + ("v6.5.0-alpha.2", 100), + ("v6.5.0-beta.4", 200), + ], + tag_exists_fn=lambda tag: tag == "v6.4.1", + now_unix_fn=lambda: 201, + ) + self.assertEqual(metadata["release_stage"], "rc") + self.assertEqual(metadata["previous_prerelease_tag"], "") + def test_rehearsal_does_not_query_or_enforce_publication_window(self) -> None: metadata = resolver.resolve_metadata( version="6.4.0-rc.13", diff --git a/scripts/trigger-release-dry-run.sh b/scripts/trigger-release-dry-run.sh index 5f553f5cb6..a62844317d 100755 --- a/scripts/trigger-release-dry-run.sh +++ b/scripts/trigger-release-dry-run.sh @@ -17,10 +17,23 @@ echo "Pre-flight checks for Release Dry Run v${VERSION}..." echo "" IS_PRERELEASE="false" -if [[ "$VERSION" =~ -rc\.[0-9]+$ ]] || [[ "$VERSION" =~ -alpha\.[0-9]+$ ]] || [[ "$VERSION" =~ -beta\.[0-9]+$ ]]; then +RELEASE_STAGE="stable" +if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc)\.([1-9][0-9]*)$ ]]; then IS_PRERELEASE="true" + RELEASE_STAGE="${BASH_REMATCH[1]}" +elif [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "❌ Unsupported release version: ${VERSION}" + echo "Use X.Y.Z, X.Y.Z-alpha.N, X.Y.Z-beta.N, or X.Y.Z-rc.N." + exit 1 fi +case "$RELEASE_STAGE" in + alpha) echo "Maturity: alpha (incomplete internal evaluation build)" ;; + beta) echo "Maturity: beta (user-testing build that is not yet release-ready)" ;; + rc) echo "Maturity: release candidate (believed capable of becoming stable without product changes)" ;; + stable) echo "Maturity: stable (general production release)" ;; +esac + FILE_VERSION=$(cat VERSION | tr -d '\n') if [ "$FILE_VERSION" != "$VERSION" ]; then echo "❌ VERSION file mismatch" diff --git a/scripts/trigger-release.sh b/scripts/trigger-release.sh index 49d35499d2..0d6acd7f48 100755 --- a/scripts/trigger-release.sh +++ b/scripts/trigger-release.sh @@ -18,10 +18,37 @@ echo "Pre-flight checks for release v${VERSION}..." echo "" IS_PRERELEASE="false" -if [[ "$VERSION" =~ -rc\.[0-9]+$ ]] || [[ "$VERSION" =~ -alpha\.[0-9]+$ ]] || [[ "$VERSION" =~ -beta\.[0-9]+$ ]]; then +RELEASE_STAGE="stable" +if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc)\.([1-9][0-9]*)$ ]]; then IS_PRERELEASE="true" + RELEASE_STAGE="${BASH_REMATCH[1]}" +elif [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "❌ Unsupported release version: ${VERSION}" + echo "Use X.Y.Z, X.Y.Z-alpha.N, X.Y.Z-beta.N, or X.Y.Z-rc.N." + exit 1 fi +case "$RELEASE_STAGE" in + alpha) + echo "Maturity: alpha (incomplete internal evaluation build)" + ;; + beta) + echo "Maturity: beta (user-testing build that is not yet release-ready)" + ;; + rc) + echo "Maturity: release candidate (believed capable of becoming stable without product changes)" + echo "RC publication runs the stable-depth integration gate." + read -r -p "Confirm this build is genuinely a release candidate [y/N]: " RC_CONFIRMATION + if [[ ! "$RC_CONFIRMATION" =~ ^[Yy]$ ]]; then + echo "Release cancelled. Use a beta version while further product changes are expected." + exit 1 + fi + ;; + stable) + echo "Maturity: stable (general production release)" + ;; +esac + # Check 1: VERSION file matches FILE_VERSION=$(cat VERSION | tr -d '\n') if [ "$FILE_VERSION" != "$VERSION" ]; then