engine

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 6, 2026 License: Apache-2.0 Imports: 20 Imported by: 0

Documentation

Overview

Package engine drives one promotion's four steps — branch, commit, push, PR — to completion, re-observing the remote before every action (AGENTS.md §4.1: "the world is the state"). No step trusts PromotionState.Phase or any other recorded flag to decide whether its work is done; Observe always re-derives that from the local worktree and the remote (origin, the forge) before Act is allowed to run. Killing the process at any point and re-running the same command must therefore produce exactly one branch, one commit and one PR — proven in resume_test.go against a real local git remote and an in-memory forge.

internal/engine is the one package in this milestone allowed to import both pkg/git and pkg/forge together with internal/config and pkg/gitops — it is the orchestration layer AGENTS.md §4.3 describes pkg/* as never containing.

Index

Constants

This section is empty.

Variables

View Source
var ErrWaiting = errors.New("engine: waiting")

ErrWaiting is returned by Drive when a step is Waiting: the caller should report Detail (e.g. "waiting for signing approval") and may retry later. It is not a failure.

Functions

func ArgoAppNames

func ArgoAppNames(r *gitops.Repo, targetEnv string, edits []gitops.Edit) ([]string, error)

ArgoAppNames returns the distinct, sorted set of Argo Application names in targetEnv whose family directory contains at least one edit's file. The CLI calls this once, from the same gitops.Repo Discover already produced, when building a PromotionState — mirroring RenderCommitMessage/PRTitle/RenderPRBody: a pure function of the repo's discovered structure and the plan, called once and then carried on PromotionState.ArgoApps (see its own doc comment for why carrying it does not violate "the world is the state"). An edit whose file matches no family in targetEnv is an internal inconsistency — BuildPlan only ever produces edits from occurrences it read from an env's own families — and is reported as an error naming the file and directory, rather than silently dropped.

func BranchName

func BranchName(targetEnv, id string) string

BranchName is the deterministic branch name a promotion's id names (AGENTS.md §4.1): hoist/<targetEnv>/<id>.

func CacheDir

func CacheDir() (string, error)

CacheDir is $XDG_CACHE_HOME/hoist, else ~/.cache/hoist — the XDG rule on every platform, never ~/Library. WorktreeDir(id) is CacheDir/worktrees/<id>, the location AGENTS.md §4.6 and the M3 brief's invariant 1 name explicitly.

func ClaimInFlight

func ClaimInFlight(repoFullName, targetEnv, id string) (release func(), err error)

ClaimInFlight atomically claims repoFullName/targetEnv for promotion id. On success, release removes the claim; the caller must call it once the promotion's own state file has been durably written for the first time (or immediately, on any error path that never reaches that point) — see this file's package doc comment for why the claim's lifetime is deliberately short.

On conflict — a claim already exists at this path, whether it looks live or looks stale by age, or can't be read/parsed at all — err is always a plain, actionable error naming the target env and the existing claim's age (or that its age couldn't be determined). This file no longer distinguishes those cases by auto-deleting and retrying (see the package doc comment for why); every one of them is the same answer: a human needs to look, and either wait or remove the claim file manually.

func ClaimPath

func ClaimPath(repoFullName, targetEnv string) (string, error)

ClaimPath is the claim file path for repoFullName/targetEnv, under the same $XDG_STATE_HOME/hoist/promotions/ directory StatePath keeps state files in.

func CommitTrailer

func CommitTrailer(id string) string

CommitTrailer is the commit trailer line naming the promotion, verbatim, on its own line at the end of the commit message.

func ComputeExpectedBlobs

func ComputeExpectedBlobs(ctx context.Context, g git.Git, dir string, edits []gitops.Edit) (map[string]string, error)

ComputeExpectedBlobs computes what each edited file's blob hash will be once edits are applied, reading "before" bytes from dir via the same gitops.ApplyBytes/Verify M1/M2 already use (AGENTS.md invariant 3), so the result never drifts from what gitops.Apply itself would write for the same input. It deliberately never reads a worktree that might already hold this promotion's own commit: a second call against one (a fresh PromotionState's first Observe, in particular) would try to re-apply the edit on top of its own result and fail — dir must be something that reflects the pre-edit state and stays that way across any number of calls.

CommittedStep.expectedBlobs (Observe/Act, both unchanged) calls this with dir = s.CloneDir — the PR flow's own long-standing choice, validated ahead of time by cmd/hoist's checkCloneCurrentForBase, which refuses to start the engine at all when the clone disagrees with origin/<base>. Direct mode cannot lean on that same validate-and-refuse dance for the files it never even knew to look at (a new occurrence origin/<base> gained that the clone's own disk never had — round-N finding): cmd/hoist's own direct-mode planning instead calls this directly against a throwaway snapshot of origin/<base>'s actual current tree (see discoverAtFreshBase in cmd/hoist/promote.go) and passes the result into PromotionState. ExpectedBlobs up front, so CommittedStep's own lazy computation from CloneDir is never reached for a direct-mode promotion at all.

func DeriveID

func DeriveID(repoFullName string, plan gitops.Plan) string

DeriveID computes a promotion's deterministic identity (AGENTS.md §4.1): the hash of (repoFullName, plan.TargetEnv, the plan's edits' new references). It reuses image. PromotionID unchanged — this milestone does not reimplement or vary the hash — passing every edit's New ref, duplicates included: PromotionID's own dedup step (by repo@digest) is what the fixed-vector tests in pkg/image freeze, and calling it with the plan's edits as they come is what proves this package used that function rather than a parallel one.

func Drive

func Drive(ctx context.Context, steps []Step, s *PromotionState, save func(*PromotionState) error) error

Drive runs steps in order against s, saving after every step that changes state (and stopping to save "waiting" too, so a concurrent `hoist config show`-style inspection can see it). save may be nil (tests that don't care about persistence). Drive stops at the first step that is Blocked, Waiting, or whose Act fails; a later re-invocation of Drive with the same steps and an s built the same way re-observes every step from the top — nothing here remembers where it left off beyond what Observe itself re-derives.

One exception, and the reason for the probe below: without it, that "re-observes every step from the top" rule collides with MergedStep's own Act, which deletes the promotion's branch on origin. Once a later step (M5's ArgoRefreshed/ArgoSynced/RolledOut) leaves Drive Waiting for multiple polls, every one of those later polls would re-run the full loop from Branched — including PushedStep, whose Observe finds the now-deleted branch missing and (correctly, by its own contract) re-pushes it, which in turn makes MergedStep's Observe see "branch not yet deleted" and re-run its own delete. That is a real, if individually harmless, re-push/re-delete cycle on every poll tick for as long as the rollout takes to converge — first noticed, and worked around locally with a widened poll.argo, in TestResumeRebuildsArgoAppsForALegacyStateFile (cmd/hoist/resume_test.go). The fix mirrors ObserveAll/Status's own short-circuit (see their doc comments): MergedStep's Observe re-verifies, from the world, that the whole core chain up to and including the merge still genuinely holds (FindPR, ancestry via mergeWasReverted, and the branch's absence) — that is self-contained proof, and re-deriving Branched..Approved on top of it adds nothing. So: probe MergedStep's Observe once, out of order, before the main loop, and when it comes back cleanly Satisfied, skip straight past it — Branched through Merged are not re-Observed or re-Acted this pass. The probe only runs once s.Phase (an advisory hint only, same as pollInterval's own use of it — never trusted as proof) shows a prior Drive call already reached Merged or beyond, so the earlier, still-in-progress ticks (waiting on CI or approval) pay no extra Observe call for a step that cannot possibly be satisfied yet. If the probe comes back anything other than cleanly Satisfied — including a real revert caught by mergeWasReverted's ancestry check — its Observation is reused rather than re-fetched when the main loop reaches that step in its own turn, so the probe never costs a duplicate real call either way.

func Marker

func Marker(id string) string

Marker is the PR body's identity line, verbatim: a PR is findable by searching for exactly this string even if its branch was renamed or recreated (AGENTS.md §4.1, invariant 5). It must be the first line of the rendered body — RenderPRBody enforces that.

func PRTitle

func PRTitle(plan gitops.Plan) string

PRTitle renders the PR title for plan.

func RenderCommitMessage

func RenderCommitMessage(id string, plan gitops.Plan) string

RenderCommitMessage renders the commit message for plan, identified by id: a summary, then the hoist-id trailer on its own line at the end (AGENTS.md invariant 5).

func RenderPRBody

func RenderPRBody(id string, plan gitops.Plan) string

RenderPRBody renders the pull request body for plan, identified by id. AGENTS.md invariant 5 requires the marker to be the body's exact first line; invariant 6 requires the result to carry nothing scripts/public-safety.sh would flag — a table of image/from/to, occurrences updated, target-only images left, and the plan's own warnings, all values gitops.Plan and pkg/image already produce for `hoist plan`'s dry-run output, nothing new. template_test.go asserts the public-safety patterns never appear in the rendered result.

func SaveState

func SaveState(path string, s *PromotionState) error

SaveState writes s to path atomically: a temp file in the same directory, then rename, so a process killed mid-write never leaves partial JSON where the next run will look (Known bug classes: "A state file write that isn't atomic"). The file states its own permissions explicitly (AGENTS.md §8) rather than inheriting the umask — 0600, since it names local paths and, once M4 lands, will sit next to CI/approval state a stranger on a shared machine has no business reading.

func StateDir

func StateDir() (string, error)

StateDir is $XDG_STATE_HOME/hoist, else ~/.local/state/hoist — the XDG rule on every platform, never ~/Library (mirrors internal/config.DefaultPath's rule for the config file).

func StatePath

func StatePath(id string) (string, error)

StatePath is where SaveState/LoadState keep one promotion's state, keyed by its id.

func WorktreeDir

func WorktreeDir(id string) (string, error)

WorktreeDir is the worktree path for promotion id, under CacheDir.

Types

type ApprovedStep

type ApprovedStep struct {
	Forge forge.Forge
	Git   git.Git
}

ApprovedStep enforces R-001: the author of an approval comment is checked by GitHub login via the API, never the comment body, and bots are excluded (invariant 2).

func (ApprovedStep) Act

Act implements Step: nothing to do. An approval is a human commenting, not this step's job.

func (ApprovedStep) Name

func (ApprovedStep) Name() StepName

Name implements Step.

func (ApprovedStep) Observe

Observe implements Step. An env whose approval mode is "auto" is satisfied immediately (no comment required) — ApprovedStep trusts RepoConfig.Approval's own guarantee that a production env can never resolve to auto by a config default (§4.5) rather than re-deriving it, since duplicating that rule here would be the same enforcement in two places with no way to keep them in sync (AGENTS.md §8, "layered checks"). Otherwise: the newest allowed-author comment matching approve or reject, posted at or after the head commit's own committer date (this type's own doc comment above explains the anchor), decides it — reject wins when it is the newer of the two (Known bug classes: a correctly-typed reject after a correctly-typed approve must win; a typo'd reject must never match at all, since the pattern is exact).

type ArgoRefreshedStep

type ArgoRefreshedStep struct{ Argo argo.Argo }

ArgoRefreshedStep asks Argo CD to look at the merged commit sooner than its own poll interval would. See this file's package doc comment for the Observe strategy and the idempotent-refresh reasoning.

func (ArgoRefreshedStep) Act

Act implements Step: annotates every Application this promotion touches. Annotating one already-reconciled Application (Observe found some pending, not necessarily all) is harmless — see this file's package doc comment on why a redundant refresh costs an API call, never a second real action.

func (ArgoRefreshedStep) Name

func (ArgoRefreshedStep) Name() StepName

Name implements Step.

func (ArgoRefreshedStep) Observe

Observe implements Step.

type ArgoSyncedStep

type ArgoSyncedStep struct{ Argo argo.Argo }

ArgoSyncedStep is satisfied once every Application this promotion touches has synced to exactly this promotion's own merge commit and is healthy (invariant 3: revision match alone, or sync/health alone, never satisfies without the other).

func (ArgoSyncedStep) Act

Act implements Step: nothing to do. Syncing is Argo's own auto-sync/self-heal acting on the merged commit hoist already asked it to refresh toward; hoist has no separate "sync" call to make (AGENTS.md §4.7 — Argo is driven by the refresh annotation alone).

func (ArgoSyncedStep) Name

func (ArgoSyncedStep) Name() StepName

Name implements Step.

func (ArgoSyncedStep) Observe

Observe implements Step. Health Degraded, or an operation phase of Failed/Error, Blocks immediately for that Application — never waits out the deadline first (invariant 3) — regardless of what its own revision currently reads, since a promotion has no business declaring itself synced-and-rolled-out while the app it just changed is unhealthy.

type BlockedError

type BlockedError struct {
	Step   StepName
	Reason string
}

BlockedError is returned by Drive when a step's Observe reports Blocked: a genuine conflict that retrying will not resolve (AGENTS.md named adversary: a same-name branch already on origin with different content). The caller should surface Reason to the operator rather than retry automatically.

func (*BlockedError) Error

func (e *BlockedError) Error() string

type BranchedStep

type BranchedStep struct{ Git git.Git }

BranchedStep ensures a linked worktree exists for the promotion, on its branch, based on Base.

func (BranchedStep) Act

Act implements Step.

func (BranchedStep) Name

func (BranchedStep) Name() StepName

Name implements Step.

func (BranchedStep) Observe

Observe checks the local worktree registry, not the remote: "branched" is about this process's own worktree, which nothing but this promotion's own runs ever create or reuse. A file existing at "<WorktreeDir>/.git" is not by itself proof of anything — a stale pointer file from an unrelated prior state, or one that happens to resolve into the right clone's git dir but on the wrong branch, would satisfy that check while pointing Act's subsequent git add/commit at the wrong repository or the wrong branch. WorktreeBranch asks git's own worktree registry instead: satisfied only when WorktreeDir is registered against CloneDir AND checked out on exactly s.Branch (Known bug classes: recovering from a stale directory; trusting a filesystem shape instead of the registry that shape is supposed to reflect).

type CIGreenStep

type CIGreenStep struct {
	Forge forge.Forge
	// Now is injectable so tests can assert the grace-period boundary precisely; nil means
	// time.Now.
	Now func() time.Time
}

CIGreenStep is satisfied once the pushed head sha's checks are all green, under the configured ci.none policy when nothing has been reported at all (R-003).

func (CIGreenStep) Act

Act implements Step: nothing to do. CI runs itself; hoist only observes it.

func (CIGreenStep) Name

func (CIGreenStep) Name() StepName

Name implements Step.

func (CIGreenStep) Observe

Observe implements Step. total>0 && pending==0 && failure==0 && skipped==0 is satisfied (invariant 1's condition, extended: see below); failure>0 is always Blocked, named by check-run name when the forge can give one; skipped>0 is likewise always Blocked — a `skipped` conclusion means a check-run never actually ran at all (a path filter, a conditional job), and forge.CheckSummary carries no required-vs-optional distinction that would let this step tell "safely skipped" from "a required gate that silently never ran", so a skipped run is treated as a hard gate, not silently folded into green (AGENTS.md §2 principle 5's own stated exception: "warn, don't block, except where the runbook blocks" — a required check skipped out from under a promotion is exactly that case). total==0 is a grace-period Waiting, then the ci.none policy: green satisfies, prompt Blocks with an override path (CINoneOverride, set by `hoist resume --override-ci-none`), block Blocks with none at all — an operator who chose block gets no in-band bypass, only "wait for real checks" or "abandon this promotion and start one under a different ci.none" (a config change never reaches a promotion already under way — PromotionState's policy fields), which is the entire point of choosing the stricter of the two non-green policies over the milder one.

type CommittedStep

type CommittedStep struct {
	Git git.Git
	// OnWaiting is called (at most once per Act) when the commit has not returned within 5s
	// — the interactive 1Password SSH-sign prompt. May be nil.
	OnWaiting func()
}

CommittedStep applies the plan's edits to the worktree (via gitops.Apply, which re-verifies before every write — AGENTS.md invariant 3, "Verify runs before git add, always" — unmodified from M1/M2) and commits them.

func (CommittedStep) Act

Act implements Step.

func (CommittedStep) Name

func (CommittedStep) Name() StepName

Name implements Step.

func (CommittedStep) Observe

Observe implements Step.

type DirectCommitGateStep

type DirectCommitGateStep struct {
	// ProductionEnvs is RepoConfig.Envs.Production, verbatim.
	ProductionEnvs []string
	Confirmed      bool
}

DirectCommitGateStep is the sole enforcement point for AGENTS.md invariant 5/6: direct mode is a distinct commit path that must be unreachable for a production env "by construction, not convention", and no config combination may weaken that.

The mechanism: this step runs first in DirectSteps' list, before BranchedStep/CommittedStep/ DirectPushedStep ever touch a worktree or the remote. Its Observe reports either Satisfied (both conditions below hold, so Drive proceeds to the steps that actually write) or Blocked (either fails); Drive (engine.go) stops at the first Blocked step and never calls a later step's Observe or Act at all — so a production env's block here is not "this step declines to act", it is "nothing after this step in the list ever runs". Act re-checks the identical two conditions before doing anything (belt and suspenders, matching appendHistory's own pattern in engine.go): Drive only calls Act when Observe reported not-Satisfied-and-not- Blocked, which this step's Observe never returns, so Act is not reachable through Drive at all — the check is duplicated there anyway so that a caller who builds a *BlockedError-blind driver of their own (bypassing Drive) still cannot reach a production commit by calling Act directly.

  • (a) s.TargetEnv must not be listed in ProductionEnvs. ProductionEnvs must always be the repo's full, unfiltered RepoConfig.Envs.Production — see the doc comment on DirectSteps for why passing anything narrower here would defeat this entirely, and AGENTS.md invariant 6 for why no additional config field is needed or wanted: "not listed in envs.production" is already the one and only switch, and it is the same list that already governs the PR-required and approval-required behaviors elsewhere (AGENTS.md §4.5) — there is no second "direct allowed" toggle for a config bug or a future caller to set inconsistently with it.
  • (b) Confirmed must be true: the operator has already completed the keypress + huh. Confirm gesture invariant 5 requires (internal/app/tags). This step does not itself render or drive that UI — it only trusts the bool it was built with — so the caller that constructs DirectCommitGateStep (cmd/hoist) is the one place responsible for never setting Confirmed true except in direct response to that confirmed gesture (or, at the CLI, its own explicit two-flag equivalent — see cmd/hoist's own doc comment on runPromote's --direct/--confirm-direct=<env> flags — the latter must equal --to exactly, refused otherwise; see runPromote's own doc comment).

func (DirectCommitGateStep) Act

Act implements Step. Not reachable through Drive (see the type doc comment) — kept as a second, independent check in case anything outside this package ever calls Act without going through Drive/Observe first.

func (DirectCommitGateStep) Name

Name implements Step.

func (DirectCommitGateStep) Observe

Observe implements Step. It is intentionally never "not satisfied but not blocked" — see the type's own doc comment for why that shape is what makes the later steps unreachable through Drive whenever this step refuses.

type DirectPushedStep

type DirectPushedStep struct{ Git git.Git }

DirectPushedStep is direct mode's publish step: it pushes the promotion's own worktree branch (built by BranchedStep/CommittedStep exactly as the PR flow builds it) straight onto s.Base on origin, via git.Git.PushHeadTo, instead of opening a PR from it. Its Observe/Act only ever reference s.Base as the remote ref that must move — never s.Branch, which in direct mode is purely a local staging name inside the worktree and is never pushed under its own name (PushHeadTo's own doc comment explains why: s.Base is very likely already checked out in the user's own clone, and git refuses to check that branch name out a second time in this promotion's own worktree).

func (DirectPushedStep) Act

Act implements Step.

func (DirectPushedStep) Name

func (DirectPushedStep) Name() StepName

Name implements Step.

func (DirectPushedStep) Observe

Observe implements Step: satisfied when origin's Base ref already carries what this promotion actually planned to write — either because the tip IS this promotion's own commit (the common case, checked by exact equality first) or because every one of this promotion's planned paths already matches its planned content at whatever the tip currently is (AGENTS.md gotcha, same class as MergedStep's own revert check elsewhere in this milestone's history).

This deliberately compares planned blob CONTENT at the tip, never mere object-graph ANCESTRY (an earlier revision of this method used git.Git.IsAncestor instead — reverted here; see this package's own doc.go for the history). Ancestry alone cannot tell two cases apart that need opposite answers: "Base advanced further with a distinct, later, legitimate change" (this promotion's own commit is still an ancestor, AND the planned content is still genuinely there — Satisfied is correct) from "someone git-reverted this exact promotion's commit" (this promotion's own commit remains an ancestor forever — a revert commit never removes it from history — but the file content it changed is no longer at the tip; treating that as Satisfied would let a re-run of the identical promotion exit "successfully" without restoring anything). Comparing content directly gets both right without needing the ancestry relationship at all: a legitimate later change that never touches these paths still matches, and a revert (or any other rewrite) that changes them back no longer does.

A Base ref that exists but doesn't yet carry the planned content is reported unsatisfied, not Blocked — Act's own push is what actually discovers whether that is "not pushed yet" or "a genuine conflict" (mirroring PushedStep's shape one step later, since direct mode has no separate branch push to observe first).

type HistoryEntry

type HistoryEntry struct {
	Step   StepName
	At     time.Time
	Detail string
}

HistoryEntry is one line of a promotion's audit trail: what Observe/Act found, and when. History is informational only — Observe never reads it to decide anything (AGENTS.md §4.1: the state file, History included, is an index of what to look at, never evidence of what happened).

type MergedStep

type MergedStep struct {
	Forge forge.Forge
	Git   git.Git
}

MergedStep enforces R-003's neighbor: merge only once Approved is satisfied (guaranteed by AllSteps' ordering, never re-checked here — AGENTS.md §8, layered checks) and refuse a stale head using the forge's own atomic "merge iff head is X" (Known bug classes: no client-side check-then-merge race). Observe reports done only once the PR is both merged *and* its branch is gone, so a process killed between a successful merge and the branch delete resumes into Act again rather than reporting done prematurely; Act itself re-checks FindPR before treating a failed MergePR call as a real failure (the named adversary: "did the merge actually happen server-side even though the client never saw the response").

func (MergedStep) Act

Act implements Step.

func (MergedStep) Name

func (MergedStep) Name() StepName

Name implements Step.

func (MergedStep) Observe

func (m MergedStep) Observe(ctx context.Context, s *PromotionState) (Observation, error)

Observe implements Step. A merged PR record is historical evidence, not proof the promotion still holds (M4 hardening, finding #1): mergeWasReverted revalidates that the merge commit itself is still part of s.Base's live current history before pr.Merged is trusted at all, so a base reset outside hoist after a real merge is caught rather than silently reported as success on a re-run of the same promotion (same deterministic id/branch/marker, same already-merged PR) — see mergeWasReverted's own doc comment for why ancestry, not blob content, is the correct test (round 3, finding #2: a blob comparison would misclassify an ordinary later re-promotion to the same env, which legitimately changes the same paths, as a revert).

type Observation

type Observation struct {
	Satisfied bool
	Waiting   bool
	Detail    string
	Blocked   string
}

Observation is what Observe reports before Drive decides whether to call Act.

  • Satisfied: the step's goal already holds in the world; Act must not run.
  • Waiting: the step is in progress on something external and interactive (signing approval) — Drive stops here without error so the caller can retry later; it is not a failure.
  • Blocked: the step cannot proceed and retrying will not help — a real conflict (someone else moved the branch to different content), not a transient failure. Drive stops and reports it as a terminal *BlockedError, distinct from a plumbing error from Observe itself.
  • Detail: a short human-readable note for History and CLI output; never a substitute for Satisfied/Waiting/Blocked in code that branches on the result.

type PROpenedStep

type PROpenedStep struct{ Forge forge.Forge }

PROpenedStep finds or opens the promotion's pull request.

func (PROpenedStep) Act

Act implements Step.

func (PROpenedStep) Name

func (PROpenedStep) Name() StepName

Name implements Step.

func (PROpenedStep) Observe

Observe implements Step. A PR FindPR turns up by head branch name alone is only ever adopted as this promotion's own once its base also matches s.Base (M4 hardening): the head branch name is deterministic from the promotion id (BranchName), but nothing stops a second, unrelated PR being opened from that exact same branch name onto a *different* base (someone else's manual PR, or a stale one from a config change that moved TargetEnv's base) — adopting that PR here would go on to have MergedStep squash-merge it into the wrong target while still reporting success. Refusing to adopt is a Blocked, not a silent skip, so the operator sees the conflict named rather than hoist quietly trying to open a second PR for the same branch (which the forge would then itself refuse).

type PromotionState

type PromotionState struct {
	ID, RepoFullName, SourceEnv, TargetEnv string
	Branch                                 string
	ExpectedBlobs                          map[string]string // repo-relative path -> expected git blob hash after Apply
	CommitSHA, PushedSHA                   string
	PR                                     *forge.PR
	// Direct records that this promotion was started with --direct (M6): committed straight to
	// Base with no branch pushed to origin and no PR. Every other field is identical between
	// the two modes and nothing else can tell them apart after the fact — DirectPushedStep
	// pushes to Base, so Branch is set but was never pushed, which is indistinguishable from a
	// PR promotion that died before PushedStep ran. Without this field, resuming a direct
	// promotion re-observes it through AllSteps, finds Branch missing on origin, and pushes it
	// and opens a PR — the exact thing the operator asked not to happen (Codex review, PR #43).
	Direct      bool
	Phase       StepName // an index/hint only — Observe never trusts it, see AGENTS.md §4.1
	History     []HistoryEntry
	GeneratedAt time.Time

	// CloneDir is the user's own clone (--repo). WorktreeDir is the linked worktree this
	// promotion's own steps operate in, under $XDG_CACHE_HOME/hoist/worktrees/<id>. Base is
	// the branch the worktree and the PR are based on (the clone's default branch).
	CloneDir, WorktreeDir, Base string

	// Edits are the gitops.Plan's edits this promotion writes, unmodified from what M1/M2's
	// BuildPlan produced (AGENTS.md invariant 3 — this milestone never re-implements or
	// bypasses gitops.Apply/Verify).
	Edits []gitops.Edit

	// CommitMessage, PRTitle and PRBody are rendered once (identity.go/template.go) from the
	// plan and id, then carried here so a resumed run acts on exactly the same text it would
	// have rendered fresh — RenderPRBody and CommitMessage are pure functions of the plan and
	// id, so recomputing them is also always safe; storing them here just avoids requiring
	// the caller to keep the whole gitops.Plan around only to re-render text.
	CommitMessage   string
	PRTitle, PRBody string

	// CINone and CIGrace are RepoConfig.CI as of when this promotion started: green|prompt|block
	// and the grace duration CIGreenStep waits before applying that policy to a PR reporting
	// zero checks.
	CINone  string
	CIGrace time.Duration
	// CINoneOverride, once set (via `hoist resume --override-ci-none`), lets CIGreenStep treat
	// a still-empty check-run set as satisfied under ci.none: prompt after the grace period —
	// the explicit override invariant 1 requires. ci.none: block never consults this field: it
	// has no override path through this flag at all (see CIGreenStep's doc comment for why).
	CINoneOverride bool

	// Approval is RepoConfig.Approval(TargetEnv) as of when this promotion started: "auto" or
	// "comment". Approvers and Collaborators are RepoConfig.Approvers and .Collaborators.
	Approval      string
	Approvers     []string
	Collaborators bool

	// MergeSHA is the squash-merge commit sha, once MergedStep's Act (or a re-observed
	// already-merged PR) reports one.
	MergeSHA string

	// ArgoNamespace is where this promotion's target env's Argo Application custom resources
	// live on the cluster (RepoConfig.Kube.ArgoNamespace) — never spec.destination.namespace,
	// which TargetEnv already names (see pkg/argo's package doc). Read once when the
	// promotion is built and re-read on `hoist resume` from the current config, UNLIKE
	// CINone/CIGrace/Approval/Approvers/Collaborators just above: those gate decisions against
	// historical events (an already-recorded approval/CI comment) and must never straddle two
	// policies mid-flight, but ArgoNamespace only names where a live Get lands — re-reading it
	// lets `hoist resume` follow the Applications if an operator moves them to a different
	// namespace mid-flight, and a stale value would fail loudly (Application not found) rather
	// than silently misjudge anything (see cmd/hoist/resume.go's runResume for the same
	// reasoning at the call site).
	ArgoNamespace string
	// ArgoApps is the distinct, sorted set of Argo Application names (in TargetEnv) whose
	// family directory contains at least one of Edits' files — computed once, from
	// gitops.Discover's own Family->Application mapping, by ArgoAppNames when the promotion is
	// first built (see its doc comment), then carried unchanged across every resume. AGENTS.md
	// §4.1's "the world is the state" governs the Argo *status* ArgoRefreshedStep/
	// ArgoSyncedStep re-derive from a fresh Get on every Observe; it does not require
	// re-discovering which Application owns which family on every call, any more than it
	// requires BuildPlan to re-run on every resume — Edits is exactly this same kind of
	// carried, not re-derived, plan-time fact.
	//
	// A state file written before M5 added this field decodes it as empty, which is not the same
	// thing as a promotion computed to touch no Application (round-1 review finding): a non-empty
	// Edits with an empty ArgoApps is only possible for a state that predates ArgoAppNames ever
	// running against it, since a real call against a non-empty edit set always yields at least
	// one name or an error (see ArgoAppNames' own doc comment). cmd/hoist/resume.go's
	// ensureArgoApps repairs exactly that case, once, the first time such a state is resumed after
	// upgrading past M5 — everywhere else in this package, ArgoApps is read as carried, never
	// recomputed.
	ArgoApps []string
}

PromotionState is everything one promotion's steps read and write. It is JSON-serialised to a state file that AGENTS.md §4.1 calls "an index of what to look at, never evidence of what happened": every field an Observe method reads to decide truth also exists independently on the remote (the branch, the commit, the PR) or is deterministically re-derivable from the CLI's own inputs (CloneDir, Base, Edits, the rendered messages) — so deleting this file and rebuilding PromotionState the same way (same --repo/--from/--to and the same resolved digests) reproduces the same id, branch and marker, and Observe finds the same already-satisfied steps on the remote. See DeriveID's doc comment and the CLI wiring in cmd/hoist for exactly what is recomputed versus loaded.

Beyond the brief's listed shape, this adds the fields Act needs to do its work — CloneDir, WorktreeDir, Base, Edits, CommitMessage, PRTitle, PRBody — since a step cannot act on a promotion it cannot locate or a Plan it does not carry. None of them are secret or unbounded (AGENTS.md §4.3): they are local paths, a branch name, and rendered text already destined for a public commit/PR.

func ListStates

func ListStates() ([]*PromotionState, error)

ListStates reads every promotion state file under $XDG_STATE_HOME/hoist/promotions/, sorted by ID for a stable listing. A missing promotions directory is not an error: it returns (nil, nil), the same as "no promotions started yet". Reading it is purely informational — the caller (hoist resume, and the one-in-flight-per-env check at the start of hoist promote) still re-observes each promotion's steps against the remote before trusting anything about its current phase; this only recovers the set of IDs and the CloneDir/TargetEnv/Branch needed to rebuild each one's Steps and re-observe it (AGENTS.md §4.1 — Phase itself is never trusted).

func LoadState

func LoadState(path string) (*PromotionState, error)

LoadState reads the state file at path. A missing file is not an error: it returns (nil, nil), since a state file is only ever a cache of History — Observe never needs it to determine truth (AGENTS.md invariant 4; see PromotionState's doc comment).

func (*PromotionState) LandedSHA

func (s *PromotionState) LandedSHA() string

LandedSHA is the commit this promotion put on the branch Argo actually tracks — the one the M5 steps compare an Application's status.sync.revision against. The two modes reach it differently and neither field alone is the answer:

  • A PR promotion lands via MergeSHA, the squash commit MergedStep records on Base. Its PushedSHA names a commit on the promotion branch, which Argo never tracks.
  • A direct promotion (M6) never merges anything, so MergeSHA stays empty forever. Its PushedSHA IS on Base, because DirectPushedStep pushes there rather than to a branch — and is the base tip carrying this promotion's content, re-derived on each observation, not the original commit object (see DirectPushedStep.Observe for why the distinction is the difference between converging and waiting out the deadline).

Deriving it rather than persisting a third field keeps one source of truth per mode and needs no migration for state files written before direct mode existed: Direct is false for every one of them, so they resolve to MergeSHA exactly as they always did.

type PushedStep

type PushedStep struct{ Git git.Git }

PushedStep pushes the worktree's branch to origin.

func (PushedStep) Act

Act implements Step.

func (PushedStep) Name

func (PushedStep) Name() StepName

Name implements Step.

func (PushedStep) Observe

func (p PushedStep) Observe(ctx context.Context, s *PromotionState) (Observation, error)

Observe implements Step.

type RolledOutStep

type RolledOutStep struct{ Rollout rollout.Rollout }

RolledOutStep is satisfied once every Deployment this promotion edited carries the new image in every occurrence it wrote and its rollout is complete by kubectl's own definition (invariant 4). A rollout that has exceeded its own progress deadline Blocks, the same immediacy ArgoSyncedStep applies to Degraded health — retrying will not fix a deployment that is never coming up. A missing Deployment (rollout.ErrNotFound) Blocks the same way, naming the Deployment — mirroring ArgoRefreshedStep/ArgoSyncedStep's own errorsIsNotFound handling of a missing Application, for the same reason: retrying cannot make a deleted object reappear, and a generic plumbing error would read as "something is broken" rather than "this object is gone" (round-1 review finding). Any other error reading a Deployment (a transient API hiccup) still propagates as a plain error, for the CLI's poll loop to retry.

Jobs and CronJobs this promotion touched are listed, never gated on: any error reading one (not found — a short ttlSecondsAfterFinished or an Argo hook's deletion policy can GC a Job before this Observe gets to it — or transient) becomes a report line and the loop continues, rather than a hard error that would gate the whole promotion on a status this step's own contract says it never gates on (round-1 review finding).

func (RolledOutStep) Act

Act implements Step: nothing to do. The rollout is the kubelet/Deployment controller acting on the manifest Argo already synced; hoist only ever observes it (AGENTS.md invariant 4 of M1-M4's own CIGreen/Approved precedent — "there is nothing for hoist itself to do about CI running", the same shape here for a rollout already in motion).

func (RolledOutStep) Name

func (RolledOutStep) Name() StepName

Name implements Step.

func (RolledOutStep) Observe

Observe implements Step.

type Step

type Step interface {
	Name() StepName
	Observe(ctx context.Context, s *PromotionState) (Observation, error)
	Act(ctx context.Context, s *PromotionState) error
}

Step is one stage of a promotion. Observe must have no side effects beyond querying the local worktree and/or the remote; only Act is allowed to change anything.

func AllDirectSteps

func AllDirectSteps(g git.Git, a argo.Argo, ro rollout.Rollout, productionEnvs []string, confirmed bool, onWaiting func()) []Step

AllDirectSteps is DirectSteps plus the Argo/rollout convergence both modes share — the direct mirror of CoreSteps/AllSteps, and what `hoist promote --direct` and `hoist deploy --direct` actually drive. The split exists for the same reason the PR path's does: DirectSteps is the git-only core, useful on its own in tests that exercise the gate and the push without a cluster, while the exported pairing keeps a caller from silently driving a promotion that lands a commit and then never tells Argo about it (issue #66).

func AllSteps

func AllSteps(g git.Git, f forge.Forge, a argo.Argo, ro rollout.Rollout, onWaiting func()) []Step

AllSteps returns every step a promotion drives through, in order: CoreSteps' seven (branch, commit, push, PR, CIGreen, Approved, Merged) then ArgoRefreshed, ArgoSynced and RolledOut (M5). `hoist promote` and `hoist resume` always drive AllSteps to completion.

func ConvergeSteps

func ConvergeSteps(a argo.Argo, ro rollout.Rollout) []Step

ConvergeSteps is the post-landing tail both modes share: ask Argo to refresh, wait for it to agree with what landed, then watch the Deployments roll. Extracted so DirectSteps drives the identical three rather than a copy — the design has always said direct mode converges through Argo too ("Pushed -> ArgoRefreshed -> ..."), and it only ever stopped at the push because every step here used to gate on MergeSHA, which a direct push never produces (issue #66).

func CoreSteps

func CoreSteps(g git.Git, f forge.Forge, onWaiting func()) []Step

CoreSteps returns the seven steps a promotion drives through up to and including the merge: Steps' four (branch, commit, push, PR) plus CIGreen, Approved and Merged. This is exactly the step list (and signature) `AllSteps` had before M5 — see steps_m4.go's own trailing comment — kept alive under a new name because M5 needed the name `AllSteps` for the ten-step list below. It exists for one caller: `findInFlight` in cmd/hoist/drive.go, which deliberately observes only through Merged when deciding whether a promotion still counts as "in flight" for AGENTS.md invariant 5 — see that function's own doc comment for the reasoning. `hoist promote` and `hoist resume` never call this directly; they always drive `AllSteps` to real completion.

func DirectSteps

func DirectSteps(g git.Git, productionEnvs []string, confirmed bool, onWaiting func()) []Step

DirectSteps returns the steps a direct-mode promotion drives: the production/confirmation gate, then the same branch-and-commit steps the PR flow uses (BranchedStep, CommittedStep — unmodified), then DirectPushedStep in place of PushedStep+PROpenedStep.

productionEnvs MUST be RepoConfig.Envs.Production passed through exactly as loaded, never filtered, narrowed, or recomputed by the caller — DirectCommitGateStep's whole guarantee rests on this list actually being the one config authority that also governs PR-required and approval-required elsewhere (AGENTS.md §4.5); a caller that "helpfully" pre-filters it (e.g. "only pass the envs relevant to this repo") reintroduces exactly the config-bug risk invariant 6 asks to be structurally impossible. confirmed must be true only in direct response to the operator's own keypress + huh.Confirm gesture (internal/app/tags) or, at the CLI, its documented equivalent (cmd/hoist) — never a default, never inferred from anything else in the promotion.

func ObserveSteps

func ObserveSteps(s *PromotionState, g git.Git, f forge.Forge, a argo.Argo, ro rollout.Rollout, onWaiting func()) []Step

ObserveSteps is the step list to observe a PRIOR promotion state by, chosen from the state itself rather than from what the caller happens to be doing now. Three callers ask the same question of a state file they did not create — findInFlight ("is another promotion still running for this env?"), `hoist promotions` and `resume --env`'s candidate scan — and all three used a fixed list, which for a direct state is a list it can never satisfy: a direct promotion pushes to the base branch, so PushedStep's branch on origin, PROpenedStep's PR and MergedStep's merge are all permanently unsatisfied. The consequence was not cosmetic: one completed direct run made findInFlight refuse every later promotion into that env forever, and left the finished run listed as in flight.

DirectCommitGateStep is deliberately NOT among the direct list here. The gate decides whether a direct promotion may START; re-running it while observing one that already landed would let a later config edit (an env newly listed under envs.production) turn a finished run into a permanently blocked one — a state file re-interpreted by today's config rather than observed. Refusing a new direct promotion is the gate's job, and it still runs first in DirectSteps where that decision is actually made (AGENTS.md §4.5, R-007).

through is where to stop: pass nil for the git-only core (findInFlight's own "the branch/PR collision risk is retired" boundary — see its doc comment), or a non-nil argo/rollout pair for the full list. The PR path's boundary is Merged; the direct path's is the push.

func Steps

func Steps(g git.Git, f forge.Forge, onWaiting func()) []Step

Steps returns the four steps, in order, wired to g and f.

type StepError

type StepError struct {
	Step StepName
	Op   string // "observe" or "act"
	Err  error
}

StepError wraps a plain (non-Blocked, non-Waiting) error a step's Observe or Act returned, naming which step and which of the two failed. Added in M4 so a caller (the CLI's poll loop) can tell a step apart before deciding whether the error is worth retrying — a Checks/Comments call erroring on CIGreen or Approved (Known bug classes: a 404 or permissions hiccup, which should be retried, not read as authoritative) looks very different from a git/GitHub operation failing on an earlier step (terminal — waiting will not fix a broken git binary or a rejected push). Error()'s text is unchanged from Drive's pre-M4 format ("<step>: observe: " / "<step>: act: " prefix), so nothing that only inspected the message is affected.

func (*StepError) Error

func (e *StepError) Error() string

func (*StepError) Unwrap

func (e *StepError) Unwrap() error

type StepName

type StepName string

StepName identifies one of the four steps a promotion drives through, in order.

const (
	StepDirectGate   StepName = "direct-gate"
	StepDirectPushed StepName = "direct-pushed"
)

StepDirectGate and StepDirectPushed are direct mode's own two steps (AGENTS.md M6 brief, "Direct mode"). They run instead of — never alongside — StepPushed and StepPROpened: DirectSteps assembles a disjoint step list from Steps, and nothing in this package ever combines the two. StepBranched and StepCommitted are shared unchanged: a direct-mode promotion still needs a worktree and a commit, exactly as the PR flow does (BranchedStep, CommittedStep — untouched by this file), only the publish step differs.

const (
	StepBranched  StepName = "branched"
	StepCommitted StepName = "committed"
	StepPushed    StepName = "pushed"
	StepPROpened  StepName = "pr-opened"
)

The four steps a promotion drives through, in order: a worktree and branch, a commit on it, a push of that commit to origin, and a pull request for that branch.

const (
	StepCIGreen  StepName = "ci-green"
	StepApproved StepName = "approved"
	StepMerged   StepName = "merged"
)

The three M4 steps, run after PROpened in that order (AllSteps): CI must go green before a human approval is even asked for, and merging only ever follows both.

const (
	StepArgoRefreshed StepName = "argo-refreshed"
	StepArgoSynced    StepName = "argo-synced"
	StepRolledOut     StepName = "rolled-out"
)

The three M5 steps, run after Merged in that order: Argo must see the merged commit before anyone asks whether it synced, and syncing precedes asking whether the rollout it drove has actually landed.

type StepStatus

type StepStatus struct {
	Step StepName
	Observation
}

StepStatus is one step's re-observed state — never read from Phase, always from a fresh Observe call. Returned by ObserveAll for a listing (`hoist resume`'s startup listing) or a one-in-flight-per-target-env check (`hoist promote`'s refusal), neither of which should call Act.

func ObserveAll

func ObserveAll(ctx context.Context, steps []Step, s *PromotionState) (done bool, last StepStatus, err error)

ObserveAll re-derives every step's Observation in order, stopping at the first that is not cleanly Satisfied (Waiting or Blocked included) — the read-only half of what Drive does, without ever calling Act. done reports whether every step was Satisfied; last is the stopping point's own status (or the final step's, when done). A promotion is "in flight" exactly when done is false: still has real work ahead of it, is waiting on something external, or is blocked and needs operator attention — any of which means a second promotion for the same target env must not start (AGENTS.md §4.1: re-observe, never trust the state file's own Phase, for this question too).

The last step is checked first, as a short-circuit: MergedStep's own Observe (merged AND its branch deleted) is self-contained proof the whole promotion finished, and is deliberately the only step whose Act *removes* something an earlier step's Observe depends on for its own Satisfied condition (PushedStep's Observe requires the branch to still exist on origin — true throughout the promotion, false forever after MergedStep's cleanup runs). Without this short-circuit, ObserveAll would report a fully completed, cleaned-up promotion as stuck at Pushed — wrong, and exactly backwards from what a one-in-flight-per-env check needs: it would make a *finished* promotion block every future one for the same env, forever. ObserveAll never calls Act, so its short-circuit probes the *last* step. Drive does call Act, and for a while (pre-M5) that meant it never needed a short-circuit of its own: Merged was always the last step, so Drive simply finished the moment Merged was satisfied and was never called again. M5 added steps after Merged, so a promotion now sits waiting there across many further Drive calls — each of which, without its own short-circuit, would re-hit exactly the problem this paragraph describes for ObserveAll, except with Act: PushedStep re-pushing the branch Merged just deleted, then MergedStep deleting it again, every poll tick. Drive now carries the same probe, aimed one step earlier (at Merged rather than the last step, since Merged's own Observe is the self-contained proof either way) — see Drive's own doc comment.

func Status

func Status(ctx context.Context, steps []Step, s *PromotionState) (done bool, statuses []StepStatus, err error)

Status is ObserveAll's sibling for a caller that needs to render every step's own standing, not only the stopping point: the internal/app/flight screen's step list (glyph per step, detail on whichever one is active) needs a StepStatus for each step already passed as well as the one it stopped at, which ObserveAll's single "last" return cannot carry. Status shares ObserveAll's two rules verbatim — the final-step short-circuit (see ObserveAll's doc comment: MergedStep's own Observe is self-contained proof the whole promotion finished, since PushedStep's Observe would otherwise falsely read as stuck once Merged's own Act has deleted the branch it depends on) and the stopping rule (the first step that is not cleanly Satisfied ends the walk) — only the shape of what is returned differs: statuses accumulates one entry per step actually observed, in order, rather than discarding every entry but the last.

When the short-circuit fires, statuses is the single-element slice {final step's own StepStatus} — deliberately not one entry per step, since re-observing every earlier step individually is exactly what the short-circuit exists to avoid (an already-merged, already-cleaned-up promotion would read PushedStep's Observe as false: the branch it checks is gone). A caller rendering a fully done list (done == true) should treat every step as done regardless of how many entries statuses carries, using the final entry only for its Detail — see flight.DeriveRows.

Otherwise, statuses holds exactly the steps Status reached: every entry before the last is Satisfied (the walk only continues past a step that cleanly is), and the last entry is the one Status stopped at — Blocked, Waiting, or plainly not yet Satisfied. A step whose name never appears in statuses has not been reached at all this call. The short-circuit probe above already called Observe once on the final step before the walk started (needed to even know whether to short-circuit); when the walk isn't short-circuited it reaches that same final step again in its own turn, but reuses the probe's Observation there rather than calling Observe a second time — every poll that isn't yet fully done would otherwise cost one extra, wasted remote call on the final step, every tick of the flight screen's own poll loop (PR #39 review finding #3). Both of Status's own Observe errors are returned as *StepError (Op: "observe"), not a bare fmt.Errorf, even though nothing here ever calls Act: internal/app/flight.Model's retry classifier (retryableErr, mirroring cmd/hoist/drive.go's own driveToCompletion) only retries automatically on a *StepError naming StepCIGreen or StepApproved — the two steps whose Observe alone can transiently 404/scope-error on a Checks or Comments call without the underlying condition (CI status, an approval) actually being answerable yet. A bare wrapped error carries the same message (StepError.Error()'s "<step>: <op>: <err>" format matches this function's own pre-existing "%s: observe: %w" text exactly, and Unwrap still reaches oerr, so errors.Is/the message text are both unchanged) but cannot be told apart by errors.As, which is all that classifier can use. Before this, a transient hiccup on the immediately-following Status call — after engine.Drive had itself already observed the very same step successfully as Waiting or Blocked, in cmd/hoist/wiring.go's own DriveFunc — surfaced as a plain error the flight screen read as terminal and stopped polling on for good, unlike the CLI's own driveToCompletion, which retries the identical shape of failure when Drive's own Observe hits it directly (Codex review, PR #50 round 4).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL