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, same ${X} substitution. 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.
forge:exclude-contract buildtarget is an outbound build-dispatch adapter (the `sh -c` shell-build escape hatch for out-of-module sources), not a contract-shaped service. Opt out of the require-contract rule.
Index ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func Expand ¶
Expand substitutes the documented ${X} tokens in template against the Spec. Thin wrapper around deploytarget.ExpandVars + Vars(spec) so callers don't have to thread the var-map through themselves.
Phase-1 surface: callers that just want to know "what would forge run" can call this without instantiating a Runner.
func StatePath ¶
StatePath exposes the per-service state path for callers that want to print it (forge build's summary, forge audit) without re-deriving the layout. Kept exported so the path lives in one place.
func Vars ¶
Vars returns the substitution map for a Spec's ${X} tokens. The built-in keys (IMAGE/TAG/CODE_VERSION/SERVICE/TARGETARCH/REGISTRY/ PROJECT_DIR/ENV/BUILD_CWD) win on conflict with BuildEnv keys — same precedence the deploy-side External provider uses (so users carry one mental model across both escape hatches).
Exposed so callers that want to render a preview of the substituted command (forge build --dry-run, forge audit) can reuse the same token map the runner consumes — no risk of the preview drifting from the actual exec.
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 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
}
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 via `sh -c` after substituting the documented ${X} tokens. Mirrors deploytarget.ExternalProvider's shape — same `sh -c` invocation, same env-overlay precedence, same "user owns the command" contract.
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 Spec ¶
type Spec struct {
// Service is the KCL Service.name, surfaced in log lines and used
// as the ${SERVICE} substitution token.
Service string
// Image is the raw Service.image string. Used as the ${IMAGE}
// substitution token. The user composes the registry into their
// command via ${REGISTRY}/${IMAGE}:${TAG} (matching External's
// ${IMAGE} semantics).
Image string
// Tag is the build-resolved image tag (git describe or --tag
// override). Used as the ${TAG} substitution token.
Tag string
// TargetArch is the resolved deploy-target GOARCH (amd64/arm64).
// Used as the ${TARGETARCH} substitution token so user commands
// can cross-compile and pass --platform=linux/<arch> to docker
// buildx without re-deriving the arch.
TargetArch string
// Registry is the configured docker registry (from forge.yaml
// docker.registry or the resolved push target). Used as the
// ${REGISTRY} substitution token.
Registry string
// ProjectDir is the project root containing forge.yaml. Used as
// the ${PROJECT_DIR} substitution token AND as the cwd fallback
// when BuildCwd is empty.
ProjectDir string
// Env is the deploy-env name (dev/staging/prod). Used as the
// ${ENV} substitution token so a build_cmd can branch on env (e.g.
// a different Dockerfile or build-arg per env). Mirrors the
// deploy-side External provider's ${ENV} token.
Env string
// BuildCmd is the shell command to exec via `sh -c`. Required —
// callers should NOT construct a Spec without a build_cmd set.
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 warn-and-skip
// (see Runner.Build).
BuildCwd string
// BuildEnv carries extra env vars merged into the command's
// environment AND added to the substitution map (built-in tokens
// win on conflict — same precedence External uses).
BuildEnv map[string]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.
Image is the raw Service.image string (matching External's IMAGE semantics — registry is composed separately via ${REGISTRY}). Tag is the build-resolved tag (`git describe` or --tag override) shared across all services in a build invocation.
type State ¶
type State struct {
Service string `json:"service"`
Image string `json:"image"`
Tag string `json:"tag"`
Registry string `json:"registry,omitempty"`
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 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"`
}
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.