pubgh

package
v0.1.18 Latest Latest
Warning

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

Go to latest
Published: Aug 26, 2026 License: Apache-2.0, MIT Imports: 15 Imported by: 0

Documentation

Overview

Package pubgh verifies GitHub Actions artifact handoff metadata, signed release bundles, and the draft GitHub Release publication state machine.

VerifyHandoff is a pure check over the ArtifactMeta port. It confirms that an artifact exists, belongs to the expected workflow run, has not expired, and reports the caller-supplied digest. It does not download the artifact and does not recompute the Actions ZIP transport digest.

VerifyBundle reconciles a distribution directory against its checksums.txt claim and then verifies the detached Sigstore bundle through BlobVerifier. Local checks run before the signature check.

Publish binds a git tag to github.sha, refuses unexpected assets, uploads the closed expected set onto the matching draft, converges GitHub-reported digests, and optionally leaves draft state. It never creates a release, re-drafts a public one, or deletes an asset.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrNoDraft reports that no release for the tag appeared before the
	// draft poll budget expired.
	ErrNoDraft = errors.New("no draft release for the tag")
	// ErrAmbiguousRelease reports that the tag resolves to more than one
	// GitHub Release.
	ErrAmbiguousRelease = errors.New("tag does not uniquely resolve to one release")
	// ErrUnexpectedAsset reports that the release already contains an asset
	// name outside the expected closed set. Unexpected assets are refused
	// and are never deleted.
	ErrUnexpectedAsset = errors.New("release contains an unexpected asset")
	// ErrIndeterminate reports that a non-draft release exists for the tag
	// but its assets do not match the expected closed set exactly, so
	// [Publish] cannot tell whether a prior run completed successfully.
	ErrIndeterminate = errors.New("release state is indeterminate")
)

Sentinel errors classified for Publish.

View Source
var (
	// ErrRetryable reports a transient GitHub API failure that VerifyHandoff retries.
	ErrRetryable = errors.New("retryable github error")
	// ErrHandoffMismatch reports that observed artifact metadata failed the tuple check.
	ErrHandoffMismatch = errors.New("handoff mismatch")
)

Sentinel errors classified for the handoff check.

Functions

This section is empty.

Types

type ArtifactDigest

type ArtifactDigest string

ArtifactDigest is a lowercase sha256:<64 hex> digest.

The only constructor is ParseArtifactDigest, which accepts an optional sha256: prefix and normalizes hex to lowercase. The zero value is invalid.

func ParseArtifactDigest

func ParseArtifactDigest(raw string) (ArtifactDigest, error)

ParseArtifactDigest constructs an ArtifactDigest from a SHA-256 hex string.

An optional sha256: prefix is accepted. Uppercase hex is normalized to lowercase. The returned value always uses the sha256: prefix.

func (ArtifactDigest) String

func (d ArtifactDigest) String() string

String returns the canonical sha256:<hex> digest.

type ArtifactID

type ArtifactID int64

ArtifactID is a positive GitHub Actions artifact identifier.

The only constructors are ParseArtifactID and ArtifactIDFromInt. The zero value is invalid.

func ArtifactIDFromInt

func ArtifactIDFromInt(value int64) (ArtifactID, error)

ArtifactIDFromInt constructs an ArtifactID from a positive safe integer.

func ParseArtifactID

func ParseArtifactID(raw string) (ArtifactID, error)

ParseArtifactID constructs an ArtifactID from a decimal string.

func (ArtifactID) Int64

func (id ArtifactID) Int64() int64

Int64 returns the identifier as int64.

func (ArtifactID) String

func (id ArtifactID) String() string

String returns the decimal identifier.

type ArtifactMeta

type ArtifactMeta interface {
	// Get returns metadata for id in repository.
	//
	// Callers must not assume Get downloads the archive or recomputes
	// its digest.
	Get(ctx context.Context, repository Repository, id ArtifactID) (ArtifactMetadata, error)
}

ArtifactMeta fetches Actions artifact metadata.

type ArtifactMetadata

type ArtifactMetadata struct {
	// ID is the observed artifact identifier.
	ID ArtifactID
	// Name is the observed artifact name.
	Name string
	// Digest is the GitHub-reported digest. The zero value means GitHub
	// reported no digest.
	Digest ArtifactDigest
	// SizeBytes is the reported archive size.
	SizeBytes int64
	// HasRun reports whether workflow-run metadata was present.
	HasRun bool
	// Run is the workflow run that produced the artifact. It is only
	// valid when HasRun is true.
	Run RunID
	// ExpiresAt is the reported expiry instant. The zero value means
	// GitHub reported no expiry.
	ExpiresAt time.Time
	// Expired reports GitHub's expired flag.
	Expired bool
}

ArtifactMetadata is the observed Actions artifact tuple.

Values are produced by ArtifactMeta.Get. A zero ArtifactMetadata is invalid and must not be treated as a successful lookup.

func VerifyHandoff

func VerifyHandoff(
	ctx context.Context,
	meta ArtifactMeta,
	expected Handoff,
	sleep SleepFunc,
) (ArtifactMetadata, error)

VerifyHandoff confirms that the artifact metadata matches expected.

The artifact must exist, belong to expected.Run, not be expired, include workflow-run metadata, and report expected.Digest after normalization. A cancelled context.Context fails before the port is called. A nil context or port is rejected. Failures name what mismatched and never include credentials.

Get is called at most four times. Failures wrapping ErrRetryable wait 1s, then 2s, then 4s between attempts. Context cancellation returns immediately. Absent, authentication, and malformed responses are never retried. A nil sleep uses a context-aware timer.

type Asset

type Asset struct {
	// Name is the asset file name on the release.
	Name string
	// Digest is the GitHub-reported digest, "sha256:<64 hex>". It is empty
	// until GitHub finishes processing the upload.
	Digest string
	// State is the GitHub-reported asset state. It is "uploaded" when the
	// asset is ready.
	State string
}

Asset is one GitHub Release asset observed through ReleaseReader.

type AssetPath

type AssetPath string

AssetPath is a local filesystem path handed to AssetReplacer.

func (AssetPath) String

func (p AssetPath) String() string

String returns the filesystem path.

type AssetReplacer

type AssetReplacer interface {
	// Replace uploads expected onto the release identified by tag.
	Replace(ctx context.Context, repository Repository, tag rel.Tag, expected []AssetPath) error
}

AssetReplacer uploads the expected local assets onto a draft release.

Clobber semantics live in the adapter. Unexpected existing assets must already have been refused by Publish before Replace is called.

type AssetsView

type AssetsView struct {
	// Assets are the GitHub-reported release assets, in API order.
	Assets []Asset
}

AssetsView is the set of assets currently attached to a release.

type BlobVerification

type BlobVerification struct {
	// Payload is the name inside the distribution directory, for example
	// checksums.txt.
	Payload string
	// Bundle is the name inside the distribution directory, for example
	// checksums.txt.sigstore.json.
	Bundle string
	// Identity is the exact certificate identity URL.
	Identity string
	// Issuer is the OIDC issuer URL.
	Issuer string
}

BlobVerification is one detached-bundle verification request.

type BlobVerifier

type BlobVerifier interface {
	// Verify checks request.Payload against request.Bundle using the
	// exact identity and issuer. Implementations must not mutate the
	// distribution directory.
	Verify(ctx context.Context, request BlobVerification) error
}

BlobVerifier verifies a detached Sigstore bundle against a payload.

type Bundle

type Bundle struct {
	// Payloads are the checksummed release payloads, in checksums.txt order.
	Payloads []BundleEntry
	// Controls are exactly checksums.txt then checksums.txt.sigstore.json.
	Controls []BundleEntry
}

Bundle is a closed, checksummed release distribution.

Payloads follow checksums.txt order. Controls are always checksums.txt then checksums.txt.sigstore.json.

func BuildBundle

func BuildBundle(fsys fs.FS, claim stage.ChecksumSet) (Bundle, error)

BuildBundle performs the closed-set reconciliation of claim against fsys.

Every claimed payload must be a regular file whose digest matches. Both control files must exist as regular files and must not appear in the claim. The directory must contain nothing else: no extra file, directory, symlink, or irregular entry. The closed-set scan uses each fs.DirEntry mode so a symlink is refused rather than followed. Payload digests are then checked with stage.VerifyBundle. Control digests are streamed with io.Copy into sha256.New. A nil filesystem is rejected. The first offending entry is named in the error.

func VerifyBundle

func VerifyBundle(
	ctx context.Context,
	fsys fs.FS,
	verifier BlobVerifier,
	trust TrustPolicy,
) (Bundle, error)

VerifyBundle parses checksums.txt, builds the closed bundle, and verifies the Sigstore signature last.

Local checks run first: stage.ParseChecksums, then BuildBundle, then TrustPolicy.Normalize. The signature check runs only after every local check has passed. A nil context.Context, filesystem, or verifier is rejected. A cancelled context fails before the filesystem is read.

func (Bundle) Names

func (b Bundle) Names() []string

Names returns payload names then control names, in order.

type BundleEntry

type BundleEntry struct {
	// Name is the flat file name inside the distribution directory.
	Name string
	// Digest is the lowercase SHA-256 hex digest with no prefix.
	Digest stage.Digest
}

BundleEntry is one named digest inside a closed release bundle.

type CommitSHA

type CommitSHA string

CommitSHA is a 40-digit lowercase Git commit object ID.

The only constructor is ParseCommitSHA. The zero value is invalid.

func ParseCommitSHA

func ParseCommitSHA(raw string) (CommitSHA, error)

ParseCommitSHA constructs a CommitSHA from a 40-digit hex string.

Surrounding space is trimmed. Uppercase hex is normalized to lowercase.

func (CommitSHA) String

func (s CommitSHA) String() string

String returns the lowercase commit object ID.

type Handoff

type Handoff struct {
	// Repository is the repository that owns the artifact.
	Repository Repository
	// Run is the workflow run that must own the artifact.
	Run RunID
	// Artifact is the expected artifact identifier.
	Artifact ArtifactID
	// Digest is the expected GitHub-reported artifact digest.
	Digest ArtifactDigest
}

Handoff is the expected Actions artifact metadata tuple.

The only constructor is NewHandoff. The zero value is invalid.

func NewHandoff

func NewHandoff(repository Repository, run RunID, artifact ArtifactID, digest ArtifactDigest) (Handoff, error)

NewHandoff constructs a Handoff from already-validated domain values.

type PollPolicy

type PollPolicy struct {
	// Attempts is the maximum number of lookups, including the first.
	Attempts int
	// Wait is the pause after each failed lookup, including the last.
	Wait time.Duration
}

PollPolicy is a bounded lookup budget.

A zero Attempts or Wait field is replaced by the matching field from DefaultDraftPolicy or DefaultAssetPolicy.

func DefaultAssetPolicy

func DefaultAssetPolicy() PollPolicy

DefaultAssetPolicy returns the GR-16 asset-convergence budget: 12 attempts, 1s apart.

func DefaultDraftPolicy

func DefaultDraftPolicy() PollPolicy

DefaultDraftPolicy returns the GR-06 draft-discovery budget: 24 attempts, 5s apart.

type PublishInput

type PublishInput struct {
	// Repository is the GitHub owner/name that owns the release.
	Repository Repository
	// Tag is the git tag bound to the draft release.
	Tag rel.Tag
	// Commit is the workflow's github.sha. The tag must resolve to it.
	Commit CommitSHA
	// Expected is the closed bundle rebuilt from the distribution
	// directory. Payload and control names carry their hex digests.
	Expected Bundle
	// Assets are the local paths to upload, in [Bundle] order.
	Assets []AssetPath
	// Undraft is false for --no-undraft: converge and stop while still
	// a draft.
	Undraft bool
	// Draft is the draft-discovery budget. The zero value selects
	// [DefaultDraftPolicy].
	Draft PollPolicy
	// Asset is the asset-convergence budget. The zero value selects
	// [DefaultAssetPolicy].
	Asset PollPolicy
	// Sleep waits between retryable GitHub calls and between poll
	// attempts. Nil selects a context-aware timer.
	Sleep SleepFunc
}

PublishInput is the closed input to Publish.

type PublishResult

type PublishResult struct {
	// ReleaseID is the GitHub Release identifier.
	ReleaseID int64 `json:"release_id"`
	// Tag is the git tag bound to the release.
	Tag string `json:"tag"`
	// URL is the GitHub html_url of the release.
	URL string `json:"url"`
	// Draft reports the release draft state after the run.
	Draft bool `json:"draft"`
	// Assets are the converged asset names, sorted.
	Assets []string `json:"assets"`
}

PublishResult is the JSON document produced by a successful Publish.

func Publish

func Publish(
	ctx context.Context,
	input PublishInput,
	reader ReleaseReader,
	replacer AssetReplacer,
	publisher Publisher,
	resolver RefResolver,
) (PublishResult, error)

Publish binds a tag to github.sha, uploads the expected assets onto the matching draft, converges them, and optionally leaves draft state.

The order is fail-closed at every step:

  1. Validate the input and reject a nil context or port.
  2. Resolve the tag and fail unless it equals PublishInput.Commit.
  3. Find the draft. Absence after the budget is ErrNoDraft. More than one release for the tag is ErrAmbiguousRelease.
  4. A non-draft release is a rerun after a completed publication. If Undraft is false, that is ErrIndeterminate: a draft-only publication was requested but the release is already public. If Undraft is true and the assets match the expected set exactly (same names and count, every asset uploaded with a nonempty digest, every digest equal to "sha256:" plus the expected hex), return success with Draft false. Any other difference is ErrIndeterminate. This branch never creates, re-drafts, uploads, or deletes.
  5. Read assets once before uploading and refuse any existing name outside the expected set with ErrUnexpectedAsset. Unexpected assets are never deleted.
  6. Replace every expected asset path. Clobber lives in the adapter.
  7. Converge under the asset policy. Incomplete readiness (count below expected, missing digest, or a state other than uploaded) is retried. A duplicate name, unexpected name, count above expected, or digest mismatch fails immediately.
  8. If Undraft, publish and require the final Get to report Draft false. A Publish, Get, or draft-state failure after the undraft call is ErrIndeterminate because the remote release may already have left draft. Otherwise require the release to still be a draft.
  9. Return the release URL and the sorted converged asset names.

Transient failures classified ErrRetryable are retried with the same bounded helper used by VerifyHandoff (four attempts, 1s/2s/4s). Tag and SHA mismatch, unexpected assets, and digest mismatches are never retried. A cancelled context.Context fails immediately.

type Publisher

type Publisher interface {
	// Publish marks the release public. It must not re-draft a public
	// release and must not create a release.
	Publish(ctx context.Context, repository Repository, release ReleaseID) error
}

Publisher leaves draft state on a populated release.

type RefResolver

type RefResolver interface {
	// Resolve returns the commit object ID named by tag.
	Resolve(ctx context.Context, tag rel.Tag) (CommitSHA, error)
}

RefResolver resolves a git tag to the commit it currently names.

type Release

type Release struct {
	// ID is the GitHub Release identifier.
	ID ReleaseID
	// Tag is the git tag bound to the release.
	Tag rel.Tag
	// Draft reports whether the release is still a draft.
	Draft bool
	// URL is the GitHub html_url of the release.
	URL string
}

Release is one GitHub Release observed through ReleaseReader.

type ReleaseID

type ReleaseID int64

ReleaseID is a positive GitHub Release identifier.

The only constructors are ParseReleaseID and ReleaseIDFromInt. The zero value is invalid.

func ParseReleaseID

func ParseReleaseID(raw string) (ReleaseID, error)

ParseReleaseID constructs a ReleaseID from a decimal string.

func ReleaseIDFromInt

func ReleaseIDFromInt(value int64) (ReleaseID, error)

ReleaseIDFromInt constructs a ReleaseID from a positive safe integer.

func (ReleaseID) Int64

func (id ReleaseID) Int64() int64

Int64 returns the identifier as int64.

func (ReleaseID) String

func (id ReleaseID) String() string

String returns the decimal identifier.

type ReleaseReader

type ReleaseReader interface {
	// FindDraft returns the unique release whose tag_name equals tag.
	//
	// The adapter lists once. Zero matches is [ErrNoDraft] immediately.
	// More than one match is [ErrAmbiguousRelease]. Otherwise it returns
	// the release with its real Draft flag; it does not wait for a draft
	// and does not refuse a public release. [Publish] retries [ErrNoDraft]
	// under [PublishInput.Draft].
	FindDraft(ctx context.Context, repository Repository, tag rel.Tag) (Release, error)
	// WaitAssets returns the current assets on release after one list pass.
	//
	// The adapter does not judge readiness, count, or digests. [Publish]
	// re-reads under [PublishInput.Asset] until the expected set matches.
	// Implementations must not mutate the release.
	WaitAssets(ctx context.Context, repository Repository, release ReleaseID) (AssetsView, error)
	// Get returns the current release metadata for id.
	Get(ctx context.Context, repository Repository, release ReleaseID) (Release, error)
}

ReleaseReader observes GitHub Releases and their assets.

Implementations must not create, undraft, or delete a release, and must not delete an asset. FindDraft and WaitAssets perform one paginated snapshot each; they take no poll budget. Publish owns the 24×5s draft budget and the 12×1s asset-convergence budget.

type Repository

type Repository struct {
	// Owner is the account or organization that owns the repository.
	Owner string
	// Name is the repository name.
	Name string
}

Repository is a GitHub owner/name pair.

The only constructor is ParseRepository. The zero value is invalid.

func ParseRepository

func ParseRepository(raw string) (Repository, error)

ParseRepository constructs a Repository from an owner/name pair.

func (Repository) String

func (r Repository) String() string

String returns the owner/name pair.

type RunID

type RunID int64

RunID is a positive GitHub Actions workflow run identifier.

The only constructors are ParseRunID and RunIDFromInt. The zero value is invalid.

func ParseRunID

func ParseRunID(raw string) (RunID, error)

ParseRunID constructs a RunID from a decimal string.

func RunIDFromInt

func RunIDFromInt(value int64) (RunID, error)

RunIDFromInt constructs a RunID from a positive safe integer.

func (RunID) Int64

func (id RunID) Int64() int64

Int64 returns the identifier as int64.

func (RunID) String

func (id RunID) String() string

String returns the decimal identifier.

type SleepFunc

type SleepFunc func(ctx context.Context, d time.Duration) error

SleepFunc waits for d or until ctx is cancelled.

type TrustPolicy

type TrustPolicy struct {
	// Identity is the exact certificate identity URL. It is required.
	Identity string
	// Issuer is the OIDC issuer. An empty value defaults to
	// https://token.actions.githubusercontent.com.
	Issuer string
}

TrustPolicy is the exact Sigstore identity a release bundle must carry.

func (TrustPolicy) Normalize

func (p TrustPolicy) Normalize() (TrustPolicy, error)

Normalize fills the default issuer and rejects an empty or non-URL identity or issuer.

Identity must be an absolute https URL. An empty issuer becomes https://token.actions.githubusercontent.com. The issuer, after defaulting, must also be an absolute https URL.

Jump to

Keyboard shortcuts

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