statewrite

package
v0.16.1-rc.0 Latest Latest
Warning

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

Go to latest
Published: Jul 15, 2026 License: Apache-2.0 Imports: 10 Imported by: 0

Documentation

Overview

Package statewrite provides the shared optimistic-lock retry used by every finalize verb (orchestrate, promote, rollback, hotfix) when it commits the manifest to the trunk branch through the GitHub Contents REST API.

Concurrent finalize jobs for different environments mutate disjoint keys in the same manifest file. They do not conflict semantically, but they collide on the file blob SHA: the second writer to PUT with a now-stale SHA gets an HTTP 409 ("does not match <sha>") and its state is dropped. CommitWithRetry closes that race by expressing the write as a read-modify-write that re-reads the current manifest, re-applies the caller's mutation on top of whatever the other writer committed, and re-PUTs, retrying on 409.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func CommitWithRetry

func CommitWithRetry(opts Options) error

CommitWithRetry performs an optimistic-locked read-modify-write of the manifest at opts.Ref. It fetches the current manifest and blob SHA, applies opts.Mutate to the current bytes, and PUTs with that SHA. On a 409 conflict or a transient PUT failure (a rate limit, an HTTP 5xx, a transport blip; see TransientError) it re-fetches, re-applies the mutation on top of the now-current manifest, and re-PUTs, up to maxAttempts with a staggered backoff, so the CAS token is always bound to the freshly fetched base. A permanent PUT failure (401 revoked token, 403 authorization, 404, 422 validation) fails fast with its real cause. It returns the last conflict or transient error when the bound is exhausted, so a genuinely stuck write still surfaces.

Because Mutate is re-applied against the freshly fetched manifest, two finalize jobs that each set only their own env's ci.state.<env> keys merge: the loser re-reads the winner's committed keys and re-applies its own on top, so the final manifest carries both.

func IsConflict

func IsConflict(err error) bool

IsConflict reports whether err is (or wraps) a Contents API 409 conflict. It recognizes the typed ConflictError and both raw gh-CLI 409 bodies, so a client that forwards the gh error verbatim still triggers a retry: the blob If-Match mismatch carries "does not match", and the branch-ref compare-and-swap failure two racing finalizes produce reads "... is at X but expected Y ..." with no "does not match". Either lock marker alongside a "409" or "Conflict" status is a conflict; this mirrors classifyPutError exactly.

func IsNotFound added in v0.16.1

func IsNotFound(err error) bool

IsNotFound reports whether err is (or wraps) a Contents API 404.

func IsTransient added in v0.16.1

func IsTransient(err error) bool

IsTransient reports whether err is (or wraps) a transient Contents API failure. It recognizes the typed TransientError plus the raw markers a client forwarding gh output verbatim would carry: a 429 or rate-limit message and an HTTP 5xx status. A raw transport failure with no status is deliberately NOT matched here: only classifyPutError, which sees the actual gh output, may default no-status failures to transient; an arbitrary error with no markers stays permanent so unknown failures never spin the loop.

Types

type ConflictError

type ConflictError struct {
	// Err is the underlying transport or CLI error, surfaced when the retry
	// bound is exhausted.
	Err error
}

ConflictError reports an optimistic-lock (HTTP 409) failure from the Contents API, the signal CommitWithRetry retries on. Clients wrap the API's 409 in this type so the retry loop does not have to string-match raw gh output.

func (*ConflictError) Error

func (e *ConflictError) Error() string

func (*ConflictError) Unwrap

func (e *ConflictError) Unwrap() error

Unwrap exposes the wrapped error to errors.Is/As.

type ContentsClient

type ContentsClient interface {
	GetContent(repo, path, ref string) (content []byte, sha string, err error)
	PutContent(repo, path, ref, sha, message string, content []byte, author Identity) error
}

ContentsClient is the minimal GitHub Contents API surface the retry loop needs. The production implementation shells out to the gh CLI; tests inject a fake that returns a 409 on the first PUT and succeeds on the second.

GetContent returns the current file bytes and its blob SHA at ref. A real HTTP 404 returns an error satisfying IsNotFound; a state write only ever targets an already committed manifest, so absence is a hard failure, not a create-on-first-write signal. PutContent writes content at ref guarded by sha as the optimistic-lock token (a non-empty sha is required) and returns an error satisfying IsConflict when the blob SHA no longer matches.

func NewGHClient

func NewGHClient() ContentsClient

NewGHClient returns the production ContentsClient backed by the gh CLI.

type Identity added in v0.5.0

type Identity struct {
	// Name is the commit author/committer name. Empty falls back to the bot default.
	Name string
	// Email is the commit author/committer email. Empty falls back to the bot default.
	Email string
}

Identity is the name and email stamped as both the author and the committer of a Contents API state commit. State writers populate it from the manifest git config (GetGitUserName/GetGitUserEmail) so automated commits are attributed to the bot identity rather than the token owner that GitHub would otherwise use.

func (Identity) OrDefault added in v0.5.0

func (id Identity) OrDefault() Identity

OrDefault returns the identity with any empty field filled from the bot default, so a commit is always attributed to a concrete identity and behavior is never worse than before this attribution was threaded through. It is exported so other state writers that build their own Contents API request can resolve the same identity without duplicating the bot defaults.

type Mutate

type Mutate func(current []byte) ([]byte, error)

Mutate applies a caller's state change to the current manifest bytes and returns the new bytes to write. It MUST be re-appliable: CommitWithRetry calls it again after re-fetching the manifest on a 409, so it must derive the new bytes purely from the current bytes it is handed (for example, parse them, set only this env's ci.state.<env> keys, and re-marshal) rather than from a stale in-memory snapshot. Re-fetching picks up the other writer's committed keys, and re-applying preserves both.

type NotFoundError added in v0.16.1

type NotFoundError struct {
	// Repo, Path, and Ref locate the lookup that 404ed, so the operator can
	// spot a wrong path or repo slug at a glance.
	Repo, Path, Ref string
	// Detail carries the gh CLI's diagnostic text (stderr plus the API's JSON
	// error body).
	Detail string
}

NotFoundError reports that the Contents API GET for the manifest returned a real HTTP 404. GitHub deliberately answers 404 both for a file that does not exist and for a repository the token cannot read (it never confirms a private repo's existence with a 403), so the two causes are indistinguishable at the API. Either way the state write cannot proceed and retrying cannot help, so CommitWithRetry fails fast on it instead of spending the backoff budget.

func (*NotFoundError) Error added in v0.16.1

func (e *NotFoundError) Error() string

type Options

type Options struct {
	// Client performs the Contents API get/put. Required.
	Client ContentsClient
	// Repo is the "owner/name" repository slug. Required.
	Repo string
	// Path is the repo-relative manifest path. Required.
	Path string
	// Ref is the branch the write targets (the trunk branch). Required.
	Ref string
	// Message is the commit message for the write. Required.
	Message string
	// Mutate derives the bytes to write from the current manifest bytes. It is
	// re-applied on every retry. Required.
	Mutate Mutate
	// Author is the identity stamped as both author and committer of the state
	// commit. Callers populate it from the manifest git config so the commit is
	// attributed to the bot identity. An empty field falls back to the
	// github-actions[bot] default.
	Author Identity
	// Sleep is called between retries. Defaults to time.Sleep; tests inject a
	// no-op so no real time passes.
	Sleep func(time.Duration)
}

Options carries the inputs CommitWithRetry needs. Required identity fields are explicit; Sleep is optional and defaults to time.Sleep.

type TransientError added in v0.16.1

type TransientError struct {
	// Err is the underlying API or transport error, surfaced when the retry
	// bound is exhausted.
	Err error
}

TransientError reports a Contents API PUT failure that is transient rather than permanent: a rate limit (HTTP 429, or a 403 whose body names a rate, secondary, or abuse limit; GitHub's secondary limits surface exactly this way, never as auth failures), an HTTP 5xx, or a transport failure carrying no HTTP status. CommitWithRetry treats it like a 409: re-fetch, re-apply, and re-PUT within the bounded backoff loop, because the retry itself is the fix. The transient-vs-permanent taxonomy is shared with the generated state-write shell (internal/generate/state_write.go) and the git push retry (internal/git's retryablePushRejection); keep the three classifiers in sync.

func (*TransientError) Error added in v0.16.1

func (e *TransientError) Error() string

func (*TransientError) Unwrap added in v0.16.1

func (e *TransientError) Unwrap() error

Unwrap exposes the wrapped error to errors.Is/As.

Jump to

Keyboard shortcuts

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