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:
rcourtman
2026-10-06 12:34:16 +01:00
committed by GitHub
20 changed files with 255 additions and 677 deletions
+14 -13
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+12 -32
View File
@@ -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
+12 -9
View File
@@ -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
View File
@@ -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"