mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
Describe releases as the fixed release train everywhere (#2565)
## Why Releases have run on a fixed train since 4 October 2026, but most release documentation still described the model it replaced. Readers and agents got different answers depending on which page they read. ## What changes - **Shipped docs** (`docs/` and the `frontend-modern/public/docs/` mirror): - `RELEASE_PROCESS.md` describes the train: a cut from `main` 14 days after the last stable (only when `main` has changed), or earlier on request; a 24-hour Preview soak; promotion of the unchanged candidate. - Consecutive minor versions, with no scheduled patch releases. - A `release-blocker` issue is closed once its fix is on `main`, and closing the last one cuts a fresh candidate. - `AI_TRANSPARENCY.md` states the same rules and says no model chooses release scope or timing. - **Promotion policy:** a new **Release Train** section is the single statement of the rules (cut, versions, soak, promote, stop, failures, workflow checks), checked against the train's implementation. It covers: - when an early-cut request takes effect; - `rc.N` numbering; - the 72-hour held alert; - the Pro source-pair failure; - the 12-hour retry. The earlier release models move under History. The stable patch path stays because the workflow still supports it, but nothing schedules it. - **Checklist, source of truth, development protocol, control plane, workflows README, RC-to-GA template and the deployment-installability contract:** now consistent with that section. - No 72-hour soak, no steward admission, no severity-scheduled patches. - v6 resolves to `main`, and only 6.3.2 and 6.4.3 to 6.4.6 stay bound to release lines. - Helm charts are packaged from the validated release tag. - The stable patch dry run is optional, as the policy already said. - **Workflow comments:** `release-signing-preflight.yml` no longer claims a nightly dispatch that was switched off, and `helm-pages.yml` no longer names the retired steward. - **`status.json`:** the release-train coverage gap becomes a resolved decision. - **Scripts:** removed `scripts/release_control/internal/resolve_release_promotion.py` and its test. It was an unused duplicate of the live resolver and still enforced a 72-hour soak. The live resolver's comment and error text now name the train. ## Checks - Docs mirror check passes. - `release_promotion_policy_test` and `resolve_release_promotion_test` pass (108 tests). - Registry, contract and status audits pass, as does `subsystem_contracts_test`. - The canonical completion guard passes in its CI form.
This commit is contained in:
+14
-13
@@ -154,8 +154,10 @@ nothing; results are in the job log and step summary. Run
|
||||
it locally (`PULSE_REHEARSAL_ENGINE=podman` on hosts without Docker).
|
||||
|
||||
`release-signing-preflight.yml` checks that the macOS release signing path
|
||||
would work, without building or publishing anything. The maintainer's nightly
|
||||
release rehearsal dispatches it from `main`. On a `macos-15` runner it imports
|
||||
would work, without building or publishing anything. It runs on manual
|
||||
dispatch from `main`; the nightly release rehearsal that used to dispatch it
|
||||
was switched off with the release steward on 4 October 2026. On a `macos-15`
|
||||
runner it imports
|
||||
the Developer ID certificate into a throwaway keychain, requires the configured
|
||||
identity to be valid for code signing and at least 30 days from expiry, signs
|
||||
and verifies a probe binary with a secure timestamp, and reads the Apple notary
|
||||
@@ -203,9 +205,9 @@ mutable release. GitHub documents this boundary in
|
||||
Do not disable the continuity assertion, reset incident state, replace historical
|
||||
assets, or delete/recreate a published release to obtain a passing result.
|
||||
|
||||
Recovery belongs to the governed release path: qualify a replacement candidate,
|
||||
retain its exact source and artifact identities, complete the required clean
|
||||
soak, and obtain release-steward admission of the exact stable packet under the
|
||||
Recovery belongs to the governed release path: the release train cuts a fresh
|
||||
candidate from `main`, which retains its exact source and artifact identities,
|
||||
completes the 24-hour soak, and is promoted unchanged under the
|
||||
[Release Train policy](../../docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md#release-train).
|
||||
An immutable prerelease alone does not satisfy those gates. After authorised
|
||||
stable activation, require the continuity read-back for the newly advertised
|
||||
@@ -235,14 +237,13 @@ Use this decision sequence within the existing release authority:
|
||||
later replacement, older orphan tags no longer fail that specific check.
|
||||
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 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
|
||||
publisher needs no per-release founder approval or three-clean-trains
|
||||
prerequisite; an explicit operator revocation still applies. This does not
|
||||
waive qualification, soak, independent review or delivery verification.
|
||||
3. Repairs ship in a fresh candidate the release train cuts from `main`; the
|
||||
train schedules no patch releases. A fresh candidate restarts the 24-hour
|
||||
soak, and an older immutable RC or green main cannot lend its qualification
|
||||
to changed bytes. Promotion is mechanical: the train promotes the exact
|
||||
soaked candidate unless an open `release-blocker` issue stops it, with no
|
||||
per-release founder approval. This does not waive qualification, soak or
|
||||
delivery verification.
|
||||
4. After authorised publication and convergence, require all release, frontier,
|
||||
activation and provenance checks for the newly advertised identity. A
|
||||
passing frontier alone can coexist with a mutable release and is not
|
||||
|
||||
@@ -191,7 +191,7 @@ jobs:
|
||||
# A chart release is a prerelease exactly when its version is one;
|
||||
# stable chart releases were being published as prereleases
|
||||
# (helm-chart-6.4.0, 6.4.1 and every earlier stable chart), which
|
||||
# the release steward reports as an incident. Never "latest": that
|
||||
# was recorded as a release incident. Never "latest": that
|
||||
# pointer belongs to the Pulse release itself.
|
||||
if [[ "${VERSION}" == *-* ]]; then
|
||||
prerelease=true
|
||||
|
||||
@@ -3,14 +3,15 @@ name: Release Signing Preflight
|
||||
# the dispatch answer is lost.
|
||||
run-name: ${{ format('Release signing preflight (no release build or publication){0}', inputs.request_id && format(' [{0}]', inputs.request_id) || '') }}
|
||||
|
||||
# The maintainer's nightly release rehearsal (pulse-dev-infra
|
||||
# pulse-maintainer-release-rehearsal) dispatches this from main, so a lapsed
|
||||
# Apple developer agreement, a revoked notary key or an expiring Developer ID
|
||||
# certificate shows up the morning after it happens instead of as a failed RC.
|
||||
# Dispatch this from main to find a lapsed Apple developer agreement, a revoked
|
||||
# notary key or an expiring Developer ID certificate before a release candidate
|
||||
# fails on it. The maintainer's nightly release rehearsal (pulse-dev-infra
|
||||
# pulse-maintainer-release-rehearsal) dispatched it until that rehearsal was
|
||||
# switched off with the release steward on 4 October 2026.
|
||||
# On 2 Oct 2026 v6.4.6-rc.1 failed notarization because the agreement was
|
||||
# unsigned. This signs only a throwaway probe binary on the runner; it builds,
|
||||
# notarizes, uploads and publishes nothing. The certificate and notary checks
|
||||
# are independent, so one night names every broken credential.
|
||||
# are independent, so one run names every broken credential.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
|
||||
+12
-9
@@ -55,21 +55,24 @@ requests are opened by `pulse-triage[bot]` and state what changed, why it
|
||||
was needed, which reports or demand-ledger entries it answers, and what
|
||||
validation was used, so the record on GitHub is the record of the decision.
|
||||
|
||||
Releases run on a train rather than on demand. A release candidate is cut
|
||||
from `main` into a `release/vX.Y` branch on a fixed schedule, soaks on the
|
||||
opt-in preview channel, takes only backports of regression and security
|
||||
fixes while it soaks, and is promoted to stable as the exact candidate
|
||||
content. The promotion resolver in the release pipeline enforces the soak
|
||||
and the exact-content rule for every dispatcher. The full rules are in
|
||||
[RELEASE_PROMOTION_POLICY.md](https://github.com/rcourtman/Pulse/blob/b64709e7b7ad174e9c94ad2a0d3d841678690935/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md),
|
||||
under "Release Train".
|
||||
Releases run on a fixed train. Fourteen days after the last stable release, or
|
||||
earlier when I ask for an early cut, a release candidate for the next minor
|
||||
version is cut from the head of `main`. Patch releases are not scheduled. The
|
||||
candidate carries everything merged at the cut and nothing is added to it
|
||||
afterwards. It soaks for 24 hours on the opt-in preview channel and is then
|
||||
promoted to stable unchanged. Only an open issue labelled `release-blocker`
|
||||
stops a promotion. The fix lands on `main`, the issue is closed, and a fresh
|
||||
candidate is cut from `main`. The promotion resolver in the release pipeline
|
||||
enforces the soak and the exact-content rule for every dispatcher. [Releases
|
||||
and update channels](RELEASE_PROCESS.md) describes the train for users.
|
||||
|
||||
## Authority and responsibility
|
||||
|
||||
On 8 September 2026 I confirmed standing authority for the automated maintainer
|
||||
to own public Pulse issue replies and release publication, including stable
|
||||
releases. These actions do not require my approval for each comment or release.
|
||||
The maintainer chooses release scope, maturity and timing from current evidence.
|
||||
Since 4 October 2026 releases are mechanical: no model chooses release scope or
|
||||
timing, and the train cuts, soaks and promotes on its schedule.
|
||||
|
||||
Standing authority does not waive independent review, release qualification,
|
||||
exact-candidate promotion, required soak, or verification after publication.
|
||||
|
||||
+30
-36
@@ -5,10 +5,10 @@ changes. Preview releases state what is ready to test, what remains uncertain,
|
||||
and how to return to the previous stable version.
|
||||
|
||||
Pulse's earlier releases did not consistently give testers enough time between
|
||||
candidates. From September 2026, the release process uses explicit testing stages,
|
||||
fixed candidates, and observation periods. The aim is to make each release label
|
||||
dependable, while development continues at its own pace. These are the commitments
|
||||
for releases going forward, not a claim that earlier releases met them.
|
||||
candidates. From October 2026, releases run on a fixed train: a release candidate is
|
||||
cut on a regular schedule, tested as published, and promoted to stable unchanged.
|
||||
These are the commitments for releases going forward, not a claim that earlier
|
||||
releases met them.
|
||||
|
||||
## Choose your update channel
|
||||
|
||||
@@ -33,42 +33,36 @@ deployment-specific update behaviour.
|
||||
|
||||
## How releases are prepared
|
||||
|
||||
Each release has a defined scope, testing stage, and set of required checks.
|
||||
Development can continue while a candidate is being tested without changing
|
||||
what testers have installed.
|
||||
Releases follow a fixed schedule rather than individual judgment.
|
||||
|
||||
1. **Select a fixed candidate.** A release branch holds the selected changes while
|
||||
development continues on `main`. Unrelated newer work does not enter a candidate
|
||||
merely because it has landed elsewhere.
|
||||
2. **Qualify it for its testing stage.** Required checks include targeted tests,
|
||||
installation smoke checks, release-pipeline validation, and recorded rollback
|
||||
instructions. RCs also run the integration checks required for stable releases.
|
||||
3. **Publish a useful checkpoint.** Release notes explain the changes, known issues,
|
||||
what testers should exercise, and the rollback target. A tag or successful build
|
||||
alone is not a published release.
|
||||
4. **Assess the results.** Review covers reported problems, test installations,
|
||||
the demo, and available preview telemetry before advancing.
|
||||
A defect that invalidates the checkpoint holds it for repair and fresh qualification.
|
||||
1. **Cut a candidate from `main`.** Fourteen days after the last stable release,
|
||||
the next release candidate is cut from `main`, provided `main` has changed since
|
||||
that release. An important fix that should not wait for the schedule can bring
|
||||
that cut forward, or replace a candidate that is still soaking with a fresh one
|
||||
that starts its own soak. A candidate carries everything merged to `main` at the
|
||||
cut. Nothing is selected for it or added to it afterwards.
|
||||
2. **Qualify and publish it.** Required checks include targeted tests, installation
|
||||
smoke checks, release-pipeline validation, and recorded rollback instructions.
|
||||
RCs also run the integration checks required for stable releases. Release notes
|
||||
explain the changes, known issues, what testers should exercise, and the rollback
|
||||
target. A tag or successful build alone is not a published release.
|
||||
3. **Soak it on Preview.** The published candidate soaks for **24 hours** on the
|
||||
Preview channel.
|
||||
4. **Promote it unchanged.** After a clean soak, the same candidate is promoted to
|
||||
stable. Release metadata may change, but product content cannot.
|
||||
|
||||
## Cadence and stable promotion
|
||||
|
||||
Releases follow readiness rather than a fixed calendar. Compatible fixes are
|
||||
batched into checkpoints so testers have time to use a build:
|
||||
|
||||
- Successive public checkpoints at the same maturity on a version line are spaced
|
||||
at least **24 hours** apart. Work and testing continue during that interval.
|
||||
- A patch RC needs at least **72 hours** of clean observation before stable promotion.
|
||||
A minor RC needs at least **seven days**.
|
||||
- A product change to an RC starts a fresh observation period. Time spent on beta
|
||||
does not count towards the RC period.
|
||||
- Stable retains the qualified RC's product content. Release metadata may change,
|
||||
but unrelated product changes cannot be added during promotion.
|
||||
|
||||
Elapsed time alone is insufficient. Promotion also requires no open high or
|
||||
critical candidate issues, clean operation on the project's test installation
|
||||
and demo throughout the observation period, and assessment of preview failure
|
||||
signals where available. Sparse telemetry or silence from testers does not prove
|
||||
that every changed feature was exercised.
|
||||
- Each release is the next minor version: after `6.5.0` comes `6.6.0`. Patch
|
||||
releases are not scheduled.
|
||||
- Only an open issue labelled `release-blocker` stops a promotion. That label marks
|
||||
something that worked in the current stable release but is broken in the
|
||||
candidate. Each such issue is closed once its fix is on `main`. When every one is
|
||||
closed, a fresh candidate is cut from `main` and starts its own 24-hour soak.
|
||||
- Successive candidates for the same version are published at least **24 hours**
|
||||
apart. Time spent on a beta does not count towards an RC's soak.
|
||||
- A defect that the current stable release already has is fixed on `main` and ships
|
||||
in the next release.
|
||||
|
||||
An urgent hotfix exception requires active customer harm and a justification
|
||||
recorded in the release notes. Narrow testing may instead use an immutable reporter
|
||||
|
||||
@@ -262,10 +262,11 @@ user language should update the control plane.
|
||||
7. The existing v6 control surfaces are still live, but they now sit underneath
|
||||
an evergreen Pulse control plane rather than pretending to be the whole
|
||||
long-term system.
|
||||
8. The v6 profile defaults to `main`; explicit version overrides in
|
||||
`control_plane.json` bind existing releases to `release/v6.4` and
|
||||
`release/v6.5`. A new checkpoint's mapping is verified during governed
|
||||
preparation, not inferred from the profile default or this page.
|
||||
8. The v6 profile defaults to `main`, where the release train cuts every new
|
||||
release. Legacy version overrides in `control_plane.json` keep the
|
||||
historical `6.4.x` releases bound to `release/v6.4`. A new checkpoint's
|
||||
mapping is verified during governed preparation, not inferred from the
|
||||
profile default or this page.
|
||||
9. Legacy maintenance releases that still feed governed automation outside the
|
||||
active v6 line must also resolve through `control_plane.json`.
|
||||
Right now the remaining `5.1.x` stable maintenance line resolves to `main`
|
||||
|
||||
@@ -369,8 +369,9 @@ destructive state changes, authentication or another trust boundary, or
|
||||
stable-promotion lineage. When classification is uncertain, choose the RC
|
||||
path.
|
||||
|
||||
Successful reporter validation does not force an immediate patch release.
|
||||
Severity and active customer harm decide scheduling. After a stable release
|
||||
Successful reporter validation does not force a release. The fix ships in
|
||||
the next candidate the release train cuts from `main`, or sooner through an
|
||||
early cut. After a stable release
|
||||
containing the fix ships and the issue is resolved, the test image becomes
|
||||
eligible for removal only with explicit maintainer approval. Its tag must
|
||||
never be reused.
|
||||
|
||||
@@ -15,9 +15,9 @@ Use this as the final gate before cutting a Pulse v6 pre-release.
|
||||
- [ ] 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
|
||||
- [ ] Use `rc.N` for the release train's candidate, which is promoted to
|
||||
stable without product changes unless a `release-blocker` issue stops it.
|
||||
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
|
||||
@@ -79,15 +79,15 @@ Use this as the final gate before cutting a Pulse v6 pre-release.
|
||||
reports the `historical-credential-containment` gate as effectively passed,
|
||||
with complete provider and replacement record coverage. A raw `passed`
|
||||
value, prose approval, or optional history rewrite is insufficient.
|
||||
- [ ] For a routine stable patch, run `./scripts/trigger-stable-patch.sh --dry-run <version>` from the exact pushed candidate SHA, wait for the whole run including `Verify Current Stable Demo Path (No Mutation)` to pass, then run `./scripts/trigger-stable-patch.sh <version>` once.
|
||||
- [ ] Confirm a routine stable patch has no same-version RC and no diff in the RC-required authentication/tenant, licensing/billing, persisted-data/migration, relay/mobile-trust, or installer/update/rollback boundaries. Otherwise use RC promotion or record the emergency hotfix reason.
|
||||
- [ ] For a stable patch (none is scheduled, and the train ships important fixes through an early cut), run `./scripts/trigger-stable-patch.sh <version>` once from the exact pushed candidate SHA and wait for the whole run, including `Verify Current Stable Demo Path (No Mutation)`, to pass. `--dry-run` is optional and only for an explicit no-public-release rehearsal.
|
||||
- [ ] Confirm a stable patch has no same-version RC and no diff in the RC-required authentication/tenant, licensing/billing, persisted-data/migration, relay/mobile-trust, or installer/update/rollback boundaries. Otherwise use RC promotion or record the emergency hotfix reason.
|
||||
- [ ] Treat `Release Activation Commit Verdict` as the irreversible GitHub release result, and follow its exact linked `Release Convergence` run until `Customer Promotion Convergence Verdict` is green. A red convergence run is retriable customer-surface debt, not evidence that the committed release returned to draft.
|
||||
- [x] Record the previous stable tag and exact rollback pin command before publishing a new prerelease or stable release.
|
||||
- [ ] For any prerelease or stable publication, confirm the repo variable `PULSE_UPDATE_SIGNING_PUBLIC_KEY` is set to the intended active update signer public key and that the release workflows are consuming it alongside `PULSE_UPDATE_SIGNING_KEY`, so accidental trust-root rotation fails closed before publication.
|
||||
- [x] For the first GA or an RC-required stable promotion, confirm the release pipeline has already been exercised on a real prerelease tag, not only linted or YAML-parsed.
|
||||
- [x] For an RC-required stable promotion, confirm the candidate commit has already shipped on `rc`.
|
||||
- [x] For an RC-required stable promotion, confirm the chosen `promoted_from_tag` is a prerelease that was actually published through the governed prerelease path, not an accidental git tag.
|
||||
- [x] For an RC-required stable promotion, confirm the prerelease soak window is at least 72 hours or document the hotfix exception explicitly.
|
||||
- [x] For an RC-required stable promotion, confirm the candidate soaked for at least 24 hours (the release train's soak) or document the hotfix exception explicitly.
|
||||
- [x] For stable promotion, record the 2026-07-02 release-owner decision accepting the current-branch validation risk for the post-RC7 changes.
|
||||
- [x] For stable v6.2.0 promotion, record the 2026-08-09 release-owner decision
|
||||
accepting the shortened RC11 soak and bind it to the exact content cutoff,
|
||||
|
||||
@@ -38,7 +38,7 @@ command, or GA/EOS dates into the record.
|
||||
|
||||
## Minimum Human Notes
|
||||
|
||||
1. Confirm the rehearsed branch matched the governed release line from `control_plane.json` (currently `pulse/v6-release`).
|
||||
1. Confirm the rehearsed branch matched the governed release line from `control_plane.json` (`pulse/v6-release` for the v6.0.0 GA rehearsal, `main` for the v6 profile under the release train).
|
||||
2. Confirm the release path was exercised end to end up to, but not including,
|
||||
publication.
|
||||
3. Confirm no manual input was surprising or ambiguous during the run.
|
||||
|
||||
@@ -110,10 +110,11 @@ the build is believed capable of becoming stable without product changes.
|
||||
qualify installers, updater behavior, Helm, other architectures, private
|
||||
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 prerelease, stable, or patch
|
||||
must rebuild and qualify through the canonical release workflow rather
|
||||
than promote the reporter image.
|
||||
5. A successful test does not force a release. The fix ships in the next
|
||||
candidate the release train cuts from `main`, or sooner through an early
|
||||
cut when it should not wait. Any later prerelease, stable, or patch must
|
||||
rebuild and qualify through the canonical release workflow rather than
|
||||
promote the reporter image.
|
||||
|
||||
### Forbidden Release Mutations And Lifecycle
|
||||
|
||||
@@ -205,9 +206,10 @@ TLS-unverified receipts leave the claim at `implemented` 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.
|
||||
- `rc.N` is the release train's candidate for stable: it is promoted
|
||||
unchanged unless an open `release-blocker` issue stops it. 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:
|
||||
@@ -235,71 +237,73 @@ TLS-unverified receipts leave the claim at `implemented` or
|
||||
|
||||
## Release Train
|
||||
|
||||
Adopted 2026-09-01 under the delivery contract
|
||||
(`pulse-dev-infra/docs/delivery-contract.md`). The train exists so that a
|
||||
stable release is an exact soaked candidate rather than the tip of a branch
|
||||
that keeps moving, and so that the always-running maintainer can release
|
||||
without the other lanes changing the candidate underneath it.
|
||||
Releases are mechanical (founder direction, 4 October 2026). No model judges,
|
||||
holds or schedules a release. The release train (`pulse-maintainer-ship` on
|
||||
pulse-dev, documented in `pulse-dev-infra/docs/release-train.md`) replaced the
|
||||
release steward, its publisher, the nightly rehearsal and the release-line
|
||||
lane. Every other document that describes releases defers to this section and
|
||||
to that direction.
|
||||
|
||||
1. Founder direction, 2026-09-23: releasing is the default. Every 14 days
|
||||
the maintainer cuts the next minor line from `main`, publishes its RC,
|
||||
and promotes the exact RC to stable once its soak is clean. A due step is
|
||||
held, and a candidate withdrawn or replaced, only for a listed stop
|
||||
reason: a regression against the current stable release, a security
|
||||
problem, data loss or a failed upgrade or rollback, a qualification
|
||||
failure, or a release-pipeline failure. A defect the current stable
|
||||
release already has, or a fix that landed after selection, waits for the
|
||||
next cycle or a patch release. The operating policy is
|
||||
`pulse-dev-infra/maintainer/RELEASE_POLICY.md`. This replaces the
|
||||
2026-09-05 direction that left timing to open-ended judgment. The
|
||||
exact-candidate, source, soak and publication-authority requirements
|
||||
still apply.
|
||||
2. Each train has its own branch, `release/v6.N`, created from `main` at cut
|
||||
time and declared in `docs/release-control/control_plane.json` so the
|
||||
release workflow verifies that governed source line. `main` is never
|
||||
frozen. A selected release is an immutable commit, not the current tip of
|
||||
the train. Its preparation PR uses a fixed `release-candidate/<packet>`
|
||||
ref and passes the normal protected review path into the governed line.
|
||||
Qualification, builds and publication use that PR's exact head commit,
|
||||
even when the merge or later train commits contain newer work. Snapshot
|
||||
dispatch must verify the merged PR's head, canonical repository, source
|
||||
line and continued ancestry in published history. The workflow and source
|
||||
SHA must both equal the admitted snapshot. Later commits belong to another
|
||||
release unless the maintainer explicitly rejects the selected candidate
|
||||
for a concrete defect in that candidate. Merely finding newer work does
|
||||
not invalidate qualification or restart a release.
|
||||
A fix for something found in a checkpoint is backported to the
|
||||
release branch through a pull request. Each changed RC starts its own full soak; beta time never counts toward
|
||||
stable promotion. After general availability the branch is
|
||||
the patch line for that train.
|
||||
3. General availability promotes the candidate's content. The resolver
|
||||
refuses a stable promotion whose tree differs from the promoted candidate
|
||||
in anything but release metadata (version, chart, compose, release notes,
|
||||
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`) 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
|
||||
labelled `affects-<candidate version>` at high or critical severity;
|
||||
preview-channel telemetry, where it exists, shows no elevated failure
|
||||
rate; and, where the candidate observation guest is operational, the
|
||||
candidate ran there for the soak without an incident. An unavailable
|
||||
guest is recorded as a limitation rather than a reason to hold. The
|
||||
stable dogfood instance and the demo server keep running the current
|
||||
stable release by design and are not soak evidence. The release steward
|
||||
names this evidence in the packet.
|
||||
6. Version-bound owner exceptions waived the soak for v6.0.0, v6.1.0,
|
||||
1. **Cut.** Fourteen days after the last stable release, or earlier on
|
||||
`pulse-maintainer-ship request`, the train cuts a release candidate for the
|
||||
next minor version from the head of `main`. A release carries everything on
|
||||
`main` at its cut. Nothing is selected for a release or copied onto it
|
||||
afterwards. An important fix that should not wait for the cycle ships by
|
||||
requesting an early cut. The train does not cut when `main` has nothing new
|
||||
since the last stable release. A request takes effect at the next train run
|
||||
when no candidate is in progress and `main` has changed. A request made
|
||||
while a candidate is soaking replaces that candidate with a fresh one cut
|
||||
from `main`, which restarts the soak. While a `release-blocker` issue holds
|
||||
the soaking candidate the request waits. A request made while a candidate
|
||||
is being prepared or published waits for that attempt to finish: once the
|
||||
candidate publishes the request replaces it, and if the attempt fails the
|
||||
request starts the retry at once, even while the failure's issue is open.
|
||||
2. **Versions.** Version numbers are consecutive: the release after stable
|
||||
`vX.Y.Z` is `vX.(Y+1).0`. Patch releases are not scheduled. Each candidate
|
||||
for a version takes the next unused `rc.N` number, one above every existing
|
||||
candidate tag of that version.
|
||||
3. **Soak.** A published candidate soaks for 24 hours on the preview channel.
|
||||
Successive candidates for one version are published at least 24 hours
|
||||
apart, and beta time never counts toward a candidate's soak.
|
||||
4. **Promote.** After a clean soak the train promotes the exact candidate to
|
||||
stable. The resolver refuses a stable promotion whose tree differs from the
|
||||
promoted candidate in anything but release metadata (version, chart,
|
||||
compose, release notes, 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.
|
||||
5. **Stop.** Only an open issue labelled `release-blocker` stops a promotion.
|
||||
Product intelligence adds that label when a report shows that something
|
||||
which worked in the previous stable release is broken in the current
|
||||
candidate. When every such issue is closed, the train cuts a fresh
|
||||
candidate from `main` and that candidate starts its own soak. The train
|
||||
does not check that the fix landed, so a `release-blocker` issue is closed
|
||||
only once its fix is on `main`. If a promotion stays held for more than 72
|
||||
hours, the train notifies the operator once. A defect the current stable
|
||||
release already has is fixed on `main` and ships in the next release.
|
||||
6. **Failures.** A failed build, test or publication is repaired on `main`.
|
||||
The train reruns a failed release workflow once, then opens a
|
||||
`release-blocker` issue naming the failure and cuts a fresh candidate from
|
||||
`main` 12 hours later, or at once on request. A failed private Pro
|
||||
source-pair pull request fails the attempt without a rerun.
|
||||
7. **Workflow checks.** The GitHub release workflow's own checks are
|
||||
unchanged: exact source, signing, the private Pro build and the promotion
|
||||
rules in this document.
|
||||
|
||||
### History
|
||||
|
||||
These records describe releases before the train and stay as written.
|
||||
|
||||
1. The release train was first adopted on 2026-09-01 under the delivery
|
||||
contract, with a release steward that cut `release/v6.N` lines, judged
|
||||
holds against listed stop reasons, and backported fixes to the line. The
|
||||
founder direction of 4 October 2026 replaced that with the mechanical
|
||||
train above.
|
||||
2. Version-bound owner exceptions waived the soak for v6.0.0, v6.1.0,
|
||||
v6.2.0, v6.3.0, and v6.4.0. They remain recorded and bounded; the train
|
||||
does not continue the practice. An exception requires active customer
|
||||
harm and is recorded in the release notes.
|
||||
7. The `v6.4.3` patch line predates the first train and is the first line
|
||||
3. The `v6.4.3` patch line predates the first train and is the first line
|
||||
released under the train's branch rule. `release/v6.4` was created from
|
||||
`main` at the exact-SHA-qualified commit `56e51e622e` on 2026-09-02 and is
|
||||
declared in `control_plane.json` with the version prefix `6.4.3`, so the
|
||||
@@ -307,7 +311,7 @@ without the other lanes changing the candidate underneath it.
|
||||
moving `main` can no longer invalidate the compiler's exact-SHA binding
|
||||
between dispatch and compilation, which is what failed run 33579042375.
|
||||
Earlier `6.4.x` versions keep their historical `main` mapping.
|
||||
8. The forward regression checkpoint `v6.4.4-beta.1` uses the same
|
||||
4. The forward regression checkpoint `v6.4.4-beta.1` uses the same
|
||||
`release/v6.4` line, with an explicit `6.4.4` mapping for beta, RC and
|
||||
eventual stable. This is a maturity reset, not new feature scope: a
|
||||
`6.4.3-beta.N` would sort below the published `v6.4.3-rc.1`.
|
||||
@@ -321,7 +325,7 @@ without the other lanes changing the candidate underneath it.
|
||||
to the release line before taking a fresh bound packet. Unlisted patches
|
||||
and new product work retain their existing mapping and scope.
|
||||
|
||||
9. The following reliability patch `v6.4.5` also uses `release/v6.4`,
|
||||
5. The following reliability patch `v6.4.5` also uses `release/v6.4`,
|
||||
explicitly mapped for beta, RC and stable. It remains separate from the
|
||||
selected `v6.4.4` candidates: this mapping does not change their source,
|
||||
version or qualification. Land the mapping and resolver tests on main,
|
||||
@@ -700,9 +704,13 @@ without the other lanes changing the candidate underneath it.
|
||||
workflow performs the exact-SHA preflight before crossing the publication
|
||||
boundary.
|
||||
|
||||
## Routine Stable Patch Path
|
||||
## Stable Patch Path
|
||||
|
||||
1. A normal stable patch may omit a same-version RC only when all of these are
|
||||
The release train schedules no patch releases, and an important fix ships
|
||||
through an early cut from `main` (see Release Train). The release workflow
|
||||
still supports a stable patch; when one is published, these rules apply.
|
||||
|
||||
1. A stable patch may omit a same-version RC only when all of these are
|
||||
true:
|
||||
- the rollback target is the latest preceding stable tag and the candidate
|
||||
descends from it;
|
||||
@@ -715,14 +723,14 @@ without the other lanes changing the candidate underneath it.
|
||||
current candidate evidence; and
|
||||
- the integrated exact-SHA candidate build and release checks pass before
|
||||
the workflow creates or publishes the release.
|
||||
2. `scripts/trigger-stable-patch.sh` is the standard operator entrypoint. Run
|
||||
2. `scripts/trigger-stable-patch.sh` is the operator entrypoint. Run
|
||||
it once without `--dry-run`; it derives rollback and release notes, refuses
|
||||
local-only or dirty state, and supplies workflow metadata without
|
||||
interactive prompts. `--dry-run` is optional and exists only for an explicit
|
||||
no-public-release rehearsal.
|
||||
3. Creating a same-version RC or touching an RC-required path moves the patch
|
||||
onto the RC promotion path. The resolver enforces that boundary. Do not use
|
||||
the routine helper to relabel a risky patch as routine.
|
||||
the patch helper to relabel a risky patch as low-risk.
|
||||
4. `--emergency-hotfix-reason` is the narrow escape hatch for active customer
|
||||
harm. It does not remove the integrated exact-SHA candidate and release
|
||||
checks, and the reason is recorded in the release metadata.
|
||||
|
||||
@@ -80,7 +80,9 @@ This file must not contain:
|
||||
completion obligations.
|
||||
11. `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`
|
||||
Canonical stable-versus-prerelease promotion rules, rollout criteria, and rollback
|
||||
expectations for v6 and later release lines.
|
||||
expectations for v6 and later release lines. Its Release Train section is the
|
||||
single statement of release cadence, soak, versioning and promotion in this
|
||||
repository; other documents defer to it rather than restating it.
|
||||
12. `docs/release-control/v6/internal/V5_MAINTENANCE_SUPPORT_POLICY.md`
|
||||
Canonical v5 maintenance-only support policy, release-line rules, and GA
|
||||
notice requirements for the v6 cutover.
|
||||
@@ -522,9 +524,9 @@ Assertion design rules:
|
||||
Claims reduce overlap, but they do not isolate hooks, formatters, staged
|
||||
reads, or unrelated dirt. Parallel mutation should use separate worktrees
|
||||
so each agent sees one slice's git state at a time.
|
||||
22. Do not publish a routine stable patch without a successful exact-SHA
|
||||
`Release Dry Run` from the previous 24 hours. That run must prove the
|
||||
current stable demo network/SSH/browser path without mutation, and the
|
||||
22. Do not publish a stable patch unless its exact-SHA publish run proves the
|
||||
current stable demo network/SSH/browser path without mutation before the
|
||||
publication boundary. A separate `Release Dry Run` is optional. The
|
||||
publish workflow must await demo deployment plus definitive verification;
|
||||
manual SSH deployment is not an acceptable release completion path.
|
||||
23. Do not authorize limited unattended Pulse Intelligence mutation until the
|
||||
@@ -609,8 +611,9 @@ Assertion design rules:
|
||||
issue intended for v6 is fixed in the candidate, proven invalid with
|
||||
evidence, or conservatively superseded with the original problem resolved
|
||||
or explicitly narrowed.
|
||||
15. Routine stable patch releases after GA do not require a fabricated RC.
|
||||
They may use the no-RC path only when the candidate descends from the latest
|
||||
15. The release train schedules no patch releases. When a stable patch is
|
||||
published after GA it does not require a fabricated RC and may use the
|
||||
no-RC path only when the candidate descends from the latest
|
||||
stable rollback target, no same-version RC exists, no governed high-risk
|
||||
auth/tenant, licensing/billing, persisted-data/migration, relay/mobile
|
||||
trust, or installer/update/rollback path changed, and the exact-SHA release
|
||||
@@ -669,8 +672,8 @@ Assertion design rules:
|
||||
or other release surface. Broad, multi-platform, release-surface,
|
||||
migration, destructive-state, trust-boundary, cross-repo, and promotion-
|
||||
lineage validation remains on the governed RC path. Successful reporter
|
||||
confirmation informs severity-based release scheduling but does not force
|
||||
an immediate patch release.
|
||||
confirmation does not force a release: the fix ships in the next candidate
|
||||
the release train cuts from `main`, or sooner through an early cut.
|
||||
22. A published release candidate is a cohort checkpoint, not a per-fix delivery
|
||||
mechanism. After the first RC on a version line, at least 24 hours of public
|
||||
observation must elapse before another RC on that line is published.
|
||||
|
||||
@@ -9996,38 +9996,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "release-train-exact-candidate-promotion",
|
||||
"summary": "Stable promotions built whatever the dispatch branch was at that second: the resolver checked that HEAD descends from the promoted RC but not that its content matches, so v6.4.0 shipped 64 changed files that v6.4.0-rc.12 never soaked, and every v6 version was mapped to main, which moves every few minutes under the autonomous maintainer. The release train needs a branch per train declared in the control plane, a resolver rule that refuses stable content beyond release metadata without a recorded hotfix reason, and a seven day soak for minor releases. Founder direction on 2026-09-05 replaces the fixed minor calendar with maintainer-owned release judgment informed by the preference for fast progress and willingness to use many betas, while retaining exact-candidate, branch and clean-soak requirements.",
|
||||
"owner": "project-owner",
|
||||
"status": "triaged",
|
||||
"recorded_at": "2026-09-01",
|
||||
"lane_ids": [
|
||||
"L1"
|
||||
],
|
||||
"subsystem_ids": [
|
||||
"deployment-installability"
|
||||
],
|
||||
"proposed_resolution": "lane-expansion",
|
||||
"coverage_impact": 5,
|
||||
"evidence": [
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "docs/release-control/control_plane.json",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "scripts/release_control/resolve_release_promotion.py",
|
||||
"kind": "file"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ai-provider-guided-setup",
|
||||
"summary": "Paying installs choose a Patrol model and schedule with no guidance on which models work with Patrol or what a schedule costs: 38 of 120 paid installs had no AI provider and 12 more had AI without Patrol in the 2026-09-01 assessment, a Pro customer picked Gemini Flash-Lite to save money and Patrol could not file verdicts, and a mispriced Opus row tripped the 20 USD budget and silently disabled Patrol (issue 1789). The provider settings need recommended, suggested, and caution markers drawn from the qualified set and known failures, a projected monthly Patrol cost from the price table and the install's run history, a cost-model schedule default for per-token providers, and a visible Patrol pause with a budget action when the budget is used up.",
|
||||
@@ -11422,6 +11390,18 @@
|
||||
"lane_ids": [
|
||||
"L1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "release-train-mechanical-from-main",
|
||||
"summary": "Resolves coverage gap release-train-exact-candidate-promotion. The resolver now refuses stable content beyond release metadata without a recorded hotfix reason, so the exact-candidate rule the gap asked for is enforced. The founder direction of 2026-10-04 replaced the rest: releases are mechanical, with no maintainer judgment; the release train cuts the next minor candidate from the head of main 14 days after the last stable release or on request, soaks it for 24 hours and promotes it unchanged; only an open release-blocker issue stops a promotion; versions are consecutive and patch releases are not scheduled. The branch per train, backports and the seven-day minor soak it called for no longer apply. RELEASE_PROMOTION_POLICY.md, Release Train, is the in-repository statement.",
|
||||
"kind": "release-policy",
|
||||
"decided_at": "2026-10-04",
|
||||
"subsystem_ids": [
|
||||
"deployment-installability"
|
||||
],
|
||||
"lane_ids": [
|
||||
"L1"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -307,9 +307,10 @@ head and ref match the admitted snapshot, and that its merge remains in that
|
||||
line's published history. Qualification, workflow execution, compiler dispatch
|
||||
and published artifacts bind to that head, not a later merge or branch tip.
|
||||
The source workflow must implement the snapshot input and provenance contract
|
||||
before the maintainer spends an exact qualification run on it. Later changes
|
||||
belong to the next candidate unless the maintainer explicitly rejects the
|
||||
selected source for a concrete defect. Existing maturity, soak, failed-check
|
||||
before the release train spends an exact qualification run on it. Later
|
||||
changes belong to the next candidate. The train replaces a candidate only with
|
||||
a fresh cut from `main`: after its `release-blocker` issues are closed, on an
|
||||
early-cut request during its soak, or after a failed attempt. Existing maturity, soak, failed-check
|
||||
and publication-authority boundaries still apply.
|
||||
`scripts/release_control/release_snapshot.py` owns snapshot identity validation.
|
||||
Its executable identity cases are in `release_snapshot_test.py`, and the staged
|
||||
@@ -756,11 +757,10 @@ release assets and four release signatures, runs the canonical twenty-three-scen
|
||||
schema-v7 lab, and applies the release-candidate attester against the exact
|
||||
GitHub release ID, tag, source commit, checksums, compiler provenance, assembly
|
||||
provenance, and update-key fingerprint. The attester anchors the candidate to
|
||||
the branch the control plane maps the version to, either `origin/main` or the
|
||||
release train's `origin/release/vX.Y`, and verifies that remote-tracking ref
|
||||
against the canonical origin's live branch tip, so a release-train candidate is
|
||||
qualified against the line that owns its version rather than being required to
|
||||
appear on `main`. The v7 host starts a rootful Docker
|
||||
the branch the control plane maps the version to: `origin/main`, where the
|
||||
release train cuts every release, or a legacy `origin/release/vX.Y` line that
|
||||
the control plane still maps for a historical version. It verifies that
|
||||
remote-tracking ref against the canonical origin's live branch tip. The v7 host starts a rootful Docker
|
||||
daemon inside the disposable systemd container without mounting the hosted
|
||||
runner socket or exposing a TCP listener. It imports an offline, source-bound
|
||||
fixture image and proves legacy inventory, summary-only typed-helper parity,
|
||||
@@ -1527,8 +1527,8 @@ artifact-selection behaviour.
|
||||
candidate workflow; publishing releases invoke it as a sibling of inert
|
||||
draft staging so qualification and upload overlap without weakening the
|
||||
activation join.
|
||||
A provider-isolation security backport also requires a live two-installation
|
||||
Docker result before the release steward freezes a packet. On protected
|
||||
A provider-isolation security fix also requires a live two-installation
|
||||
Docker result before a candidate carrying it is published. On protected
|
||||
`release/v*` pushes, Build and Test checks out the event SHA, preloads a
|
||||
digest-pinned helper image, and runs the opt-in provider-pair case against a
|
||||
fresh daemon; a skipped test is not a pass. The case provisions the same
|
||||
@@ -2263,7 +2263,8 @@ artifact-selection behaviour.
|
||||
`pulse_sha` equal to the frozen public commit. A missing or mismatched
|
||||
declaration must stop the run before any draft release object exists, so a
|
||||
private build that cannot resolve its pair never orphans another draft. The
|
||||
check is a fact check only; the release steward still selects the pair.
|
||||
check is a fact check only; the release train selects the pair, pairing the
|
||||
candidate with Enterprise `main` at its cut.
|
||||
Only after public release asset validation, staged install smoke, exact
|
||||
public Docker publication, exact Helm OCI publication, durable convergence
|
||||
dispatch, and the publicly readable activation-commit marker may the
|
||||
@@ -2474,8 +2475,9 @@ artifact-selection behaviour.
|
||||
7. Preserve release-matched installer and Helm operator documentation links through `scripts/install.sh`, `.github/workflows/helm-pages.yml`, `.github/workflows/publish-helm-chart.yml`, and the chart metadata itself so deployment guidance and packaged chart metadata do not drift back to branch-tip `main` docs when a release line or promoted tag already exists.
|
||||
The same governed Helm boundary also owns `deploy/helm/pulse/` itself:
|
||||
chart metadata, default values, templates, and generated chart docs must
|
||||
stay on the validated release line rather than mutating `main` or packaging
|
||||
from whatever branch GitHub happened to check out.
|
||||
be packaged from the validated release tag, which the release train cuts
|
||||
from `main`, rather than mutating `main` after release or packaging from
|
||||
whatever branch GitHub happened to check out.
|
||||
The chart's `agent.enabled=true` workload must point at an image that is
|
||||
actually published. The default `agent.image.repository` must be the main
|
||||
`rcourtman/pulse` image (the only agent-capable image `publish-docker.yml`
|
||||
@@ -4167,7 +4169,8 @@ the manual `trigger-release*.sh` entrypoints must all derive their governed
|
||||
release line from control-plane metadata before they touch public artifacts or
|
||||
deployment targets, rather than treating tag names or workflow triggers as
|
||||
enough proof on their own.
|
||||
For routine stable patches, `scripts/trigger-stable-patch.sh` is the
|
||||
For stable patches, which the release train does not schedule,
|
||||
`scripts/trigger-stable-patch.sh` is the
|
||||
noninteractive operator path. It derives the latest stable rollback, consumes
|
||||
the canonical `docs/releases/RELEASE_NOTES_vX.Y.Z.md` packet, infers
|
||||
`no-mobile-impact` only when no mobile-facing path changed, and dispatches one
|
||||
@@ -6081,12 +6084,11 @@ 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
|
||||
A release line cut earlier keeps its own resolver's longer minimum.
|
||||
`docs/release-control/control_plane.json` resolves the v6 profile's prerelease
|
||||
and stable branches to `main` for the release train, and binds only the legacy
|
||||
6.3.2 and 6.4.3 to 6.4.6 versions to their release lines, so the workflow
|
||||
refuses a dispatch of those versions from any other branch. `scripts/release_control/resolve_release_promotion_test.py` pins the
|
||||
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.
|
||||
@@ -6540,8 +6542,9 @@ redacted settings receipts are retained for seven days. Successful screenshots
|
||||
contain only synthetic state. Auth files, bootstrap/API/node tokens, encrypted
|
||||
file manifests and raw installer/updater/service logs are not artifacts.
|
||||
Delivery must reconcile terminal logs and all receipts with the exact RC;
|
||||
a workflow conclusion alone is insufficient. Existing qualification, soak,
|
||||
release-steward judgment and publication boundaries remain unchanged. The
|
||||
a workflow conclusion alone is insufficient. Existing qualification, soak
|
||||
and publication boundaries remain unchanged, and the release train's
|
||||
`release-blocker` rule decides promotion. The
|
||||
previous target's undiagnosed HTTP401 is not diagnosed or cleared by this mode.
|
||||
|
||||
Verification: `scripts/tests/test_release_browser_packet.py` executes immutable
|
||||
|
||||
@@ -55,21 +55,24 @@ requests are opened by `pulse-triage[bot]` and state what changed, why it
|
||||
was needed, which reports or demand-ledger entries it answers, and what
|
||||
validation was used, so the record on GitHub is the record of the decision.
|
||||
|
||||
Releases run on a train rather than on demand. A release candidate is cut
|
||||
from `main` into a `release/vX.Y` branch on a fixed schedule, soaks on the
|
||||
opt-in preview channel, takes only backports of regression and security
|
||||
fixes while it soaks, and is promoted to stable as the exact candidate
|
||||
content. The promotion resolver in the release pipeline enforces the soak
|
||||
and the exact-content rule for every dispatcher. The full rules are in
|
||||
[RELEASE_PROMOTION_POLICY.md](https://github.com/rcourtman/Pulse/blob/b64709e7b7ad174e9c94ad2a0d3d841678690935/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md),
|
||||
under "Release Train".
|
||||
Releases run on a fixed train. Fourteen days after the last stable release, or
|
||||
earlier when I ask for an early cut, a release candidate for the next minor
|
||||
version is cut from the head of `main`. Patch releases are not scheduled. The
|
||||
candidate carries everything merged at the cut and nothing is added to it
|
||||
afterwards. It soaks for 24 hours on the opt-in preview channel and is then
|
||||
promoted to stable unchanged. Only an open issue labelled `release-blocker`
|
||||
stops a promotion. The fix lands on `main`, the issue is closed, and a fresh
|
||||
candidate is cut from `main`. The promotion resolver in the release pipeline
|
||||
enforces the soak and the exact-content rule for every dispatcher. [Releases
|
||||
and update channels](RELEASE_PROCESS.md) describes the train for users.
|
||||
|
||||
## Authority and responsibility
|
||||
|
||||
On 8 September 2026 I confirmed standing authority for the automated maintainer
|
||||
to own public Pulse issue replies and release publication, including stable
|
||||
releases. These actions do not require my approval for each comment or release.
|
||||
The maintainer chooses release scope, maturity and timing from current evidence.
|
||||
Since 4 October 2026 releases are mechanical: no model chooses release scope or
|
||||
timing, and the train cuts, soaks and promotes on its schedule.
|
||||
|
||||
Standing authority does not waive independent review, release qualification,
|
||||
exact-candidate promotion, required soak, or verification after publication.
|
||||
|
||||
@@ -5,10 +5,10 @@ changes. Preview releases state what is ready to test, what remains uncertain,
|
||||
and how to return to the previous stable version.
|
||||
|
||||
Pulse's earlier releases did not consistently give testers enough time between
|
||||
candidates. From September 2026, the release process uses explicit testing stages,
|
||||
fixed candidates, and observation periods. The aim is to make each release label
|
||||
dependable, while development continues at its own pace. These are the commitments
|
||||
for releases going forward, not a claim that earlier releases met them.
|
||||
candidates. From October 2026, releases run on a fixed train: a release candidate is
|
||||
cut on a regular schedule, tested as published, and promoted to stable unchanged.
|
||||
These are the commitments for releases going forward, not a claim that earlier
|
||||
releases met them.
|
||||
|
||||
## Choose your update channel
|
||||
|
||||
@@ -33,42 +33,36 @@ deployment-specific update behaviour.
|
||||
|
||||
## How releases are prepared
|
||||
|
||||
Each release has a defined scope, testing stage, and set of required checks.
|
||||
Development can continue while a candidate is being tested without changing
|
||||
what testers have installed.
|
||||
Releases follow a fixed schedule rather than individual judgment.
|
||||
|
||||
1. **Select a fixed candidate.** A release branch holds the selected changes while
|
||||
development continues on `main`. Unrelated newer work does not enter a candidate
|
||||
merely because it has landed elsewhere.
|
||||
2. **Qualify it for its testing stage.** Required checks include targeted tests,
|
||||
installation smoke checks, release-pipeline validation, and recorded rollback
|
||||
instructions. RCs also run the integration checks required for stable releases.
|
||||
3. **Publish a useful checkpoint.** Release notes explain the changes, known issues,
|
||||
what testers should exercise, and the rollback target. A tag or successful build
|
||||
alone is not a published release.
|
||||
4. **Assess the results.** Review covers reported problems, test installations,
|
||||
the demo, and available preview telemetry before advancing.
|
||||
A defect that invalidates the checkpoint holds it for repair and fresh qualification.
|
||||
1. **Cut a candidate from `main`.** Fourteen days after the last stable release,
|
||||
the next release candidate is cut from `main`, provided `main` has changed since
|
||||
that release. An important fix that should not wait for the schedule can bring
|
||||
that cut forward, or replace a candidate that is still soaking with a fresh one
|
||||
that starts its own soak. A candidate carries everything merged to `main` at the
|
||||
cut. Nothing is selected for it or added to it afterwards.
|
||||
2. **Qualify and publish it.** Required checks include targeted tests, installation
|
||||
smoke checks, release-pipeline validation, and recorded rollback instructions.
|
||||
RCs also run the integration checks required for stable releases. Release notes
|
||||
explain the changes, known issues, what testers should exercise, and the rollback
|
||||
target. A tag or successful build alone is not a published release.
|
||||
3. **Soak it on Preview.** The published candidate soaks for **24 hours** on the
|
||||
Preview channel.
|
||||
4. **Promote it unchanged.** After a clean soak, the same candidate is promoted to
|
||||
stable. Release metadata may change, but product content cannot.
|
||||
|
||||
## Cadence and stable promotion
|
||||
|
||||
Releases follow readiness rather than a fixed calendar. Compatible fixes are
|
||||
batched into checkpoints so testers have time to use a build:
|
||||
|
||||
- Successive public checkpoints at the same maturity on a version line are spaced
|
||||
at least **24 hours** apart. Work and testing continue during that interval.
|
||||
- A patch RC needs at least **72 hours** of clean observation before stable promotion.
|
||||
A minor RC needs at least **seven days**.
|
||||
- A product change to an RC starts a fresh observation period. Time spent on beta
|
||||
does not count towards the RC period.
|
||||
- Stable retains the qualified RC's product content. Release metadata may change,
|
||||
but unrelated product changes cannot be added during promotion.
|
||||
|
||||
Elapsed time alone is insufficient. Promotion also requires no open high or
|
||||
critical candidate issues, clean operation on the project's test installation
|
||||
and demo throughout the observation period, and assessment of preview failure
|
||||
signals where available. Sparse telemetry or silence from testers does not prove
|
||||
that every changed feature was exercised.
|
||||
- Each release is the next minor version: after `6.5.0` comes `6.6.0`. Patch
|
||||
releases are not scheduled.
|
||||
- Only an open issue labelled `release-blocker` stops a promotion. That label marks
|
||||
something that worked in the current stable release but is broken in the
|
||||
candidate. Each such issue is closed once its fix is on `main`. When every one is
|
||||
closed, a fresh candidate is cut from `main` and starts its own 24-hour soak.
|
||||
- Successive candidates for the same version are published at least **24 hours**
|
||||
apart. Time spent on a beta does not count towards an RC's soak.
|
||||
- A defect that the current stable release already has is fixed on `main` and ships
|
||||
in the next release.
|
||||
|
||||
An urgent hotfix exception requires active customer harm and a justification
|
||||
recorded in the release notes. Narrow testing may instead use an immutable reporter
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Resolve and validate shared release-promotion metadata for governed workflows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from repo_file_io import REPO_ROOT, git_env
|
||||
|
||||
|
||||
SEMVER_STABLE_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
|
||||
SEMVER_PRERELEASE_RE = re.compile(r"-(?:[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)(?:\+[0-9A-Za-z.-]+)?$")
|
||||
|
||||
|
||||
def normalize_tag(value: str) -> str:
|
||||
value = (value or "").strip()
|
||||
if not value:
|
||||
return ""
|
||||
if value.startswith("v"):
|
||||
return value
|
||||
return f"v{value}"
|
||||
|
||||
|
||||
def is_prerelease_version(version: str) -> bool:
|
||||
return bool(SEMVER_PRERELEASE_RE.search(version))
|
||||
|
||||
|
||||
def is_stable_patch_version(version: str) -> bool:
|
||||
match = SEMVER_STABLE_RE.match(version)
|
||||
return bool(match and int(match.group(3)) > 0)
|
||||
|
||||
|
||||
def tag_exists(tag: str) -> bool:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "-q", "--verify", f"refs/tags/{tag}"],
|
||||
cwd=REPO_ROOT,
|
||||
env=git_env(),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def tag_commit(tag: str) -> str:
|
||||
result = subprocess.run(
|
||||
["git", "rev-list", "-n1", f"refs/tags/{tag}"],
|
||||
cwd=REPO_ROOT,
|
||||
env=git_env(),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def head_descends_from(commit: str) -> bool:
|
||||
result = subprocess.run(
|
||||
["git", "merge-base", "--is-ancestor", commit, "HEAD"],
|
||||
cwd=REPO_ROOT,
|
||||
env=git_env(),
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def tag_created_unix(tag: str) -> int:
|
||||
result = subprocess.run(
|
||||
["git", "for-each-ref", "--format=%(creatordate:unix)", f"refs/tags/{tag}"],
|
||||
cwd=REPO_ROOT,
|
||||
env=git_env(),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
value = result.stdout.strip().splitlines()
|
||||
if not value or not value[0].strip():
|
||||
raise ValueError(f"Could not determine creation time for promoted prerelease tag {tag}.")
|
||||
return int(value[0].strip())
|
||||
|
||||
|
||||
def normalize_whitespace(value: str) -> str:
|
||||
return " ".join((value or "").split())
|
||||
|
||||
|
||||
def resolve_metadata(
|
||||
*,
|
||||
version: str,
|
||||
promoted_from_tag_input: str,
|
||||
rollback_version_input: str,
|
||||
ga_date_input: str,
|
||||
v5_eos_date_input: str,
|
||||
hotfix_exception: bool,
|
||||
hotfix_reason_input: str,
|
||||
release_notes_input: str,
|
||||
tag_exists_fn: Callable[[str], bool] = tag_exists,
|
||||
tag_commit_fn: Callable[[str], str] = tag_commit,
|
||||
head_descends_from_fn: Callable[[str], bool] = head_descends_from,
|
||||
tag_created_unix_fn: Callable[[str], int] = tag_created_unix,
|
||||
now_unix_fn: Callable[[], int] = lambda: int(time.time()),
|
||||
) -> dict[str, str]:
|
||||
tag = normalize_tag(version)
|
||||
rollback_tag = normalize_tag(rollback_version_input)
|
||||
ga_date = (ga_date_input or "").strip()
|
||||
v5_eos_date = (v5_eos_date_input or "").strip()
|
||||
hotfix_reason = normalize_whitespace(hotfix_reason_input)
|
||||
release_notes = release_notes_input or ""
|
||||
is_prerelease = is_prerelease_version(version)
|
||||
|
||||
if not rollback_tag:
|
||||
raise ValueError(
|
||||
"rollback_version is required for every release rehearsal and promotion so rollback can be executed explicitly."
|
||||
)
|
||||
if SEMVER_PRERELEASE_RE.search(rollback_tag):
|
||||
raise ValueError(
|
||||
f"rollback_version must point to a stable release tag, not a prerelease ({rollback_tag})."
|
||||
)
|
||||
if not tag_exists_fn(rollback_tag):
|
||||
raise ValueError(f"rollback_version {rollback_tag} does not exist as a repository tag.")
|
||||
rollback_command = f"sudo /bin/update --version {rollback_tag}"
|
||||
|
||||
promoted_from_tag = ""
|
||||
soak_hours = ""
|
||||
if is_prerelease:
|
||||
if hotfix_exception:
|
||||
raise ValueError("hotfix_exception applies only to stable promotions.")
|
||||
else:
|
||||
promoted_from_tag = normalize_tag(promoted_from_tag_input)
|
||||
if not promoted_from_tag:
|
||||
if is_stable_patch_version(version) and hotfix_exception:
|
||||
if not hotfix_reason:
|
||||
raise ValueError("hotfix_reason is required when hotfix_exception is true.")
|
||||
else:
|
||||
raise ValueError(
|
||||
"Stable promotion requires promoted_from_tag naming the prerelease being promoted. "
|
||||
"Stable patch hotfix releases may omit promoted_from_tag only when hotfix_exception is true and hotfix_reason is set."
|
||||
)
|
||||
else:
|
||||
if not re.match(rf"^v{re.escape(version)}-rc\.\d+$", promoted_from_tag):
|
||||
raise ValueError(
|
||||
f"promoted_from_tag must reference a prerelease tag for the same stable version ({version}), got {promoted_from_tag}."
|
||||
)
|
||||
if not tag_exists_fn(promoted_from_tag):
|
||||
raise ValueError(
|
||||
f"promoted_from_tag {promoted_from_tag} does not exist as a repository tag."
|
||||
)
|
||||
|
||||
promoted_commit = tag_commit_fn(promoted_from_tag)
|
||||
if not head_descends_from_fn(promoted_commit):
|
||||
raise ValueError(
|
||||
f"Stable promotion {tag} must descend from promoted prerelease tag {promoted_from_tag}."
|
||||
)
|
||||
|
||||
promoted_tag_ts = tag_created_unix_fn(promoted_from_tag)
|
||||
soak_hours_value = int((now_unix_fn() - promoted_tag_ts) / 3600)
|
||||
soak_hours = str(soak_hours_value)
|
||||
|
||||
if hotfix_exception:
|
||||
if not hotfix_reason:
|
||||
raise ValueError("hotfix_reason is required when hotfix_exception is true.")
|
||||
elif soak_hours_value < 72:
|
||||
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."
|
||||
)
|
||||
|
||||
if version == "6.0.0":
|
||||
if not re.match(r"^\d{4}-\d{2}-\d{2}$", ga_date):
|
||||
raise ValueError(
|
||||
"Stable v6.0.0 requires ga_date in YYYY-MM-DD form so the GA publish notice is explicit."
|
||||
)
|
||||
if not re.match(r"^\d{4}-\d{2}-\d{2}$", v5_eos_date):
|
||||
raise ValueError(
|
||||
"Stable v6.0.0 requires v5_eos_date in YYYY-MM-DD form so the support window is published explicitly."
|
||||
)
|
||||
if release_notes:
|
||||
if "maintenance-only support" not in release_notes.lower():
|
||||
raise ValueError(
|
||||
"Stable v6.0.0 release_notes must include the Pulse v5 maintenance-only support notice."
|
||||
)
|
||||
if ga_date not in release_notes:
|
||||
raise ValueError(
|
||||
f"Stable v6.0.0 release_notes must include the exact ga_date ({ga_date})."
|
||||
)
|
||||
if v5_eos_date not in release_notes:
|
||||
raise ValueError(
|
||||
f"Stable v6.0.0 release_notes must include the exact v5_eos_date ({v5_eos_date})."
|
||||
)
|
||||
|
||||
return {
|
||||
"promoted_from_tag": promoted_from_tag,
|
||||
"rollback_tag": rollback_tag,
|
||||
"rollback_command": rollback_command,
|
||||
"ga_date": ga_date,
|
||||
"v5_eos_date": v5_eos_date,
|
||||
"hotfix_exception": "true" if hotfix_exception else "false",
|
||||
"hotfix_reason": hotfix_reason,
|
||||
"soak_hours": soak_hours,
|
||||
}
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--version", required=True)
|
||||
parser.add_argument("--promoted-from-tag", default="")
|
||||
parser.add_argument("--rollback-version", default="")
|
||||
parser.add_argument("--ga-date", default="")
|
||||
parser.add_argument("--v5-eos-date", default="")
|
||||
parser.add_argument("--hotfix-exception", action="store_true")
|
||||
parser.add_argument("--hotfix-reason", default="")
|
||||
parser.add_argument("--release-notes-file", default="")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
release_notes = ""
|
||||
if args.release_notes_file:
|
||||
release_notes = Path(args.release_notes_file).read_text(encoding="utf-8")
|
||||
|
||||
metadata = resolve_metadata(
|
||||
version=args.version,
|
||||
promoted_from_tag_input=args.promoted_from_tag,
|
||||
rollback_version_input=args.rollback_version,
|
||||
ga_date_input=args.ga_date,
|
||||
v5_eos_date_input=args.v5_eos_date,
|
||||
hotfix_exception=args.hotfix_exception,
|
||||
hotfix_reason_input=args.hotfix_reason,
|
||||
release_notes_input=release_notes,
|
||||
)
|
||||
|
||||
for key, value in metadata.items():
|
||||
print(f"{key}={value}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,174 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for shared release-promotion metadata resolution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
import resolve_release_promotion as resolver
|
||||
|
||||
|
||||
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_prerelease_requires_explicit_stable_rollback(self) -> None:
|
||||
metadata = resolver.resolve_metadata(
|
||||
version="6.0.0-rc.2",
|
||||
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["rollback_command"], "sudo /bin/update --version v5.1.14")
|
||||
self.assertEqual(metadata["promoted_from_tag"], "")
|
||||
self.assertEqual(metadata["soak_hours"], "")
|
||||
|
||||
def test_stable_requires_matching_promoted_rc_and_soak(self) -> None:
|
||||
metadata = resolver.resolve_metadata(
|
||||
version="6.0.0",
|
||||
promoted_from_tag_input="6.0.0-rc.2",
|
||||
rollback_version_input="5.1.14",
|
||||
ga_date_input="2026-03-20",
|
||||
v5_eos_date_input="2026-06-18",
|
||||
hotfix_exception=False,
|
||||
hotfix_reason_input="",
|
||||
release_notes_input="maintenance-only support 2026-03-20 2026-06-18",
|
||||
tag_exists_fn=lambda tag: tag in {"v6.0.0-rc.2", "v5.1.14"},
|
||||
tag_commit_fn=lambda tag: "abc123",
|
||||
head_descends_from_fn=lambda commit: commit == "abc123",
|
||||
tag_created_unix_fn=lambda tag: 100,
|
||||
now_unix_fn=lambda: 100 + (73 * 3600),
|
||||
)
|
||||
self.assertEqual(metadata["promoted_from_tag"], "v6.0.0-rc.2")
|
||||
self.assertEqual(metadata["soak_hours"], "73")
|
||||
|
||||
def test_stable_requires_release_notes_notice_when_supplied(self) -> None:
|
||||
with self.assertRaisesRegex(
|
||||
ValueError,
|
||||
"release_notes must include the Pulse v5 maintenance-only support notice",
|
||||
):
|
||||
resolver.resolve_metadata(
|
||||
version="6.0.0",
|
||||
promoted_from_tag_input="6.0.0-rc.2",
|
||||
rollback_version_input="5.1.14",
|
||||
ga_date_input="2026-03-20",
|
||||
v5_eos_date_input="2026-06-18",
|
||||
hotfix_exception=False,
|
||||
hotfix_reason_input="",
|
||||
release_notes_input="missing notice 2026-03-20 2026-06-18",
|
||||
tag_exists_fn=lambda tag: True,
|
||||
tag_commit_fn=lambda tag: "abc123",
|
||||
head_descends_from_fn=lambda commit: True,
|
||||
tag_created_unix_fn=lambda tag: 100,
|
||||
now_unix_fn=lambda: 100 + (73 * 3600),
|
||||
)
|
||||
|
||||
def test_stable_patch_hotfix_can_omit_promoted_prerelease(self) -> None:
|
||||
metadata = resolver.resolve_metadata(
|
||||
version="6.0.1",
|
||||
promoted_from_tag_input="",
|
||||
rollback_version_input="6.0.0",
|
||||
ga_date_input="",
|
||||
v5_eos_date_input="",
|
||||
hotfix_exception=True,
|
||||
hotfix_reason_input="Patch release for v6.0.0 upgrade and monitoring fixes.",
|
||||
release_notes_input="",
|
||||
tag_exists_fn=lambda tag: tag == "v6.0.0",
|
||||
)
|
||||
|
||||
self.assertEqual(metadata["promoted_from_tag"], "")
|
||||
self.assertEqual(metadata["rollback_tag"], "v6.0.0")
|
||||
self.assertEqual(metadata["rollback_command"], "sudo /bin/update --version v6.0.0")
|
||||
self.assertEqual(metadata["hotfix_exception"], "true")
|
||||
self.assertEqual(
|
||||
metadata["hotfix_reason"],
|
||||
"Patch release for v6.0.0 upgrade and monitoring fixes.",
|
||||
)
|
||||
self.assertEqual(metadata["soak_hours"], "")
|
||||
|
||||
def test_stable_patch_without_promoted_tag_requires_hotfix_exception(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "Stable promotion requires promoted_from_tag"):
|
||||
resolver.resolve_metadata(
|
||||
version="6.0.1",
|
||||
promoted_from_tag_input="",
|
||||
rollback_version_input="6.0.0",
|
||||
ga_date_input="",
|
||||
v5_eos_date_input="",
|
||||
hotfix_exception=False,
|
||||
hotfix_reason_input="",
|
||||
release_notes_input="",
|
||||
tag_exists_fn=lambda tag: tag == "v6.0.0",
|
||||
)
|
||||
|
||||
def test_stable_patch_hotfix_without_promoted_tag_requires_reason(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "hotfix_reason is required"):
|
||||
resolver.resolve_metadata(
|
||||
version="6.0.1",
|
||||
promoted_from_tag_input="",
|
||||
rollback_version_input="6.0.0",
|
||||
ga_date_input="",
|
||||
v5_eos_date_input="",
|
||||
hotfix_exception=True,
|
||||
hotfix_reason_input="",
|
||||
release_notes_input="",
|
||||
tag_exists_fn=lambda tag: tag == "v6.0.0",
|
||||
)
|
||||
|
||||
def test_stable_hotfix_requires_reason(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "hotfix_reason is required"):
|
||||
resolver.resolve_metadata(
|
||||
version="6.0.1",
|
||||
promoted_from_tag_input="6.0.1-rc.1",
|
||||
rollback_version_input="6.0.0",
|
||||
ga_date_input="",
|
||||
v5_eos_date_input="",
|
||||
hotfix_exception=True,
|
||||
hotfix_reason_input="",
|
||||
release_notes_input="",
|
||||
tag_exists_fn=lambda tag: True,
|
||||
tag_commit_fn=lambda tag: "abc123",
|
||||
head_descends_from_fn=lambda commit: True,
|
||||
tag_created_unix_fn=lambda tag: 100,
|
||||
now_unix_fn=lambda: 100 + (2 * 3600),
|
||||
)
|
||||
|
||||
def test_stable_rejects_short_soak_without_hotfix(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "minimum is 72 hours unless hotfix_exception is true"):
|
||||
resolver.resolve_metadata(
|
||||
version="6.0.1",
|
||||
promoted_from_tag_input="6.0.1-rc.1",
|
||||
rollback_version_input="6.0.0",
|
||||
ga_date_input="",
|
||||
v5_eos_date_input="",
|
||||
hotfix_exception=False,
|
||||
hotfix_reason_input="",
|
||||
release_notes_input="",
|
||||
tag_exists_fn=lambda tag: True,
|
||||
tag_commit_fn=lambda tag: "abc123",
|
||||
head_descends_from_fn=lambda commit: True,
|
||||
tag_created_unix_fn=lambda tag: 100,
|
||||
now_unix_fn=lambda: 100 + (2 * 3600),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1371,7 +1371,7 @@ 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("`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("`rc.N` is the release train's candidate for stable", 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)
|
||||
@@ -2962,7 +2962,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
|
||||
self.assertIn('--arg draft_only "false"', helper)
|
||||
self.assertNotIn("--argjson", helper)
|
||||
self.assertIn("Single-Build Release Path", policy)
|
||||
self.assertIn("Routine Stable Patch Path", policy)
|
||||
self.assertIn("## Stable Patch Path", policy)
|
||||
self.assertIn("single publish workflow performs the exact-SHA preflight", normalize_ws(policy))
|
||||
self.assertIn("An asynchronous dispatch or manual SSH deployment is not release completion.", normalize_ws(contract))
|
||||
|
||||
|
||||
@@ -555,7 +555,7 @@ def resolve_metadata(
|
||||
promoted_tag_ts = release_published_unix_fn(promoted_from_tag)
|
||||
soak_hours_value = int((now_unix_fn() - promoted_tag_ts) / 3600)
|
||||
soak_hours = str(soak_hours_value)
|
||||
# The seven-day minor-release cadence starts at v6.5.0.
|
||||
# The release train's exact-candidate and soak rules start at v6.5.0.
|
||||
# Source binding below applies independently to every future stable.
|
||||
train_governed = bool(stable_version and stable_version >= RELEASE_TRAIN_MIN_VERSION)
|
||||
# Every new stable promotion must ship its observed source, including
|
||||
@@ -581,7 +581,7 @@ def resolve_metadata(
|
||||
raise ValueError(
|
||||
f"Stable promotion {tag} would ship content that {promoted_from_tag} never soaked "
|
||||
f"({len(candidate_content_drift)} paths beyond release metadata: {shown}). "
|
||||
"Cut another release candidate from the release branch, or use hotfix_exception "
|
||||
"Cut a fresh release candidate from main, or use hotfix_exception "
|
||||
"with a concrete active-customer-harm reason."
|
||||
)
|
||||
elif train_governed and not stable_patch and soak_hours_value < MIN_MINOR_STABLE_SOAK_HOURS:
|
||||
|
||||
@@ -830,7 +830,7 @@ class ResolveReleasePromotionTest(unittest.TestCase):
|
||||
|
||||
|
||||
class ReleaseTrainPromotionTest(unittest.TestCase):
|
||||
"""The release train: a stable ships its soaked candidate, and minors soak a week."""
|
||||
"""The release train: a stable ships its exact candidate after a 24-hour soak."""
|
||||
|
||||
def promote(self, version: str, **overrides):
|
||||
promoted = f"{version}-rc.1"
|
||||
|
||||
Reference in New Issue
Block a user