diff --git a/docs/src/content/docs/coverage-matrix.md b/docs/src/content/docs/coverage-matrix.md index 5eb0efc..e868810 100644 --- a/docs/src/content/docs/coverage-matrix.md +++ b/docs/src/content/docs/coverage-matrix.md @@ -137,6 +137,7 @@ from a general case. | Release-only (no deploy environments) | `cascade-example-release-only` | none specific (release lane via `37`, `38`) | | No-environment library shape | covered in harness only by design | `01-no-env-repo` | | Primary plus artifact satellites (cross-repo graph) | `cascade-example-primary`, `cascade-example-artifact-a`, `cascade-example-artifact-b` | `multi-repo/*`, `21-cross-repo-callback` | +| Per-callback secrets, permissions, and OIDC posture across reusable and inline callbacks | `cascade-example-callbacks` | `orchestrate/secrets-opt-in`, `orchestrate/callback-permissions-oidc` | | External rollback entry point (`repository_dispatch`) | `cascade-example-rollback-dispatch` | `rollback/*` | The no-environment library shape is covered in the act plus gitea harness; a live diff --git a/docs/src/content/docs/workflows.md b/docs/src/content/docs/workflows.md index 862aa5b..57a12ff 100644 --- a/docs/src/content/docs/workflows.md +++ b/docs/src/content/docs/workflows.md @@ -292,6 +292,16 @@ state: `cascade status` surfaces `ref`, `base_sha`, and `patches` only when they are set. +### Reconciling a stale env branch + +Before staging the cherry-pick, the hotfix plan reconciles the `env/` branch against the environment's recorded state SHA, the trunk anchor it sits at while the environment still tracks trunk. When the branch is absent it is created on demand at that SHA; when its tip already matches, it is left untouched. When the tip has drifted, the plan either self-heals the branch back to the recorded SHA or aborts fail-closed, never cherry-picking onto a base it cannot trust. + +An interrupted hotfix run can leave an abandoned `env/` branch whose tip leads the recorded SHA with no divergence recorded behind it. Left in place, a fresh hotfix would cherry-pick onto that stale tip and open a resolution pull request that can never merge cleanly, surfacing only as a merge-poll timeout. The self-heal force-resets such an orphan branch back to the recorded SHA and lets the hotfix proceed. + +The reset is gated so it can never destroy live work. Divergence is recorded only at finalize, so a hotfix that is genuinely in flight (an open resolution pull request, real commits on `env/`) also reports as not diverged while its branch legitimately leads the base. The plan therefore resets only when both conditions hold: the environment is not diverged, and a single-flight check has run against a real repository and found no open hotfix pull request. The single-flight check inspects open pull requests whose base is `env/` and matches either the `cascade-hotfix` label (a clean resolution in progress) or the `cascade-hotfix-conflict` label (a human resolving a conflict). If either is open, the plan aborts and asks you to finalize the in-flight hotfix before re-dispatching. + +Pass `--repo owner/repo` to enable the single-flight check through `gh`. Without it the check is skipped, so the self-heal cannot fire and any stale tip aborts the run rather than being reset. With `--dry-run` the reset is planned and reported but not performed. + ### Elevating across the chain A hotfix can carry a set of commits to a target environment higher in the chain. cascade elevates the set bottom-up across every environment from the one above the first up to and including the target, so each environment that must diverge ends up running its base plus the fixes. Per environment, any commit already present (an ancestor of that environment's state SHA, or already in its `patches`) is skipped; an environment whose whole set is already present is a no-op and the chain moves on. Every commit applied to an environment is recorded in that environment's `patches`, so the recorded set reflects every fix applied there, not just the first. The first environment is never a hotfix target: a fix reaches it by merging to trunk, not by hotfix. diff --git a/internal/config/types.go b/internal/config/types.go index 387ab1b..4dbed1b 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -531,7 +531,10 @@ type ValidateConfig struct { Retries int `yaml:"retries,omitempty" json:"retries,omitempty"` TimeoutMinutes int `yaml:"timeout_minutes,omitempty" json:"timeout_minutes,omitempty"` // Job-level timeout-minutes (omits when 0) - // v1 reserved-shape per-callback fields (parse + structural validation only). + // Per-callback fields. secrets and permissions are wired into generation: + // they are emitted onto this callback's caller job (writeSecretsBlock, + // writeCallbackPermissions). runs_on and concurrency are parse and validation + // only; both are rejected on a reusable-workflow callback and never emitted. // The validate gate is a singleton, so the spec scopes optional_depends_on // (§2.11) and auto_commits (§5.5) to builds/deploys only; not here. Secrets *SecretsConfig `yaml:"secrets,omitempty" json:"secrets,omitempty"` @@ -557,7 +560,10 @@ type BuildConfig struct { Inputs map[string]interface{} `yaml:"inputs,omitempty" json:"inputs,omitempty"` EnvInputs map[string]map[string]interface{} `yaml:"env_inputs,omitempty" json:"env_inputs,omitempty"` - // v1 reserved-shape per-callback fields (parse + structural validation only). + // Per-callback fields. secrets and permissions are wired into generation: + // they are emitted onto this callback's caller job (writeSecretsBlock, + // writeCallbackPermissions). runs_on and concurrency are parse and validation + // only; both are rejected on a reusable-workflow callback and never emitted. Secrets *SecretsConfig `yaml:"secrets,omitempty" json:"secrets,omitempty"` Permissions map[string]string `yaml:"permissions,omitempty" json:"permissions,omitempty"` RunsOn *RunsOn `yaml:"runs_on,omitempty" json:"runs_on,omitempty"` @@ -617,7 +623,10 @@ type DeployConfig struct { Inputs map[string]interface{} `yaml:"inputs,omitempty" json:"inputs,omitempty"` EnvInputs map[string]map[string]interface{} `yaml:"env_inputs,omitempty" json:"env_inputs,omitempty"` - // v1 reserved-shape per-callback fields (parse + structural validation only). + // Per-callback fields. secrets and permissions are wired into generation: + // they are emitted onto this callback's caller job (writeSecretsBlock, + // writeCallbackPermissions). runs_on and concurrency are parse and validation + // only; both are rejected on a reusable-workflow callback and never emitted. Secrets *SecretsConfig `yaml:"secrets,omitempty" json:"secrets,omitempty"` Permissions map[string]string `yaml:"permissions,omitempty" json:"permissions,omitempty"` RunsOn *RunsOn `yaml:"runs_on,omitempty" json:"runs_on,omitempty"`