diff --git a/docs/cli-reference.md b/docs/cli-reference.md index b79bf14..10aa798 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -368,6 +368,88 @@ cascade promote finalize \ | `--run-id` | string | No | Workflow run ID for job query | | `--commit-push` | bool | No | Commit and push state changes | +### hotfix + +Apply a trunk commit onto an environment pinned to an older base. A hotfix targets one environment on its `env/` integration branch. The fix must already be on trunk; cascade refuses to apply a commit that is not an ancestor of trunk tip. The subcommands compute and validate the hotfix and write its final state; the cherry-pick, build, and deploy run in the generated `cascade-hotfix.yaml` workflow. See the Hotfix section of [workflows.md](workflows.md) for the full flow. + +#### hotfix plan + +Validate a hotfix request and compute the integration-branch plan. It enforces, in order: trunk ancestry of the fix, target-environment eligibility (a configured environment that is not the first; prod is allowed), no-op detection when the fix is already in the target, the single-flight open-pull-request gate, and `env/` branch reconciliation. With `--dry-run` nothing is mutated (the env branch is planned but not created). + +```bash +cascade hotfix plan \ + --commit abc1234 \ + --target-env test \ + --gha-output +``` + +##### Flags + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--config`, `-c` | string | No | Path to manifest file (default: `.github/manifest.yaml`) | +| `--key` | string | No | Top-level manifest key (default: `ci`) | +| `--commit` | string | Yes | Trunk commit (SHA or ref) carrying the fix | +| `--target-env` | string | Yes | Environment to hotfix | +| `--actor` | string | No | Actor recorded on the plan (default: `$GITHUB_ACTOR`) | +| `--remote` | string | No | Git remote env branches live on (default: `origin`) | +| `--repo` | string | No | `owner/repo` for single-flight pull-request lookup via `gh` (default: skip the check) | +| `--dry-run` | bool | No | Compute the plan without mutating anything | +| `--json` | bool | No | Output the plan as JSON | +| `--gha-output` | bool | No | Write outputs to `$GITHUB_OUTPUT` for workflow consumption | + +##### Output + +With `--json`: + +```json +{ + "target_env": "test", + "fix_sha": "abc1234...", + "branch": "env/test", + "base_sha": "def5678...", + "no_op": false, + "branch_created": true, + "hotfix_version_candidate": "v1.4.0-rc.2.hotfix.1", + "conflict_expected": false, + "protection_suggestions": ["gh api -X PUT repos/{owner}/{repo}/branches/env/test/protection ..."], + "dry_run": false +} +``` + +The GHA output writes `target_env`, `fix_sha`, `branch`, `base_sha`, `no_op`, `branch_created`, `hotfix_version_candidate`, `conflict_expected`, `dry_run`, and the `protection_suggestions` commands (as JSON and as multiline text). + +#### hotfix finalize + +Write the diverged state, tag, and release for a merged hotfix. Run after the resolution pull request merges and the build and deploy succeed. It cross-checks the merge SHA against the `env/` branch tip, allocates the next free hotfix version, snapshots the prior state into the rollback ring, writes the divergence fields and substates, commits the manifest to trunk with the rebase-retry push, and creates the hotfix tag and release object. The verb is idempotent on identical inputs: a rerun after the state already records the merge SHA is a no-op. + +```bash +cascade hotfix finalize \ + --target-env test \ + --merge-sha 1234abc \ + --fix-sha abc1234 \ + --base-sha def5678 \ + --build-result app=success \ + --deploy-result app=success +``` + +##### Flags + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--config`, `-c` | string | No | Path to manifest file (default: `.github/manifest.yaml`) | +| `--key` | string | No | Top-level manifest key (default: `ci`) | +| `--target-env` | string | Yes | Environment to finalize | +| `--merge-sha` | string | Yes | Tip of `env/` after the resolution pull request merged | +| `--fix-sha` | string | Yes | Trunk commit the hotfix carries | +| `--base-sha` | string | Yes | Trunk anchor the integration branch diverged from | +| `--actor` | string | No | Actor recorded on the state (default: `$GITHUB_ACTOR`) | +| `--dry-run` | bool | No | Validate and compute without writing state, tags, or releases | +| `--build-result` | string | No | Build result as `name=result` (repeatable) | +| `--deploy-result` | string | No | Deploy result as `name=result` (repeatable) | + +Only successful build and deploy results update the per-build and per-deploy substates. For a prerelease-environment target the hotfix release is promoted to a GitHub prerelease, superseding that environment's current prerelease object; for other environments it stays a draft. + ### next-version Calculate the next semantic version. diff --git a/docs/versioning.md b/docs/versioning.md index d8dad99..b5a79a3 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -107,6 +107,38 @@ When cascade reaches v1.0 the following guarantees apply: Older tags outside the current release line do not receive backported fixes. See [SECURITY.md](https://github.com/stablekernel/cascade/blob/main/SECURITY.md) for the security-patch policy. +## Hotfix version segment + +A hotfix applies a single trunk commit onto an environment pinned to an older trunk base (see the Hotfix section of [workflows.md](workflows.md)). The version cascade allocates for a hotfix depends on whether the environment's current version is still in flight (an rc) or already published. + +### rc-based (unpublished) base + +When the environment holds an rc version, the hotfix appends a nested `hotfix.M` segment: + +``` +v1.4.0-rc.2 -> v1.4.0-rc.2.hotfix.1 (first hotfix) +v1.4.0-rc.2.hotfix.1 -> v1.4.0-rc.2.hotfix.2 (second hotfix, stacked) +``` + +The dotted form is deliberate. Under semver precedence the pre-release field list for `v1.4.0-rc.2.hotfix.1` is `["rc", "2", "hotfix", "1"]`, which sorts strictly above `rc.2` and strictly below `rc.3`: + +``` +v1.4.0-rc.2 < v1.4.0-rc.2.hotfix.1 < v1.4.0-rc.2.hotfix.2 < v1.4.0-rc.3 +``` + +A hotfix version therefore slots cleanly between its base rc and the next rc, and it never collides with the orchestrator's rc sequence. The rc-shaped tag and draft cleanup logic matches only `vX.Y.Z-rc.N`, so it is inert on hotfix tags; hotfix tags and drafts are cleaned up explicitly when the divergence ends. + +### Published (no rc) base + +When the environment holds a published version with no rc segment (for example `v1.3.0`), a hotfix is a **normal patch bump**, not a `-hotfix.M` shape: + +``` +v1.3.0 -> v1.3.1 (first hotfix) +v1.3.1 -> v1.3.2 (next free patch) +``` + +cascade allocates the next free patch by reconciling against existing tags, so the hotfix does not collide with a patch the normal release flow may also mint. There is no `vX.Y.Z-hotfix.M` form; the nested `hotfix.M` segment applies only to rc-based, still-in-flight versions. + ## Version bump reference | Change type | CLI semver impact | `schema_version` impact | diff --git a/docs/workflows.md b/docs/workflows.md index 5d0c7b7..2704b1d 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -279,7 +279,81 @@ The framework drops the RC suffix when crossing the prerelease→release boundar ## Hotfix -Hotfix is currently handled via the standard promote workflow with `dry_run: false` and a deploy-list filter. A first-class hotfix workflow is tracked in issue #94 (direct promotion to prod with branch ancestry checks). +A hotfix applies a single trunk commit onto an environment that is pinned to an older trunk base, without dragging in the intervening commits. This is the case the standard promote flow cannot serve: promoting a pointer forward would advance the target environment past every commit between its base and the fix, which is exactly what an operator pinning that environment is trying to avoid. + +### Roll forward on trunk first (the default) + +The fix always lands on trunk first. cascade refuses to apply a commit that is not already an ancestor of trunk tip, so a hotfix never introduces a commit that exists only on a side branch. If the intervening commits between an environment's base and the fix are acceptable, the simplest answer is to merge the fix to trunk and run a normal cascade promotion: the target environment advances to a trunk SHA and nothing diverges. Reach for the hotfix workflow only when the environment must run `base + fix` and nothing else. + +### Per-environment integration branches + +When an environment genuinely needs to diverge, the hotfix is staged on a per-environment integration branch named `env/` (for example `env/test`). The branch does not exist while an environment tracks trunk; it is created on demand at the environment's recorded state SHA. The cherry-pick of the fix is staged on a working branch `hotfix//` whose base is `env/`, and a resolution pull request is opened with base `env/`. + +While an environment is diverged its state carries three additional fields, all additive and absent for environments that track trunk: + +```yaml +state: + test: + sha: # now possibly a non-trunk SHA + version: v1.4.0-rc.2.hotfix.1 # hotfix version segment + ref: env/test # the integration branch + base_sha: # the trunk anchor of the divergence + patches: [, ...] # trunk commits applied on top +``` + +`cascade status` surfaces `ref`, `base_sha`, and `patches` only when they are set. + +### Cherry-pick and resolution pull request + +A clean cherry-pick opens a pull request labeled `cascade-hotfix` with auto-merge enabled. The required checks configured on `env/` gate the merge, and the pull request is the audit record even when no human touches it. + +On conflict, the conflicted tree is committed with its conflict markers intact, the branch is pushed, and the pull request is opened labeled `cascade-hotfix-conflict`. Committing the markers makes the resolution pull request a real, checkout-able branch: the diff shows exactly where the conflict is, and a human resolves it locally by force-pushing the head branch. + +``` +git fetch && git switch hotfix// +# resolve conflicts, then +git push --force-with-lease +``` + +The pushed resolution re-runs the checks, which unblock the merge. The pull request body also carries a machine-readable trailer block so the post-merge stages do not depend on branch-name parsing alone: + +``` +Cascade-Hotfix-Target: test +Cascade-Hotfix-Source: +Cascade-Hotfix-Base: +``` + +When a conflict is resolved by hand, the resolution on `env/` and the original fix on trunk can differ. Trunk's version wins long term: the divergence is discarded, not merged back. If the manual resolution embodies a real improvement, it needs its own trunk pull request; merging `env/` back to trunk is wrong because it would introduce merge commits into a history that every SHA comparison in cascade assumes moves forward. + +### Rejoin and cleanup + +The divergence ends the next time the environment receives a normal promotion. Promote preflight verifies that the incoming trunk SHA contains every recorded patch (the regression gate). On success the divergence fields are cleared, the `env/` branch is deleted, and the hotfix tags and release objects for that base are cleaned up. Promotion is refused from a diverged environment, and promoting an older trunk SHA that would drop a recorded patch is blocked unless explicitly forced with a loud annotation. + +### Generated `cascade-hotfix.yaml` workflow + +`cascade generate-workflow` emits `cascade-hotfix.yaml` for any repository that declares two or more environments. With a single environment there is no intermediate target to hotfix onto, so nothing is emitted. + +The workflow carries two triggers in one file: + +- `workflow_dispatch` with inputs `commit` (the trunk fix SHA), `target_env` (a choice over every configured environment except the first), `pr_number` (optional, to replay an existing resolution pull request), and `dry_run`. +- `pull_request` on `types: [closed]` against `branches: ['env/*']`, with the post-merge stages gated on the pull request having merged and carrying the `cascade-hotfix` label. + +Its jobs: + +| Job | Trigger | Role | +| --- | --- | --- | +| plan | dispatch | Fetch env branches and tags, run `cascade hotfix plan`, surface branch-protection suggestions as `::notice::` lines | +| apply | dispatch (not dry-run) | Cherry-pick onto `hotfix//`, open the resolution pull request (clean auto-merges; conflict opens the labeled resolution pull request) | +| check | open pull request to `env/*` | Validate the manifest while the hotfix pull request is open | +| build | merged hotfix | Build the merge SHA, since a cherry-picked commit has no prebuilt artifact | +| deploy | merged hotfix | Deploy to the target environment, paired with a rollback job mirroring the promote workflow | +| finalize | all deploys succeed | Run `cascade hotfix finalize` to write the diverged state, tag, and release | + +Prod is a valid hotfix target. The deploy job binds to the GitHub `environment:` of the target environment, so organization protection rules (manual approval, required reviewers) apply to the hotfix deploy exactly as they do to a normal promotion. This is one mechanism, not a separate prod path. + +Branch protection on `env/*` is the operator's responsibility: cascade never creates protection rules itself, because it does not assume an admin token. When no required status checks are configured on the target `env/*` branch, the workflow **warns** rather than blocks, and the `plan` verb prints ready-to-run `gh` and `gh api` command suggestions an operator can paste to put the protections in place. + +> The `rollback_sha` output in the generated workflow is a disclosed placeholder today: the deploy and rollback jobs mirror the promote workflow's shape, and the rollback path activates once a CLI output supplies the prior SHA. ## Workflow Permissions