releasesource

package
v0.9.1 Latest Latest
Warning

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

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

Documentation

Overview

Package releasesource describes what one billet release contains and what has to be true about it before any of its bytes replace a running binary.

WHY A MANIFEST AND NOT checksums.txt. A checksum file answers "did these bytes arrive intact" and nothing else. Deciding whether a release may replace THIS process needs four more facts, and every one of them has to be readable before anything is downloaded, let alone installed: which node-wire versions the candidate speaks, which ledger schema it expects, which guest contract its images must satisfy, and which release it can be rolled back to. A rollout that learns any of those after the switch has already stopped the control plane.

WHY THE MANIFEST IS THE ONLY THING THAT NEEDS SIGNING. It carries the digest of every artifact, so one signature over it transitively covers every binary and package in the release. The large files are then checked with a hash rather than public-key arithmetic — and, more importantly, an attacker who can serve a manifest can serve digests of bytes they chose, so without the signature every other check in this package is a checksum against itself.

Index

Constants

View Source
const (
	ChannelStable    = "stable"
	ChannelCandidate = "candidate"
)

The named channels billet publishes.

A CHANNEL IS A POINTER TO ONE IMMUTABLE MANIFEST, never a range or a policy. It is the only thing in the release contract that moves, which is exactly why a rollout resolves it once, records the digest it resolved to, and never consults it again — a channel advancing mid-rollout must not retarget work already underway.

View Source
const (
	// ChannelSchema is the layout of a channel statement.
	ChannelSchema = 1

	// MaxChannelBytes bounds the pointer document.
	MaxChannelBytes = 4 << 10
)
View Source
const (
	ManifestName = "release-manifest.json"
	BundleName   = "release-manifest.sigstore.json"
)

The names the release publishes the manifest and its signature under.

View Source
const (
	KindArchive = "archive"
	KindDeb     = "deb"
	KindRPM     = "rpm"
)

The artifact kinds a reader will act on. An unknown kind is refused, because installing a .deb the way an archive is unpacked is not a degradation.

View Source
const DefaultRepo = "junioryono/billet"

DefaultRepo is the repository billet's releases come from.

View Source
const MaxArtifactBytes int64 = 512 << 20

MaxArtifactBytes bounds one published artifact at 512 MiB.

billet's own binary is around 22MB and an archive of it rather less. This is an order of magnitude above anything the release could legitimately contain, and still refuses a manifest whose size field would have an updater write until the disk filled.

View Source
const MaxArtifacts = 50

MaxArtifacts bounds how many files one release may declare.

Three platforms times an archive, a .deb and an .rpm is nine, plus checksums. Fifty is generous against that and refuses a manifest that would turn one update into thousands of requests.

View Source
const MaxManifestBytes = 256 << 10

MaxManifestBytes bounds what a reader will parse.

A manifest is a few kilobytes — a dozen artifacts and their digests. The bound exists because the document is fetched over the network before anything about the far end has been proven, and an unbounded read of an untrusted stream is how a fetch becomes a memory exhaustion.

View Source
const PublishWorkflow = ".github/workflows/release.yml"

PublishWorkflow is the workflow allowed to sign billet's releases.

NAMED, NOT JUST THE REPOSITORY, for the reason imagesource.PublishWorkflow gives: a certificate's SAN identifies the WORKFLOW that requested it, so pinning only the repository accepts a signature from any other workflow in it — including one a pull request adds, which is a far lower bar than compromising the release process.

View Source
const (
	SchemaV1 = 1
)

The manifest layouts this build knows about.

Bumped when a field changes meaning. A reader refuses a schema it does not know rather than interpreting unfamiliar fields, because the failure mode of guessing is installing the wrong bytes over a running control plane.

View Source
const SchemaVersion = SchemaV1

SchemaVersion is the layout this build WRITES.

DELIBERATELY ALLOWED TO LAG WHAT IT READS, and that gap is the whole migration path. A reader accepts exactly the schemas it understands, so publishing a new layout in the same change that teaches the reader about it is a FLAG DAY: every deployment already in the field cannot read the release, and the thing that would fix them is the release they can no longer read. Readers learn the new schema and ship; only once the fleet carries them does the writer move.

The same rule as imagesource.SchemaVersion, and for a worse reason: a guest image a node cannot read is a node that keeps running the old image, while a release a controller cannot read is a fleet with no way to update at all.

Variables

View Source
var DefaultSigningIdentity = `^https://github\.com/` +
	regexp.QuoteMeta(DefaultRepo) + `/` + regexp.QuoteMeta(PublishWorkflow) + `@` +
	PublishRefPattern + `$`

DefaultSigningIdentity is the certificate SAN a billet release manifest must carry.

ANCHORED AT BOTH ENDS AND ESCAPED EXCEPT WHERE THE PATTERN IS THE POINT. The repository and the workflow are literals and go through QuoteMeta; the ref is a deliberate alternation between the button's run on main and a hand-pushed tag.

View Source
var ErrIncompatible = errors.New("releasesource: this release cannot replace the running build")

ErrIncompatible means a candidate release must not replace this build.

A SENTINEL SO THE ROLLOUT CAN TELL IT FROM A FAILURE TO LOOK. "This release cannot be installed here" is a durable verdict a rollout records as a blocker and stops on; "the manifest could not be fetched" is a retry. Collapsing the two makes a network blip look like an incompatible release and a genuinely incompatible release look like something worth retrying forever.

View Source
var ErrNotFound = errors.New("releasesource: no such artifact at this source")

ErrNotFound means the source has no such artifact.

DISTINGUISHED FROM EVERY OTHER FAILURE because callers act on it differently: a channel that has never been published is a normal state with an instruction attached, while a network failure is a retry.

View Source
var PublishRefPattern = `refs/(heads/main|tags/v[0-9]+\.[0-9]+\.[0-9]+)`

PublishRefPattern is the git ref releases may be signed from.

THE REF IS THE RUN'S, NOT THE TAG'S, AND THAT WAS MEASURED. cut-release.yml runs on main and CALLS release.yml (a workflow_call, so the tag it pushed with GITHUB_TOKEN starts nothing), and the Fulcio certificate names the workflow that requested it under the ref of the run that requested it: v0.6.0's manifest carries `release.yml@refs/heads/main`. An earlier reading of this pattern assumed release.yml "runs against the tag" and accepted only `refs/heads/release/vX.Y` or `refs/tags/vX.Y.Z`, so every binary shipped with it, v0.5.0 and v0.6.0 included, refused every manifest billet had ever published; the first rollout rehearsal (2026-09-04 UTC) is where that surfaced. The two refs the workflow can actually run under are the cut button on main and a hotfix tag pushed by hand (`on: push: tags: v*`); a release branch is not one of them, because release.yml has no branch trigger. What must stay excluded is `refs/pull/N/head`, which is the low bar the imagesource comment names, and every other branch.

Functions

func ArtifactURL

func ArtifactURL(repo, tag, name string) string

ArtifactURL is where one published artifact lives.

func BundleURL

func BundleURL(repo, tag string) string

BundleURL is where that manifest's signature lives.

func CanRollBack

func CanRollBack(from, to *Manifest) error

CanRollBack reports whether a failed update TO `from` can restore `to`.

THE NAMES READ THE WAY THE ROLLBACK MOVES: out of `from` and back to `to`, which is what the diagnostic says and what the comparison does. Its doc comment used to have the two the other way round, which made a reader believe the candidate was the second argument.

THE SAME COMPARISON IN THE OTHER DIRECTION, and it exists because a rollback is where a schema migration becomes irreversible. If the candidate migrated the ledger past what the previous release knows, restoring that binary produces a control plane that refuses its own database — so an updater has to know this BEFORE it migrates, not after its candidate has failed.

A ROLLBACK IS AUTHORISED BY THE SNAPSHOT, NOT BY THIS. The transactional updater takes a ledger snapshot before it migrates and restores that on failure, so the schema comparison here is what decides whether the candidate may migrate at all while still leaving a way back.

func ChannelBundleURL

func ChannelBundleURL(repo, channel string) string

ChannelBundleURL is where that statement's signature lives.

func ChannelURL

func ChannelURL(repo, channel string) string

ChannelURL is where a channel's signed statement lives.

func Compatibility

func Compatibility(m *Manifest, current Current) ([]error, error)

Compatibility reports every reason a candidate must not be installed here, and separately every reason it needs work first.

TWO RETURN VALUES BECAUSE ONE WAS A DEFECT. This used to join everything into a single error and let callers pick the guest-contract change out with errors.As — which meant a candidate that ALSO shared no wire version, or published nothing for this platform, was waved through by a caller that found the change and proceeded. A warning that can hide a refusal is worse than no warning, and the type is what stops it: a fatal problem can only be returned as an error, and an error is not something a caller can mistake for advice.

IT RUNS BEFORE ANY LIVE MUTATION. The whole point of carrying these facts in a signed manifest is that a rollout can learn them while the control plane is still running: a candidate that turns out to speak no wire version in common with the fleet, or to refuse the ledger it would inherit, is discovered after the switch — with the old binary already hidden and the services already stopped.

EVERYTHING AT ONCE. An operator planning an upgrade should see all of it in one diagnostic rather than clearing one obstacle per attempt, each of which costs a maintenance window.

func KnownChannel

func KnownChannel(name string) bool

func ManifestURL

func ManifestURL(repo, tag string) string

ManifestURL is where one release's manifest lives.

Types

type Artifact

type Artifact struct {
	// Name is the file's name within the release. A BARE FILENAME, validated as
	// one; see artifactName.
	Name string `json:"name"`

	// OS and Arch are Go's spellings, matching the archive names the install
	// script already derives from uname.
	OS   string `json:"os"`
	Arch string `json:"arch"`

	// Kind separates the forms one platform publishes: an archive, a .deb, a
	// .rpm. An updater picks by kind, and an unknown one is refused rather than
	// treated as an archive.
	Kind string `json:"kind"`

	// SHA256 is the digest of the bytes as published, lowercase hex.
	SHA256 string `json:"sha256"`

	// Size is the published length in bytes, carried so a reader can bound the
	// download rather than discovering the length by exhausting a disk. A digest
	// alone cannot do that: it is only checkable after the last byte.
	Size int64 `json:"size"`
}

Artifact is one downloadable file and the digest that proves it arrived as published.

type BuildRequest

type BuildRequest struct {
	// Dist is the directory GoReleaser wrote its artifacts into.
	Dist string

	Version string
	Commit  string
	BuiltAt time.Time

	Wire          Range
	LedgerSchema  int
	GuestContract string

	// RollbackTo is the release a failed update of this one restores. Empty for
	// the first release billet ever publishes.
	RollbackTo string
}

BuildRequest is everything a publisher knows that the files themselves do not.

type ChannelStatement

type ChannelStatement struct {
	Schema  int    `json:"schema"`
	Channel string `json:"channel"`

	// Tag is the release this channel currently names.
	Tag string `json:"tag"`

	// ManifestSHA256 is the digest of that release's manifest.
	//
	// THE THING A ROLLOUT PERSISTS. A tag names a release and a digest names its
	// CONTENTS, and only the second survives somebody moving a tag. GitHub release
	// immutability means that should be impossible, which is a reason to record
	// the digest rather than a reason not to: an assumption billet can check for
	// free is one it should check.
	ManifestSHA256 string `json:"manifest_sha256"`

	PublishedAt time.Time `json:"published_at"`
	ExpiresAt   time.Time `json:"expires_at"`

	// ReleaseImmutable is the publisher asserting it PROVED the release immutable
	// before advancing the channel.
	//
	// CARRIED AND CHECKED rather than assumed from the repository setting. GitHub's
	// release immutability applies only to releases created after it was enabled,
	// so "the repository is protected now" says nothing about a given release. The
	// workflow proves it per release and signs the assertion; a reader that
	// accepted an absent or false value would be trusting a property nobody
	// checked.
	ReleaseImmutable bool `json:"release_immutable"`
}

ChannelStatement is one signed pointer from a channel to an immutable release.

func NewChannelStatement

func NewChannelStatement(channel, tag, manifestDigest string, now time.Time,
) (*ChannelStatement, error)

NewChannelStatement builds one pointer for a publisher to sign.

func ParseChannel

func ParseChannel(body []byte, want string, now time.Time) (*ChannelStatement, error)

ParseChannel decodes and validates a channel statement.

EVERY REFUSAL HERE FAILS CLOSED — an unreadable, expired, replayed, or unattested pointer resolves to nothing rather than to a default, because the only thing worse than not knowing which release is current is guessing.

`now` IS A PARAMETER because expiry is the interesting behaviour and a test that has to wait ten days is a test nobody runs.

func (*ChannelStatement) Marshal

func (c *ChannelStatement) Marshal() ([]byte, error)

Marshal renders a channel statement the way the publisher writes it.

HERE RATHER THAN IN THE WORKFLOW, so the writer and the reader agree by construction. A statement assembled by a shell heredoc is a second implementation of this schema, and the two drift on the first field anybody adds — which is the failure the vendored toolset declaration exists to prevent one directory over.

type Client

type Client struct {
	// HTTP is the transport. Nil means a bounded default.
	HTTP *http.Client

	// Repo is the repository releases come from.
	Repo string

	// Now is the clock the channel's expiry is judged against. Nil means
	// time.Now — a parameter because expiry is the behaviour worth testing and a
	// test that waits ten days is a test nobody runs.
	Now func() time.Time
	// contains filtered or unexported fields
}

Client resolves a channel to one immutable release and proves what it fetches.

func (*Client) Download

func (c *Client) Download(ctx context.Context, tag string, a *Artifact, dir string,
) (string, error)

Download fetches one artifact into dir and proves it against the manifest.

THE DIGEST IS CHECKED AS THE BYTES ARRIVE and the file is only named once it passes, so nothing downstream can ever open a partially-written or unverified artifact. The size is enforced too: a digest is only checkable after the last byte, so it cannot stop a response that never ends.

func (*Client) Manifest

func (c *Client) Manifest(ctx context.Context, tag, expectDigest string, p Policy,
) (*Manifest, string, error)

Manifest fetches one release's manifest, proves it, and validates it.

THE SIGNATURE IS CHECKED OVER THE BYTES THAT ARRIVED, before anything is parsed out of them. Verifying a re-serialised manifest would verify a document this program produced rather than the one that was signed, and the two can differ in whitespace, key order, or any field a future reader drops.

expectDigest COMES FROM THE CHANNEL and is checked here. A tag names a release; a digest names its contents. GitHub release immutability should make the two equivalent, which is a reason to check rather than a reason not to — an assumption billet can verify for free is one it should verify.

THE DIGEST IS RETURNED because every caller needs to persist it, and computing it a second time from a re-serialised manifest would hash a document this program produced rather than the one that was published. An exact version pin has no channel to take it from, and a rollout still has to record WHICH bytes it decided on.

func (*Client) Resolve

func (c *Client) Resolve(ctx context.Context, channel string, p Policy,
) (*ChannelStatement, error)

Resolve turns a channel name into the immutable release it currently points at.

ONE ANSWER, RECORDED BY THE CALLER. A rollout persists what this returns and never asks again: a channel that advances mid-rollout must not retarget work already underway, which is why a channel is a pointer rather than a subscription.

type Current

type Current struct {
	// Version is the release the running binary reports itself as.
	Version string
	// Wire is the node-wire range the running control plane speaks.
	Wire Range
	// LedgerSchema is the highest migration the running binary knows.
	LedgerSchema int
	// GuestContract is the guest protocol the running binary speaks.
	GuestContract string
	// OS and Arch are the platform this deployment runs on.
	OS   string
	Arch string
}

Current is what the running deployment is, for the compatibility check.

PASSED IN RATHER THAN READ HERE, and that is what makes this package testable and honest. Reading nodeapi and internal/state directly would make the check assert facts about the process running it, so a test could only ever confirm that this build is compatible with itself — which is the one case that never fails in production.

func Host

func Host(version string, wire Range, ledgerSchema int, guestContract string) Current

Host describes the machine this process is on.

type GuestContractChange

type GuestContractChange struct {
	From string
	To   string
}

GuestContractChange is a candidate that needs different guest images.

A WARNING RATHER THAN A REFUSAL. Every other problem Compatibility reports is a reason the binary cannot be installed at all; this one is a reason each NODE needs an image before it converges, which `billet images compatible` answers per node against that node's own configured images. Refusing the whole rollout would block a deployment whose nodes have already imported a compatible generation.

IT IS RETURNED IN A SEPARATE LIST, not mixed into the error. Callers used to pick it out with errors.As and proceed, which waved through a candidate that also shared no wire version — the warning hiding a refusal.

func (*GuestContractChange) Error

func (c *GuestContractChange) Error() string

type Manifest

type Manifest struct {
	// Schema is the layout version of this document.
	Schema int `json:"schema"`

	// Version is the release, as its git tag spells it.
	Version string `json:"version"`

	// Commit is the commit the release was built from, for an operator
	// reconciling a running binary against what it claims to be.
	Commit string `json:"commit"`

	// BuiltAt is when the release was produced, in UTC.
	BuiltAt time.Time `json:"built_at"`

	// Wire is the node-wire range this release speaks.
	//
	// THE FIELD THAT DECIDES WHETHER A ROLLOUT CAN EVEN BEGIN. A candidate whose
	// range does not overlap the range the running control plane speaks cannot be
	// bridged to — there is no version both halves implement, so nodes could not
	// register against it — and that has to be knowable before the binary is
	// replaced rather than after the fleet has fallen off.
	Wire Range `json:"wire"`

	// SchemaVersion is the ledger migration this release expects.
	//
	// Migrations are append-only and a binary refuses a database carrying a
	// version it has never heard of, so a candidate BELOW the installed schema
	// cannot open the ledger it would inherit. That is a rollback the updater has
	// to refuse rather than discover after it has stopped the control plane.
	LedgerSchema int `json:"ledger_schema"`

	// GuestContract is the protocol a guest image's baked agent must speak.
	//
	// A STRING COMPARED FOR EQUALITY, never ordered, for the reason
	// imagesource.Manifest.GuestContract gives: a newer contract is not backward
	// compatible by default, and treating it as "greater than or equal" turns a
	// clean refusal into a guest that boots and never reports.
	GuestContract string `json:"guest_contract"`

	// Actions is the tag billet's bundled composite actions resolve to in this
	// release. It is the release's own version for an ordinary cut; it is carried
	// explicitly so a reader can check that rather than assume it.
	Actions string `json:"actions"`

	// RollbackTo is the release a failed update of this one restores.
	//
	// PART OF THE MANIFEST RATHER THAN DERIVED, because "the previous tag" is not
	// the same question as "a release this one can be rolled back to". A candidate
	// that migrated the ledger cannot be rolled back to a binary that refuses the
	// new schema, and only the release knows that about itself.
	//
	// EMPTY IS A VALUE. The first release billet ever publishes has nothing behind
	// it, and refusing that would mean no release could ever be the first.
	RollbackTo string `json:"rollback_to,omitempty"`

	// Artifacts is every published file, keyed by nothing — a reader selects by
	// os and arch.
	Artifacts []Artifact `json:"artifacts"`
}

Manifest is one immutable billet release: every artifact it publishes, and every fact a deployment needs in order to refuse it.

func Build

func Build(req BuildRequest) (*Manifest, error)

Build assembles a manifest by hashing what was actually produced.

IN GO RATHER THAN IN THE WORKFLOW, and that is the point. A manifest assembled by a shell heredoc is a second implementation of this schema, and the two drift on the first field anybody adds — the two-pins problem the vendored toolset declaration exists to prevent one directory over. Here the writer and the reader are the same type, and the result is put through the reader's own validation before it is published.

THE DIGESTS COME FROM THE FILES, never from checksums.txt. Reading a digest out of a file GoReleaser wrote would make the manifest a restatement of another document's opinion; hashing the bytes that are about to be uploaded is the only thing that makes the signature over this manifest mean what it claims.

func ParseManifest

func ParseManifest(body []byte) (*Manifest, error)

ParseManifest decodes and validates a manifest.

THE ONLY WAY ONE IS PRODUCED FROM BYTES, so no caller can hold an unvalidated manifest — which is what lets the update path treat its digests, names and sizes as constrained rather than re-checking each at every use.

STRICT DECODING, because a field this build does not know is a field whose meaning it cannot honour. A release describing a constraint through a key added after this binary shipped would otherwise be installed as though the constraint were absent. That is the opposite of the node wire's rule, and deliberately so: the wire negotiates a version both sides agreed on, while a manifest is a take-it-or-leave-it document about whether this binary may be replaced.

func (*Manifest) Marshal

func (m *Manifest) Marshal() ([]byte, error)

Marshal renders a manifest the way the publisher writes it.

ONE RENDERER, for the reason ChannelStatement.Marshal is one: the bytes that get signed have to be the bytes the reader was written against. A publisher that serialised the document its own way would be signing something this package has never seen, and the difference would surface as an unverifiable signature on a release nobody had tampered with.

func (*Manifest) Select

func (m *Manifest) Select(goos, goarch, kind string) (*Artifact, error)

Select finds the artifact for one platform and kind.

AN ABSENCE IS AN ERROR RATHER THAN A ZERO VALUE. A release that does not publish darwin/arm64 is a release a Mac cannot install, and returning an empty Artifact would have the caller verify an empty digest against no bytes and report success.

func (*Manifest) Validate

func (m *Manifest) Validate() error

Validate reports everything wrong with a manifest, or nil.

CALLED BEFORE ANY FIELD IS USED, including before the digests are trusted enough to check bytes against. The document arrives over the network from a service nobody here controls, so every field is an assertion by a stranger until it has been through this.

EVERYTHING AT ONCE, rather than the first failure, because an operator fixing a hand-written manifest should not discover its problems one release at a time.

type Policy

type Policy = imagesource.Policy

Policy is what a source demands before a manifest may be trusted.

imagesource's, DELIBERATELY, and not a second one. Both consumers verify a signed document against sigstore's public-good roots using the SAME embedded trust root, and duplicating either the root or the verifier is the two-pins problem: two copies of a security-critical decision that agree today and drift on the next rotation. If a third consumer ever appears, the verifier should be lifted into a package of its own — with two, the import is cheaper than the move and carries the same guarantee.

func PolicyForRelease

func PolicyForRelease(skip bool) (Policy, error)

PolicyForRelease decides what verification billet's own releases demand.

A MISSING POLICY IS AN ERROR RATHER THAN A SKIP, which is imagesource's rule and applies here with more force. The manifest is the only thing that makes every digest in it mean anything: an attacker who can serve one names digests of bytes they chose, and every check downstream then passes against those bytes. Here the bytes in question replace a running control plane.

THE WAIVER IS EXPLICIT AND IT WINS. An air-gapped deployment mirroring its own releases has a real reason; the point is that skipping verification is an act somebody performed rather than what happens when nothing is configured.

type Range

type Range struct {
	Min int `json:"min"`
	Max int `json:"max"`
}

Range is the span of node-wire versions a release speaks, inclusive.

ITS OWN TYPE RATHER THAN nodeapi.Range, because this package must be readable by a build whose nodeapi says something different — that is the entire point of carrying it. Converting happens at the comparison, where both sides are known.

Jump to

Keyboard shortcuts

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