Documentation
¶
Overview ¶
Package buildtarget owns the per-service build dispatch for services whose source lives outside the project's Go module — sibling repos, third-party binaries, language runtimes forge doesn't natively build. Mirrors internal/deploytarget for the BUILD side: a service declares `build = forge.ShellBuild { cmd, cwd, env }` on its KCL Service (the SINGLE shell escape hatch), and forge runs that command via `sh -c` instead of the built-in Go-build pipeline. The Spec's BuildCmd/BuildCwd/ BuildEnv fields below are the RESOLVED form of that ShellBuild.
Design notes:
Mirrors External (deploy provider) in shape — same `sh -c` execution. The `cmd` is a plain KCL string that forge runs VERBATIM: there is no token substitution and forge exports no ${IMAGE}/${TAG}/${REGISTRY}-style variables, so every `$VAR` in the command is the shell's. Anything forge knows is composed in KCL, where the value already lives. The build side and deploy side are ORTHOGONAL: a service can have a ShellBuild (build externally) AND `deploy = K8sCluster { ... }` (deploy to in-cluster) — the typical cp-forge pattern.
A missing cwd is a HARD FAILURE, not a skip. The dispatcher only constructs a Spec for services that are IN the current env, so by the time a Spec reaches Runner.Build its inputs are expected to exist; a missing source tree (e.g. an un-checked-out sibling repo) means the build that was supposed to run can't — and reporting success would let a following deploy reference an unpushed image.
The user's command owns BOTH the build AND the push. Forge does NOT run `docker push` afterwards. Matches External's "user owns the command end-to-end" contract.
The runner is split from the dispatcher so unit tests can inject a fake commandRunner without spawning a real shell — the External provider's testing pattern.
Phase 1 landed the schema + token helper. Phase 2 (this commit) wires Runner.Build into internal/cli/build.go and persists per- service state. Phase 3 adds audit + doctor surfaces.
Index ¶
- Constants
- func ListStates(projectDir, env string) ([]string, error)
- func ReadInputs(path string) (release.BuildInputs, string, error)
- func ReadReceipt(path, expected string) (*release.BuildReceipt, error)
- func ResolveCwd(spec Spec) (string, error)
- func StateBelongsTo(st *State, env, derivedService string) bool
- func StatePath(projectDir, env, service string) string
- func ValidateReceipt(r release.BuildReceipt, expected string) error
- func WriteReceipt(path string, r release.BuildReceipt) error
- func WriteState(projectDir, env string, state State) error
- type BuildResult
- type Runner
- type Source
- type Spec
- type State
Constants ¶
const ReceiptPathEnv = "FORGE_BUILD_RECEIPT_PATH"
ReceiptPathEnv is reserved for an invocation-specific builder output. A receipt is never discovered by scanning state or inherited from a prior build.
Variables ¶
This section is empty.
Functions ¶
func ListStates ¶ added in v0.1.45
ListStates returns the service names that have a recorded build state for env, sorted. It lives here because the filename convention does: a caller globbing `build-<env>-*.json` itself has to re-derive the SafeSegment encoding, and get the ambiguity right.
THE AMBIGUITY. Both segments may contain "-", so build-dev-k8s-gateway.json reads equally as (dev, k8s-gateway) and (dev-k8s, gateway) — a glob for one env therefore also matches every env whose name EXTENDS it. So each candidate is read and kept only when the file's own Env/Service agree (StateBelongsTo), which is the check that keeps a sibling env's state out.
func ReadInputs ¶ added in v0.1.46
func ReadInputs(path string) (release.BuildInputs, string, error)
ReadInputs captures an optional predeclared input contract without running a generator. Dynamic builders may omit it, but cannot claim automatic reuse.
func ReadReceipt ¶ added in v0.1.46
func ReadReceipt(path, expected string) (*release.BuildReceipt, error)
ReadReceipt requires a complete typed builder attestation. The expected input digest, when supplied, was captured before executing the build.
func ResolveCwd ¶ added in v0.1.45
ResolveCwd returns the directory spec.BuildCmd runs in, or the error Runner.Build would fail with for it. It is the ONE place the cwd rule lives, so `forge build --plan` can report exactly the failure the real build would hit — before any other build in the same run has spent time or pushed anything — without a second copy of the rule to drift.
func StateBelongsTo ¶ added in v0.1.45
StateBelongsTo reports whether a state loaded from build-<env>-<service>.json was really written for (env, service), or whether the filename was split at the wrong hyphen.
The filename cannot answer this on its own: both segments may contain "-", so build-dev-k8s-gateway.json reads equally as (dev, k8s-gateway) and (dev-k8s, gateway). But the FILE names its own service, so the split can be checked against it — if a caller globbing env "dev" derived the service "k8s-daemon-gateway" from a file whose Service is "daemon-gateway", that file belongs to env "dev-k8s" and not to this deploy.
This works on state written by older forge versions too, because Service has always been recorded. Env is checked first when present, as the direct answer; the Service cross-check is what covers everything already on disk. A file carrying neither is accepted — the caller's filename filter is all it ever had.
func StatePath ¶
StatePath exposes the per-service state path for callers that want to print it (forge build's summary, forge project audit) without re-deriving the layout. Kept exported so the path lives in one place.
func ValidateReceipt ¶ added in v0.1.46
func ValidateReceipt(r release.BuildReceipt, expected string) error
ValidateReceipt delegates input identity to the shared release contract. Cwd attribution cannot substitute for the exported sources in Inputs.Sources.
func WriteReceipt ¶ added in v0.1.46
func WriteReceipt(path string, r release.BuildReceipt) error
WriteReceipt atomically creates one invocation receipt. A second write is a protocol error, not permission to replace the bytes an adapter already read.
func WriteState ¶
WriteState persists a successful Runner.Build to disk. Called from the build dispatcher after every successful per-service build so a subsequent `forge env deploy <env>` can pin the same tag. Skipped builds DO NOT call WriteState — there's no successful tag to record.
The directory is created lazily so projects that never use the external-build path never grow .forge/state/build-*-*.json files. File mode is 0o644 to match the project-docker state file.
Types ¶
type BuildResult ¶
type BuildResult struct {
Service string
Tag string
Skipped bool
SkipMsg string
Duration time.Duration
Err error
Receipt *release.BuildReceipt
}
BuildResult is the outcome of a single Runner.Build call. The Skipped field is retained for callers that branch on it but is no longer set by Runner.Build: a missing build_cwd now sets Err (a hard failure) rather than skipping, because by the time a Spec reaches Build the service is known to be in the current env and its inputs are expected to exist. Kept on the struct so the dispatcher's skip-branch is inert (defensive) rather than removed outright.
Tag carries the resolved tag the build actually produced so the caller can persist it to the per-service state file. Duration is the wall-clock time the user's command took so the build-summary line can show it alongside the docker timings.
type Runner ¶
type Runner struct {
// contains filtered or unexported fields
}
Runner executes a Spec's BuildCmd as POSIX shell (in-process) VERBATIM — the string the KCL render produced is the string the shell sees. Mirrors deploytarget.ExternalProvider's shape: same `sh -c` invocation, same env-overlay precedence, same "user owns the command" contract.
There is no token substitution. A ShellBuild `cmd` is plain KCL, so everything forge used to inject is composed in KCL where the value already lives (`forge.env()`, `file.workdir()`, the arch accessor, the rendered image reference, a literal), and every `$VAR` / `${VAR}` in the command belongs to the shell.
Tests inject a fake Runner via the unexported runner field; the production zero-value runs commands through execRunner.
func NewRunner ¶
func NewRunner() Runner
NewRunner returns a production Runner whose commandRunner is the real os/exec wrapper. Exposed so the forge CLI can construct one without poking package internals.
func (Runner) Build ¶
func (r Runner) Build(ctx context.Context, spec Spec) BuildResult
Build runs spec.BuildCmd through `sh -c` after substituting tokens and merging BuildEnv onto os.Environ(). Failure semantics:
- spec.BuildCwd resolves against spec.ProjectDir when relative.
- If the resolved cwd is non-empty AND doesn't exist on disk, return Err set (naming the missing path). This is a HARD failure, not a skip: the dispatcher only builds services that are in the current env, so a missing source tree means a build that was supposed to run can't, and reporting success would let a deploy reference an unpushed image.
- Any other failure (cwd Stat error other than NotExist, exec error) returns Err set.
The user's BuildCmd owns BOTH the build AND the push — forge does not run docker push afterwards. Matches External's contract.
type Source ¶ added in v0.1.45
type Source struct {
// Dir is the checkout's top-level directory on this machine. Local
// state only: it is never copied into a release.
Dir string `json:"dir"`
// Repo is the canonical origin remote ("github.com/org/repo"); "" when
// the checkout has no origin.
Repo string `json:"repo,omitempty"`
// Module is the Go module the checkout declares — the go.mod nearest the
// cwd, at or below the top level — or "" when there is none. It is how a
// sibling checkout is matched to the project's go.mod require.
Module string `json:"module,omitempty"`
// ModuleDir is that go.mod's directory relative to Dir ("." at the top
// level). A module in a subdirectory tags its versions "<dir>/vX.Y.Z".
ModuleDir string `json:"module_dir,omitempty"`
// Commit is HEAD, full hex.
Commit string `json:"commit"`
// Dirty: tracked or untracked-unignored changes existed when the command
// started — the same rule release provenance applies to the project.
Dirty bool `json:"dirty"`
// Project is true when the checkout is the project's OWN repository (a
// ShellBuild with no cwd, or `cwd = "."`). Its commit is then the
// release's own, which release provenance already records and checks.
Project bool `json:"project,omitempty"`
}
Source is the git checkout a ShellBuild's command ran in, captured just before the command started.
It is what ties a ShellBuild's image to a commit. A ShellBuild whose cwd is a SIBLING checkout (`cwd = "../reliant"`) builds whatever that checkout holds, and the project's own commit says nothing about it — which is how a release once shipped a days-old sibling build while go.mod pinned a newer commit. Recording the checkout's HEAD and dirty flag beside the digest is what lets a release be checked against the commit the project pins, and lets a reader answer "which commit is in this image" without rebuilding it.
It describes the directory the command RAN IN. A command that exports some other commit (`git archive <sha>`) and builds that is invisible to forge; this is the checkout it was handed.
func CaptureSource ¶ added in v0.1.45
CaptureSource reports the git checkout dir belongs to.
(nil, nil) means dir is not inside a git work tree, or the repository has no commit yet: a ShellBuild over a plain directory has no commit to record, and that is not an error. An error means git itself could not answer — missing from PATH, or a status that failed — which a caller guarding a release must not mistake for "nothing to check".
func (*Source) External ¶ added in v0.1.45
External reports whether the checkout is a repository other than the project's — a sibling whose commit the project must pin.
func (*Source) ForRelease ¶ added in v0.1.45
func (s *Source) ForRelease() *release.BuildSource
ForRelease is the part of the source a release ledger records: no path, because a release is shared beyond this machine.
func (*Source) ResolveCommit ¶ added in v0.1.45
ResolveCommit resolves rev (a tag, a ref) to a full commit in this checkout's object store, and fails when the checkout does not have it — a tag nobody fetched is a different problem from a checkout on the wrong commit, and the two get different fixes.
type Spec ¶
type Spec struct {
// Service is the KCL Service.name, surfaced in log lines and in the
// per-service build-state file.
Service string
// Image is the rendered Service.image reference, recorded in build
// state so a later deploy can resolve the same image. The command
// composes its own reference in KCL.
Image string
// Tag is the resolved image tag, recorded in build state and in the
// build log line. Bound into the render as the `image_tag` KCL input
// before the command string exists, so the rendered cmd already
// carries whatever reference KCL composed from it.
Tag string
// BuildCmd is the shell command to exec via `sh -c`, verbatim.
// Required — callers should NOT construct a Spec without one.
BuildCmd string
// BuildCwd is the working directory the command runs from.
// Relative paths are resolved against ProjectDir. Empty means
// "use ProjectDir directly." Missing-on-disk is a hard failure
// (see Runner.Build).
BuildCwd string
// ProjectDir is the project root containing forge.yaml — the cwd
// the command runs from when BuildCwd is empty, and the base that
// a relative BuildCwd resolves against.
ProjectDir string
// BuildEnv carries the ShellBuild's declared `env` map, merged onto
// the process environment for the command (declared keys win).
BuildEnv map[string]string
// RequireReceipt opts into the immutable-release builder protocol. Legacy
// local builds do not claim receipt-backed provenance or automatic reuse.
RequireReceipt bool
ExpectedInputDigest string
}
Spec is the per-service build-target shape consumed by the runner. Mirrors deploytarget.ExternalSpec for the build side — the fields the user declares on KCL Service translate to this shape.
Nothing here is substituted into BuildCmd. The command is whatever the KCL render produced, run byte-for-byte; Service/Image/Tag are carried only so the dispatcher can label log lines and record build state.
type State ¶
type State struct {
Service string `json:"service"`
// Env is the deploy env this build belongs to. It is recorded because the
// FILENAME cannot be parsed back into (env, service): both segments may
// contain "-", so build-dev-k8s-gateway.json is equally readable as
// (dev, k8s-gateway) and (dev-k8s, gateway). A reader that globs
// "build-<env>-*.json" therefore also matches every sibling env whose name
// extends this one, and when both declare the same image the later file
// silently overwrites the earlier digest. Written by WriteState; verified
// on read, so a mismatched file is skipped rather than believed.
Env string `json:"env,omitempty"`
// Image is the REPOSITORY, registry host included — the reference the
// workload declared, minus any tag. There is no Registry field: the
// registry is part of the image.
Image string `json:"image"`
Tag string `json:"tag"`
PushedAt string `json:"pushed_at"`
// Digest is the content-addressed manifest digest of the pushed image
// (canonical `sha256:...`, no `@` prefix, no repo), captured best-effort
// from the registry after the user's build_cmd builds+pushes. EMPTY when
// the digest couldn't be resolved (local-only ref, registry unreachable,
// or buildx/docker absent) — capture never fails the build. Mirrors
// internal/cli.BuildState.Digest so `forge env deploy` pins the immutable
// `<image>@<digest>` reference for external builds too, instead of
// falling back to the mutable env tag (the stale-arch-cache footgun).
Digest string `json:"digest,omitempty"`
// Platforms is the OS/arch set the pushed manifest advertises, captured
// alongside Digest. Informational; empty when the lookup failed.
Platforms []string `json:"platforms,omitempty"`
// Source is the git checkout the ShellBuild's command ran in — its HEAD
// and dirty flag when the command started. Nil when the cwd is not a git
// checkout, and on state an older forge wrote. A release cut from this
// state checks it against the commit the project pins (see Source).
Source *Source `json:"source,omitempty"`
}
State is the per-service build-state record persisted after a successful Runner.Build. Mirrors internal/cli's BuildState shape on the project-docker side — but per-service so different external- build services can carry independent (image, tag) tuples.
PushedAt is RFC3339 wall-clock. The state file is informational across forge invocations, so real time is fine.
func ReadState ¶
ReadState loads the per-service build-state file. Returns (nil, nil) when the file is missing — that's the deploy-without- build path (CI with a separate build job, or a fresh checkout) and the caller falls through to whatever default tag-resolution applies. Returns (nil, err) for malformed JSON or unreadable files; callers should not silently swallow these.