Documentation
¶
Overview ¶
Package release holds the pure, table-driven-testable logic that the release CI workflows depend on: the release-PR title grammar, the git-tag grammar, the typed release-kind derivation, and release workflow policy checks.
Everything here is deliberately side-effect-free so the negative paths (a malformed title, a non-release tag, or a broken workflow graph) are unit-testable BEFORE they run in a workflow. The thin CLI in cmd/release-guard wires these functions to real workflow files; the workflows shell out to that CLI.
Index ¶
- Variables
- func AssembleMessage(subject string, p PRProvenance) string
- func BubbleMergeMessage(p PRProvenance) string
- func CheckReleaseWorkflowFile(workflowPath string, policy WorkflowPolicy) error
- func IsMaintainer(perm CollaboratorPermission) bool
- func LatestApprovers(reviews []Review) []string
- func ParseReleaseTitle(title string) (Version, ReleaseKind, error)
- func ParseTag(tag string) (Version, ReleaseKind, error)
- func RunGreenForCommit(runs []WorkflowRun, commitSHA string) bool
- type BubbleDecision
- type BubbleFacts
- type BubbleItem
- type CollaboratorPermission
- type GitCommit
- type GitRef
- type JobRule
- type NewCommit
- type PRProvenance
- type PermissionsRule
- type Pull
- type ReleaseKind
- type ReusableRule
- type Review
- type ReviewState
- type ReviewUser
- type Squash
- type TagRef
- type Version
- type WorkflowPolicy
- type WorkflowRun
- type WorkflowRunConclusion
- type WorkflowRunStatus
Constants ¶
This section is empty.
Variables ¶
var ErrNotFastForward = errors.New("release: ref update rejected as non-fast-forward — the ref advanced since its tip was read; re-read the ref tip and retry the bubble")
ErrNotFastForward is returned (wrapped) by GitHubClient.UpdateRefFastForward when GitHub rejects a ref update because it would not be a fast-forward (HTTP 422 whose message says "...is not a fast forward"). With the update forced to a non-clobbering fast-forward compare-and-swap, this is the server-side signal that the ref advanced since its tip was read: the bubble caller re-reads the ref tip and retries. A 422 for any OTHER reason (bad SHA, malformed ref) is surfaced as its own actionable error, never misreported as non-fast-forward.
Functions ¶
func AssembleMessage ¶
func AssembleMessage(subject string, p PRProvenance) string
AssembleMessage joins a caller-provided subject with the provenance's git trailers in the fixed order Closes / Approved-by / Reviewed-by / Co-authored-by (a blank line separates the subject from the trailer block; a subject with no trailers is returned as-is). Every trailer value is line-flattened so it cannot inject extra lines or a forged trailer.
It is the SINGLE SOURCE of the bubble trailer grammar: BubbleMergeMessage uses it (with the canonical "Merge PR #<n>: <title>" subject), and the bubble orchestrator's no-PR fallback reuses it (with its own "Merge commit <sha>" subject) so the trailer format is never re-implemented across the boundary.
func BubbleMergeMessage ¶
func BubbleMergeMessage(p PRProvenance) string
BubbleMergeMessage renders the merge-commit message for a bubble M from the resolved PR provenance: a single-line subject, then (if any trailers exist) a blank line and the git trailers in fixed order — Closes, Approved-by, Reviewed-by, Co-authored-by. It is deliberately NOT the PR body; the squash commit already carries the title + body, so M records only the merge provenance.
Both the subject and every trailer value are forced single-line (any CR/LF/tab is collapsed to a space) so neither a multi-line title nor a multi-line trailer value can break the subject/trailer structure or inject a forged trailer. When the title is empty/unresolved the subject falls back to "Merge PR #<n>".
func CheckReleaseWorkflowFile ¶
func CheckReleaseWorkflowFile(workflowPath string, policy WorkflowPolicy) error
CheckReleaseWorkflowFile validates the workflow file at workflowPath against the supplied per-repo WorkflowPolicy: every JobRule's job must exist, declare the required needs-edges, and (for reusable gates or a required permission scope) match the ReusableRule / PermissionsRule.
func IsMaintainer ¶
func IsMaintainer(perm CollaboratorPermission) bool
IsMaintainer reports whether a collaborator permission grants release- maintainer authority (may open OR approve a release PR).
This is the SINGLE source of the maintainer predicate. Both release-pr.yml gates — the PR-author check and the approver check — consume it via `release-guard check-maintainer`, so the {admin, maintain} set is defined exactly once (no duplicated `admin|maintain` shell case across jobs).
func LatestApprovers ¶
LatestApprovers returns logins whose latest non-COMMENTED review is APPROVED. Plain comments do not withdraw or shadow a standing approval.
The "latest" review is determined by input slice order: the loop below is intentionally last-write-wins per reviewer. The release-guard caller feeds this from GitHub's pull-request reviews API, which returns reviews in ascending chronological/id order, and that order is preserved through `gh api --paginate --slurp` and parseReviews flattening. This id/input order is preferred over sorting by submitted_at because GitHub IDs are stable and order-preserving even when multiple reviews have same-second submitted_at timestamps.
func ParseReleaseTitle ¶
func ParseReleaseTitle(title string) (Version, ReleaseKind, error)
ParseReleaseTitle parses a release-PR title of the form "release(vX.Y.Z): subject" or "release(vX.Y.Z-rcN): subject" and returns the validated version and its kind.
On any grammar violation it returns (KindInvalid, error) with an actionable message describing the expected shape and a concrete example. This is the single implementation of the title grammar; the workflows must NOT re-encode it inline.
func ParseTag ¶
func ParseTag(tag string) (Version, ReleaseKind, error)
ParseTag parses a git tag as a schema release reference. It accepts ONLY bare "vX.Y.Z[-rcN]" tags; namespaced tags such as the legacy "pkg/schema/v1.2.3" (retained from when this module was nested in peasant) are rejected because they are not releases of THIS module and must never trigger the release pipeline. (The release.yml trigger filter `v*` already excludes pkg/schema/v* by name; this parse is the defense-in-depth guard inside the workflow.)
func RunGreenForCommit ¶
func RunGreenForCommit(runs []WorkflowRun, commitSHA string) bool
RunGreenForCommit reports whether runs contains a completed, successful run of the workflow for commitSHA. It is the pure, table-testable predicate lifted out of the old runGreen's inline scan: a tag run's HeadSHA is the tagged commit, so a later success on the same commit is also accepted. Matching on the commit SHA (rather than the latest run) keeps the rule independent of run ordering or the now-dropped 100-run client-side cap.
Types ¶
type BubbleDecision ¶
type BubbleDecision int
BubbleDecision is the typed outcome of evaluating whether (and how) a freshly squash-merged commit should be "bubbled" into the develop history as a merge-commit triangle (T -> M, parents [T, S], M.tree == S.tree).
It is a strongly-typed enum (per the repo's no-stringly-typed-API rule) so the orchestrator branches on named constants and never compares bare strings. All safety-relevant branching lives in DecideBubble — never in workflow bash.
const ( // BubbleProceed: develop's tip is exactly the squash S (a single-parent // commit at S.tree). The bubble merge-commit M should be built and the ref // fast-forwarded S -> M. BubbleProceed BubbleDecision = iota // BubbleSkipNotSquash: the target commit is not a single-parent squash // (S.parentCount != 1), so it is not an eligible squash-merge. Skip, exit 0. BubbleSkipNotSquash // BubbleSkipAlreadyBubbled: develop's tip is already the bubble merge-commit // for S (a two-parent merge whose tree equals S.tree, distinct from S). // Re-running on an already-bubbled tip is a no-op. Skip, exit 0. BubbleSkipAlreadyBubbled // BubbleRetryTipAdvanced: develop's tip has advanced to some other commit // (its tree differs from S.tree and it is not S), so the read is stale. The // orchestrator must re-read the tip and re-decide (bounded retry). BubbleRetryTipAdvanced )
func DecideBubble ¶
func DecideBubble(f BubbleFacts) BubbleDecision
DecideBubble maps topology facts to a typed decision with the PINNED precedence:
NotSquash(SParentCount != 1) > AlreadyBubbled > TipAdvanced > Proceed
Precedence only matters where facts overlap; the canonical overlaps are:
- NotSquash dominates everything: a non-single-parent S is never bubbled, regardless of what the tip looks like.
- AlreadyBubbled vs TipAdvanced: both have tip != S. A tip that is a genuine two-parent merge at S.tree is the already-built bubble (no-op), NOT a "tip advanced" retry (B12).
func (BubbleDecision) String ¶
func (d BubbleDecision) String() string
String renders the decision for CLI output, logs, and error messages.
type BubbleFacts ¶
type BubbleFacts struct {
// SParentCount is the number of parents of the target squash commit S. A
// native squash-merge produces a single-parent commit, so the only eligible
// value is 1; anything else means S is not a squash (B4 / R-B).
SParentCount int
// TipParentCount is the number of parents of develop's current tip. A bubble
// merge-commit M has two parents [T, S]; this distinguishes a genuine bubble
// tip from an unrelated same-tree commit when classifying AlreadyBubbled.
TipParentCount int
// TipTreeEqualsS reports whether develop's tip has the same tree as S. A
// bubble M is built with M.tree == S.tree, so a two-parent tip at S.tree is
// the already-bubbled state.
TipTreeEqualsS bool
// TipEqualsS reports whether develop's tip is the squash S itself (the
// just-fast-forwarded, ready-to-bubble state).
TipEqualsS bool
}
BubbleFacts are the side-effect-free topology facts the orchestrator reads from the GitHub Git Data API (the squash S and develop's current tip) and feeds to DecideBubble. Keeping the decision a pure function of these facts is what makes every branch (B12 precedence) unit-testable BEFORE any protected branch is written.
func BubbleFactsFromCommits ¶
func BubbleFactsFromCommits(squash, tip GitCommit) BubbleFacts
BubbleFactsFromCommits derives the pure BubbleFacts that DecideBubble consumes from the SLICE-1 git-data own-types: a candidate squash commit S and develop's current tip commit. It is the mechanical realignment from the GitHub Git Data projection (release.GitCommit) onto the decision's topology facts, so callers resolve facts from own-types instead of hand-assembling a BubbleFacts literal.
It is a PURE projection of the two commits. Choosing WHICH commit is the candidate S for a given tip (e.g. a two-parent merge's second parent) and any safety reclassification of a stable non-bubble merge tip remain the orchestrator's responsibility — this function only maps (S, tip) -> facts.
type BubbleItem ¶
BubbleItem is a fully pre-resolved unit of work for the Bubbler: the squash to bubble plus its rendered merge-commit Message. The orchestrator resolves provenance and renders Message via BubbleMergeMessage so that the Bubbler (the I/O layer) carries no provenance logic and creates exactly one M per item in a drain-all chain, each M carrying its own PR's trailers.
type CollaboratorPermission ¶
type CollaboratorPermission string
CollaboratorPermission is a GitHub repository collaborator permission level — the `.permission` field returned by the collaborators API. Typed per the repo's no-stringly-typed rule so the maintainer predicate compares against named constants, not bare strings.
const ( PermAdmin CollaboratorPermission = "admin" PermMaintain CollaboratorPermission = "maintain" PermWrite CollaboratorPermission = "write" PermTriage CollaboratorPermission = "triage" PermRead CollaboratorPermission = "read" PermNone CollaboratorPermission = "none" )
func (CollaboratorPermission) String ¶
func (p CollaboratorPermission) String() string
String renders the permission for CLI output and error messages.
type GitCommit ¶
type GitCommit struct {
// SHA is the commit's own object SHA (server-assigned on create).
SHA string
// TreeSHA is the SHA of the tree this commit points at. A bubble merge commit
// M over squash S carries S's tree, so M.TreeSHA == S.TreeSHA.
TreeSHA string
// ParentSHAs are the parent commit SHAs in order. For a bubble merge commit M
// over squash S onto tip T, ParentSHAs is [T, S] (first-parent T).
ParentSHAs []string
// Message is the commit message. The bubble orchestrator reads a squash
// commit's message to recover its "(#n)" PR suffix, Co-authored-by trailers,
// and Closes/Fixes issue references when building the bubble merge message.
Message string
}
GitCommit is the subset of a git commit object the bubble needs: its own SHA, the tree it points at, and its parents (first-parent first). It is the decoded shape of both "get a commit" (GET git/commits/{sha}) and the "create a commit" (POST git/commits) response, so the same own-type serves the not-a-squash / already-bubbled decisions and the created merge commit.
type GitRef ¶
type GitRef struct {
// SHA is the object SHA the reference points at — the tip the bubbler reads
// before it fast-forwards the ref.
SHA string
}
GitRef is a git reference resolved to the commit SHA it points at. It is the own-type projection of GitHub's Git Data "get a reference" response (mirroring Review / WorkflowRun / CollaboratorPermission) so the squash-merge bubble orchestrator never touches a *github.Reference and the go-github pointer-field nil-guards stay at the cmd/release-guard wrapper boundary.
type JobRule ¶
type JobRule struct {
Name string `yaml:"name"`
Needs []string `yaml:"needs"`
Reusable *ReusableRule `yaml:"reusable"`
Permissions *PermissionsRule `yaml:"permissions"`
Environment string `yaml:"environment"`
}
JobRule constrains a single workflow job. Name is required; Needs lists the required needs-edges; Reusable (when non-nil) requires the job to be a reusable-workflow gate matching the ReusableRule; Permissions (when non-nil) requires the job's own `permissions:` block to grant the scopes in PermissionsRule; Environment (when non-empty) requires the job's `environment:` to equal it - scoping which GitHub Actions environment (and therefore, for an OIDC-trusted-publishing job, which environment's protection rules) the job runs under.
Permissions/Environment recognize only the exact forms this repo's workflows use - the explicit `permissions:` map (not the `read-all`/`write-all` bare scalar shorthand) and a bare scalar `environment:` (not the `{name, url}` mapping form). GitHub accepts all of those forms; this checker intentionally does not parse the unrecognized ones (rather than silently mis-accept or mis-reject them) and instead fails closed with an error naming the unsupported form, per checkJobPermissionsAgainstRule / checkJobEnvironmentAgainstRule below.
type NewCommit ¶
type NewCommit struct {
// Message is the commit message.
Message string
// TreeSHA is the SHA of the tree the new commit points at.
TreeSHA string
// ParentSHAs are the parent commit SHAs in order (first-parent first). For a
// bubble merge commit M over squash S onto tip T, this is [T, S].
ParentSHAs []string
}
NewCommit is the input to GitHubClient.CreateCommit: the fields the caller supplies to build a git commit via the Git Data API. Kept distinct from GitCommit (the response own-type) so the created SHA is not something a caller can pretend to know before the server assigns it.
type PRProvenance ¶
type PRProvenance struct {
// Number is the pull-request number for the "Merge PR #<n>" subject.
Number int
// Title is the PR title; when empty the subject falls back to "Merge PR #<n>".
Title string
// ClosesIssues renders one "Closes #<issue>" trailer per entry, in order.
ClosesIssues []int
// ApprovedBy renders one "Approved-by: <value>" trailer per entry, in order.
// Sourced from LatestApprovers over the PR's reviews.
ApprovedBy []string
// ReviewedBy renders one "Reviewed-by: <value>" trailer per entry, in order.
ReviewedBy []string
// CoAuthoredBy renders one "Co-authored-by: <value>" trailer per entry; each
// value is already in "Name <email>" form.
CoAuthoredBy []string
}
PRProvenance is the per-PR attribution resolved by the orchestrator from the GitHub API and rendered into a bubble merge-commit's trailers by BubbleMergeMessage. It carries the data for the subject and the Closes/Approved-by/Reviewed-by/Co-authored-by trailers — NOT the PR body (the squash commit already carries the title + body).
type PermissionsRule ¶
type PermissionsRule struct {
IDToken bool `yaml:"idToken"`
}
PermissionsRule constrains a job's own `permissions:` block. IDToken (when true) requires `permissions.id-token: write` on the job - the scope an OIDC trusted-publishing step (e.g. `pnpm publish` to npm) needs to mint its token. Job-level `permissions:` REPLACES the workflow-level default entirely (GitHub Actions does not merge the two), so this checks the job's own block, not the workflow top level.
type Pull ¶
type Pull struct {
// Number is the pull request number.
Number int
// Title is the pull request title.
Title string
// Body is the pull request body (markdown), available for provenance context.
Body string
}
Pull is the subset of a GitHub pull request the bubbler needs to build per-PR provenance in the bubble merge message: the number and title (body carried for context). Own-type projection of the "get a pull request" response so the policy layer stays free of any go-github import.
type ReleaseKind ¶
type ReleaseKind string
ReleaseKind classifies a release reference (a PR title or a git tag) as a release candidate, a final release, or an invalid/unrecognized reference.
It is a strongly-typed enum (per the repo's no-stringly-typed-API rule) so that workflow steps and Go callers compare against named constants rather than bare strings.
const ( // KindInvalid is returned together with an error whenever a reference does // not parse as a release. KindInvalid ReleaseKind = "invalid" // KindRC is a release candidate: a version carrying an -rcN prerelease // suffix (e.g. v0.1.0-rc1). RCs publish as prereleases and use the npm // `next` dist-tag. KindRC ReleaseKind = "rc" // KindFinal is a final, non-prerelease version (e.g. v0.1.0). KindFinal ReleaseKind = "final" )
func (ReleaseKind) String ¶
func (k ReleaseKind) String() string
String renders the kind for CLI output and workflow consumption.
type ReusableRule ¶
type ReusableRule struct {
Uses string `yaml:"uses"`
SecretsInherit bool `yaml:"secretsInherit"`
ForbidIf bool `yaml:"forbidIf"`
}
ReusableRule constrains a reusable-workflow gate job (peasant's e2e / release-e2e shape): it must `uses:` the named reusable workflow, pass `secrets: inherit` (when SecretsInherit), and carry no `if:` condition (when ForbidIf) so the gate runs on every release tag rather than selected paths.
type Review ¶
type Review struct {
User *ReviewUser `json:"user"`
State ReviewState `json:"state"`
}
Review is the subset of a GitHub pull-request review needed by the release approval gate.
type ReviewState ¶
type ReviewState string
ReviewState is the `.state` field returned by the GitHub pull-request reviews API for a submitted review.
const ( ReviewStateApproved ReviewState = "APPROVED" ReviewStateCommented ReviewState = "COMMENTED" ReviewStateChangesRequested ReviewState = "CHANGES_REQUESTED" ReviewStateDismissed ReviewState = "DISMISSED" )
func (ReviewState) String ¶
func (s ReviewState) String() string
String renders the review state for CLI output and error messages.
type ReviewUser ¶
type ReviewUser struct {
Login string `json:"login"`
}
ReviewUser is the GitHub user shape embedded in a pull-request review.
type Squash ¶
Squash identifies a single-parent squash-merge commit S that is a candidate for bubbling. TreeSHA is set explicitly on the bubble merge-commit M so that M.tree == S.tree without a content merge (the conflict-free, signed-M path).
type TagRef ¶
type TagRef struct {
// Name is the tag's short name (e.g. "v1.2.3", "v1.2.3-rc4", "pkg/schema/v0.2.0").
Name string
// CommitSHA is the commit the tag points at.
CommitSHA string
}
TagRef is a repository tag resolved to the commit it points at. It is the own-type projection of GitHub's "list repository tags" response (RepositoryTag), so the first-run bubble guard can test drained commits against release-tag commits without importing go-github. GitHub's list-tags endpoint dereferences annotated tags, so CommitSHA is always the underlying commit SHA for both lightweight and annotated tags.
type Version ¶
type Version string
Version is a validated schema release version, always including the leading "v" (e.g. "v0.1.0" or "v0.1.0-rc1"). Construct only via NewVersion so the invariant (matches the frozen grammar) holds everywhere a Version is seen.
func NewVersion ¶
NewVersion validates raw against the frozen release-version grammar and returns a typed Version. On failure it returns an actionable error naming the expected shape and a concrete example.
func (Version) Base ¶
Base returns the version with any -rcN suffix stripped: the final version a release candidate is a candidate FOR. For a final version Base is the identity (v0.1.0 -> v0.1.0); for an rc it strips the suffix (v0.1.0-rc3 -> v0.1.0).
func (Version) Kind ¶
func (v Version) Kind() ReleaseKind
Kind returns KindRC for prerelease (-rcN) versions and KindFinal otherwise. A Version is, by construction, never KindInvalid.
type WorkflowPolicy ¶
type WorkflowPolicy struct {
Jobs []JobRule `yaml:"jobs"`
}
WorkflowPolicy is the per-repo, declarative projection of the release-workflow assertions that were previously hardcoded in each repo's workflow_guard.go. It is data, not a DSL: each JobRule states a job that must exist, the needs-edges it must declare, and (optionally) the reusable-workflow shape or the job permissions/environment binding it must carry. schema and peasant supply different policy files; the shared tool holds no repo-specific job-shape knowledge.
func LoadWorkflowPolicy ¶
func LoadWorkflowPolicy(path string) (WorkflowPolicy, error)
LoadWorkflowPolicy reads and parses a repo's release-guard policy file (.github/release-guard.policy.yml) into a WorkflowPolicy. Unknown fields are rejected so a typo'd policy key fails loudly rather than silently disabling a gate.
type WorkflowRun ¶
type WorkflowRun struct {
HeadSHA string
Status WorkflowRunStatus
Conclusion WorkflowRunConclusion
}
WorkflowRun is the minimal projection of a GitHub Actions workflow run the release-guard predicates consume: the commit it ran against and its terminal state. It is an own-type (mirroring release.Review and release.CollaboratorPermission) so pure policy code never touches a *github.WorkflowRun and the go-github pointer-field nil-guards stay at the wrapper boundary.
type WorkflowRunConclusion ¶
type WorkflowRunConclusion string
WorkflowRunConclusion is the `.conclusion` field of a GitHub Actions workflow run — meaningful only once Status is WorkflowRunCompleted. Typed for the same reason as WorkflowRunStatus.
const ( // WorkflowRunSuccess is the only conclusion a green run can have. WorkflowRunSuccess WorkflowRunConclusion = "success" // WorkflowRunFailure is a completed run that failed. WorkflowRunFailure WorkflowRunConclusion = "failure" // WorkflowRunNeutral is a completed run that neither passed nor failed. WorkflowRunNeutral WorkflowRunConclusion = "neutral" // WorkflowRunCancelled is a completed run that was cancelled. WorkflowRunCancelled WorkflowRunConclusion = "cancelled" // WorkflowRunSkipped is a completed run that was skipped. WorkflowRunSkipped WorkflowRunConclusion = "skipped" // WorkflowRunTimedOut is a completed run that exceeded its time limit. WorkflowRunTimedOut WorkflowRunConclusion = "timed_out" // WorkflowRunActionRequired is a completed run that needs manual action. WorkflowRunActionRequired WorkflowRunConclusion = "action_required" // WorkflowRunStale is a completed run whose result is considered stale. WorkflowRunStale WorkflowRunConclusion = "stale" // WorkflowRunNoConclusion is the empty conclusion of a not-yet-completed run. WorkflowRunNoConclusion WorkflowRunConclusion = "" )
type WorkflowRunStatus ¶
type WorkflowRunStatus string
WorkflowRunStatus is the `.status` field of a GitHub Actions workflow run. Typed per the repo's no-stringly-typed rule so the release-run predicate compares against named constants, not bare strings. The wrapper at the cmd/release-guard go-github seam maps the upstream *github.WorkflowRun.Status onto these values; internal/release stays free of any go-github import.
const ( // WorkflowRunCompleted is the only status a green run can have — the run has // finished and its Conclusion is meaningful. WorkflowRunCompleted WorkflowRunStatus = "completed" // WorkflowRunInProgress is a run that is still executing (no conclusion yet). WorkflowRunInProgress WorkflowRunStatus = "in_progress" // WorkflowRunQueued is a run accepted but not yet started. WorkflowRunQueued WorkflowRunStatus = "queued" // WorkflowRunRequested is a run requested but not yet queued. WorkflowRunRequested WorkflowRunStatus = "requested" // WorkflowRunWaiting is a run paused waiting on a deployment gate/approval. WorkflowRunWaiting WorkflowRunStatus = "waiting" // WorkflowRunPending is a run pending (concurrency or other hold). WorkflowRunPending WorkflowRunStatus = "pending" )