release

package
v0.1.3 Latest Latest
Warning

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

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

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

Constants

This section is empty.

Variables

View Source
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

func LatestApprovers(reviews []Review) []string

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

type BubbleItem struct {
	Squash  Squash
	Message string
}

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

type Squash struct {
	SHA       string
	ParentSHA string
	TreeSHA   string
}

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

func NewVersion(raw string) (Version, error)

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

func (v Version) Base() Version

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) IsRC

func (v Version) IsRC() bool

IsRC reports whether the version carries an -rcN prerelease suffix.

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.

func (Version) String

func (v Version) String() string

String renders the version (with its leading "v").

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"
)

Jump to

Keyboard shortcuts

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