go-build/pkg/build/ci.go
Virgil 892901dee2 fix(build): emit artifact metadata from GitHub env
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 21:19:50 +00:00

210 lines
5.9 KiB
Go

// Package build provides project type detection and cross-compilation for the Core build system.
// This file handles CI environment detection and GitHub Actions output formatting.
package build
import (
"encoding/json"
"dappco.re/go/core"
io_interface "dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
)
// CIContext holds environment information detected from a GitHub Actions run.
//
// ci := build.DetectCI()
// if ci != nil {
// fmt.Println(ci.ShortSHA) // "abc1234"
// }
type CIContext struct {
// Ref is the full git ref (GITHUB_REF).
// ci.Ref // "refs/tags/v1.2.3"
Ref string
// SHA is the full commit hash (GITHUB_SHA).
// ci.SHA // "abc1234def5678..."
SHA string
// ShortSHA is the first 7 characters of SHA.
// ci.ShortSHA // "abc1234"
ShortSHA string
// Tag is the tag name when the ref is a tag ref.
// ci.Tag // "v1.2.3"
Tag string
// IsTag is true when the ref is a tag ref (refs/tags/...).
// ci.IsTag // true
IsTag bool
// Branch is the branch name when the ref is a branch ref.
// ci.Branch // "main"
Branch string
// Repo is the owner/repo string (GITHUB_REPOSITORY).
// ci.Repo // "dappcore/core"
Repo string
// Owner is the repository owner derived from Repo.
// ci.Owner // "dappcore"
Owner string
}
// artifactMeta is the structure written to artifact_meta.json.
type artifactMeta struct {
Name string `json:"name"`
OS string `json:"os"`
Arch string `json:"arch"`
Ref string `json:"ref,omitempty"`
SHA string `json:"sha,omitempty"`
Tag string `json:"tag,omitempty"`
Branch string `json:"branch,omitempty"`
IsTag bool `json:"is_tag"`
Repo string `json:"repo,omitempty"`
}
// FormatGitHubAnnotation formats a build message as a GitHub Actions annotation.
//
// s := build.FormatGitHubAnnotation("error", "main.go", 42, "undefined: foo")
// // "::error file=main.go,line=42::undefined: foo"
//
// s := build.FormatGitHubAnnotation("warning", "pkg/build/ci.go", 10, "unused import")
// // "::warning file=pkg/build/ci.go,line=10::unused import"
func FormatGitHubAnnotation(level, file string, line int, message string) string {
return core.Sprintf("::%s file=%s,line=%d::%s", level, file, line, message)
}
// DetectCI reads GitHub Actions environment variables and returns a populated CIContext.
// Returns nil if GITHUB_ACTIONS is not set or GITHUB_SHA is empty, which indicates
// the process is not running inside GitHub Actions.
//
// ci := build.DetectCI()
// if ci == nil {
// // running locally, skip CI-specific output
// }
// if ci != nil && ci.IsTag {
// // upload release assets
// }
func DetectCI() *CIContext {
return detectGitHubContext(true)
}
// DetectGitHubMetadata returns GitHub CI metadata when the standard environment
// variables are present, even if GITHUB_ACTIONS is unset.
//
// This is useful for metadata emission paths that only need the GitHub ref/SHA
// shape and should not be coupled to a specific runner environment.
func DetectGitHubMetadata() *CIContext {
return detectGitHubContext(false)
}
func detectGitHubContext(requireActions bool) *CIContext {
if requireActions && core.Env("GITHUB_ACTIONS") == "" {
return nil
}
sha := core.Env("GITHUB_SHA")
if sha == "" {
return nil
}
ref := core.Env("GITHUB_REF")
repo := core.Env("GITHUB_REPOSITORY")
ctx := &CIContext{
Ref: ref,
SHA: sha,
Repo: repo,
}
populateGitHubContext(ctx)
return ctx
}
func populateGitHubContext(ctx *CIContext) {
if ctx == nil {
return
}
// ShortSHA is first 7 chars of SHA.
runes := []rune(ctx.SHA)
if len(runes) >= 7 {
ctx.ShortSHA = string(runes[:7])
} else {
ctx.ShortSHA = ctx.SHA
}
// Derive owner from "owner/repo" format.
if ctx.Repo != "" {
parts := core.SplitN(ctx.Repo, "/", 2)
if len(parts) == 2 {
ctx.Owner = parts[0]
}
}
// Classify ref as tag or branch.
const tagPrefix = "refs/tags/"
const branchPrefix = "refs/heads/"
if core.HasPrefix(ctx.Ref, tagPrefix) {
ctx.IsTag = true
ctx.Tag = core.TrimPrefix(ctx.Ref, tagPrefix)
} else if core.HasPrefix(ctx.Ref, branchPrefix) {
ctx.Branch = core.TrimPrefix(ctx.Ref, branchPrefix)
}
}
// ArtifactName generates a canonical artifact filename from the build name, CI context, and target.
// Format: {name}_{OS}_{ARCH}_{TAG|SHORT_SHA}
// When ci is nil or has no tag or SHA, only the name and target are used.
//
// name := build.ArtifactName("core", ci, build.Target{OS: "linux", Arch: "amd64"})
// // "core_linux_amd64_v1.2.3" (when ci.IsTag)
// // "core_linux_amd64_abc1234" (when ci != nil, not a tag)
// // "core_linux_amd64" (when ci is nil)
func ArtifactName(buildName string, ci *CIContext, target Target) string {
base := core.Join("_", buildName, target.OS, target.Arch)
if ci == nil {
return base
}
var version string
if ci.IsTag && ci.Tag != "" {
version = ci.Tag
} else if ci.ShortSHA != "" {
version = ci.ShortSHA
}
if version == "" {
return base
}
return core.Concat(base, "_", version)
}
// WriteArtifactMeta writes an artifact_meta.json file to path.
// The file contains the build name, target OS/arch, and CI metadata if available.
//
// err := build.WriteArtifactMeta(io.Local, "dist/artifact_meta.json", "core", build.Target{OS: "linux", Arch: "amd64"}, ci)
// // writes: {"name":"core","os":"linux","arch":"amd64","tag":"v1.2.3","is_tag":true,...}
func WriteArtifactMeta(fs io_interface.Medium, path string, buildName string, target Target, ci *CIContext) error {
meta := artifactMeta{
Name: buildName,
OS: target.OS,
Arch: target.Arch,
}
if ci != nil {
meta.Ref = ci.Ref
meta.SHA = ci.SHA
meta.Tag = ci.Tag
meta.Branch = ci.Branch
meta.IsTag = ci.IsTag
meta.Repo = ci.Repo
}
data, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return coreerr.E("build.WriteArtifactMeta", "failed to marshal artifact meta", err)
}
if err := fs.Write(path, string(data)); err != nil {
return coreerr.E("build.WriteArtifactMeta", "failed to write artifact meta", err)
}
return nil
}