go-build/pkg/build/ci_test.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

332 lines
10 KiB
Go

package build
import (
"encoding/json"
"testing"
"dappco.re/go/core/build/internal/ax"
"dappco.re/go/core/io"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// setenvCI sets the GitHub Actions environment variables for a test and cleans up afterwards.
func setenvCI(t *testing.T, sha, ref, repo string) {
t.Helper()
t.Setenv("GITHUB_ACTIONS", "true")
t.Setenv("GITHUB_SHA", sha)
t.Setenv("GITHUB_REF", ref)
t.Setenv("GITHUB_REPOSITORY", repo)
}
func TestCi_FormatGitHubAnnotation_Good(t *testing.T) {
t.Run("formats error annotation correctly", func(t *testing.T) {
s := FormatGitHubAnnotation("error", "main.go", 42, "undefined: foo")
assert.Equal(t, "::error file=main.go,line=42::undefined: foo", s)
})
t.Run("formats warning annotation correctly", func(t *testing.T) {
s := FormatGitHubAnnotation("warning", "pkg/build/ci.go", 10, "unused import")
assert.Equal(t, "::warning file=pkg/build/ci.go,line=10::unused import", s)
})
t.Run("formats notice annotation correctly", func(t *testing.T) {
s := FormatGitHubAnnotation("notice", "cmd/main.go", 1, "build started")
assert.Equal(t, "::notice file=cmd/main.go,line=1::build started", s)
})
t.Run("uses correct line numbers", func(t *testing.T) {
s := FormatGitHubAnnotation("error", "file.go", 99, "msg")
assert.Contains(t, s, "line=99")
})
}
func TestCi_FormatGitHubAnnotation_Bad(t *testing.T) {
t.Run("empty file produces empty file field", func(t *testing.T) {
s := FormatGitHubAnnotation("error", "", 1, "message")
assert.Equal(t, "::error file=,line=1::message", s)
})
t.Run("empty level still produces annotation format", func(t *testing.T) {
s := FormatGitHubAnnotation("", "main.go", 1, "message")
assert.Equal(t, ":: file=main.go,line=1::message", s)
})
t.Run("empty message produces empty message section", func(t *testing.T) {
s := FormatGitHubAnnotation("error", "main.go", 1, "")
assert.Equal(t, "::error file=main.go,line=1::", s)
})
t.Run("line zero is valid", func(t *testing.T) {
s := FormatGitHubAnnotation("error", "main.go", 0, "msg")
assert.Contains(t, s, "line=0")
})
}
func TestCi_FormatGitHubAnnotation_Ugly(t *testing.T) {
t.Run("message with newline is included as-is", func(t *testing.T) {
s := FormatGitHubAnnotation("error", "main.go", 1, "line one\nline two")
assert.Contains(t, s, "line one\nline two")
})
t.Run("message with colons does not break format", func(t *testing.T) {
s := FormatGitHubAnnotation("error", "main.go", 1, "error: something::bad")
// The leading ::level file=... part should still be present
assert.Contains(t, s, "::error file=main.go,line=1::")
assert.Contains(t, s, "error: something::bad")
})
t.Run("file path with spaces is included as-is", func(t *testing.T) {
s := FormatGitHubAnnotation("warning", "my file.go", 5, "msg")
assert.Contains(t, s, "file=my file.go")
})
t.Run("unicode message is preserved", func(t *testing.T) {
s := FormatGitHubAnnotation("error", "main.go", 1, "résumé: 日本語")
assert.Contains(t, s, "résumé: 日本語")
})
}
func TestCi_DetectCI_Good(t *testing.T) {
t.Run("detects tag ref", func(t *testing.T) {
setenvCI(t, "abc1234def5678901234567890123456789012345", "refs/tags/v1.2.3", "dappcore/core")
ci := DetectCI()
require.NotNil(t, ci)
assert.Equal(t, "refs/tags/v1.2.3", ci.Ref)
assert.Equal(t, "abc1234def5678901234567890123456789012345", ci.SHA)
assert.Equal(t, "abc1234", ci.ShortSHA)
assert.Equal(t, "v1.2.3", ci.Tag)
assert.True(t, ci.IsTag)
assert.Equal(t, "", ci.Branch)
assert.Equal(t, "dappcore/core", ci.Repo)
assert.Equal(t, "dappcore", ci.Owner)
})
t.Run("detects branch ref", func(t *testing.T) {
setenvCI(t, "deadbeef1234567890123456789012345678abcd", "refs/heads/main", "org/repo")
ci := DetectCI()
require.NotNil(t, ci)
assert.Equal(t, "main", ci.Branch)
assert.False(t, ci.IsTag)
assert.Equal(t, "", ci.Tag)
assert.Equal(t, "deadbee", ci.ShortSHA)
})
t.Run("owner is derived from repo", func(t *testing.T) {
setenvCI(t, "aaaaaaaaaaaaaaaa", "refs/heads/dev", "myorg/myrepo")
ci := DetectCI()
require.NotNil(t, ci)
assert.Equal(t, "myorg", ci.Owner)
assert.Equal(t, "myorg/myrepo", ci.Repo)
})
}
func TestCi_DetectCI_Bad(t *testing.T) {
t.Run("returns nil when GITHUB_ACTIONS is not set", func(t *testing.T) {
t.Setenv("GITHUB_ACTIONS", "")
t.Setenv("GITHUB_SHA", "abc1234def5678901234567890123456789012345")
t.Setenv("GITHUB_REF", "refs/heads/main")
t.Setenv("GITHUB_REPOSITORY", "org/repo")
ci := DetectCI()
assert.Nil(t, ci)
})
t.Run("returns nil when GITHUB_SHA is not set", func(t *testing.T) {
t.Setenv("GITHUB_ACTIONS", "true")
t.Setenv("GITHUB_SHA", "")
t.Setenv("GITHUB_REF", "")
t.Setenv("GITHUB_REPOSITORY", "")
ci := DetectCI()
assert.Nil(t, ci)
})
}
func TestCi_DetectGitHubMetadata_Good(t *testing.T) {
t.Run("detects GitHub metadata without GITHUB_ACTIONS", func(t *testing.T) {
t.Setenv("GITHUB_ACTIONS", "")
t.Setenv("GITHUB_SHA", "abc1234def5678901234567890123456789012345")
t.Setenv("GITHUB_REF", "refs/heads/main")
t.Setenv("GITHUB_REPOSITORY", "org/repo")
ci := DetectGitHubMetadata()
require.NotNil(t, ci)
assert.Equal(t, "abc1234", ci.ShortSHA)
assert.Equal(t, "main", ci.Branch)
assert.Equal(t, "org/repo", ci.Repo)
assert.Equal(t, "org", ci.Owner)
})
}
func TestCi_DetectCI_Ugly(t *testing.T) {
t.Run("SHA shorter than 7 chars still works", func(t *testing.T) {
setenvCI(t, "abc", "refs/heads/main", "org/repo")
ci := DetectCI()
require.NotNil(t, ci)
assert.Equal(t, "abc", ci.ShortSHA)
})
t.Run("ref with unknown prefix leaves tag and branch empty", func(t *testing.T) {
setenvCI(t, "abc1234def5678", "refs/pull/42/merge", "org/repo")
ci := DetectCI()
require.NotNil(t, ci)
assert.Equal(t, "", ci.Tag)
assert.Equal(t, "", ci.Branch)
assert.False(t, ci.IsTag)
})
t.Run("repo without slash leaves owner empty", func(t *testing.T) {
setenvCI(t, "abc1234def5678", "refs/heads/main", "noslashrepo")
ci := DetectCI()
require.NotNil(t, ci)
assert.Equal(t, "", ci.Owner)
assert.Equal(t, "noslashrepo", ci.Repo)
})
t.Run("empty repo is tolerated", func(t *testing.T) {
setenvCI(t, "abc1234def5678", "refs/heads/main", "")
ci := DetectCI()
require.NotNil(t, ci)
assert.Equal(t, "", ci.Owner)
assert.Equal(t, "", ci.Repo)
})
}
func TestCi_ArtifactName_Good(t *testing.T) {
t.Run("uses tag when IsTag is true", func(t *testing.T) {
ci := &CIContext{
IsTag: true,
Tag: "v1.2.3",
ShortSHA: "abc1234",
}
name := ArtifactName("core", ci, Target{OS: "linux", Arch: "amd64"})
assert.Equal(t, "core_linux_amd64_v1.2.3", name)
})
t.Run("uses ShortSHA when not a tag", func(t *testing.T) {
ci := &CIContext{
IsTag: false,
ShortSHA: "abc1234",
}
name := ArtifactName("myapp", ci, Target{OS: "darwin", Arch: "arm64"})
assert.Equal(t, "myapp_darwin_arm64_abc1234", name)
})
t.Run("produces correct format for windows", func(t *testing.T) {
ci := &CIContext{IsTag: true, Tag: "v2.0.0", ShortSHA: "ff00ff0"}
name := ArtifactName("core", ci, Target{OS: "windows", Arch: "amd64"})
assert.Equal(t, "core_windows_amd64_v2.0.0", name)
})
}
func TestCi_ArtifactName_Bad(t *testing.T) {
t.Run("nil ci returns name_os_arch only", func(t *testing.T) {
name := ArtifactName("core", nil, Target{OS: "linux", Arch: "amd64"})
assert.Equal(t, "core_linux_amd64", name)
})
t.Run("ci with no tag and no SHA returns name_os_arch only", func(t *testing.T) {
ci := &CIContext{IsTag: false, ShortSHA: "", Tag: ""}
name := ArtifactName("core", ci, Target{OS: "linux", Arch: "amd64"})
assert.Equal(t, "core_linux_amd64", name)
})
}
func TestCi_ArtifactName_Ugly(t *testing.T) {
t.Run("empty build name produces leading underscore segments", func(t *testing.T) {
ci := &CIContext{IsTag: true, Tag: "v1.0.0", ShortSHA: "abc1234"}
name := ArtifactName("", ci, Target{OS: "linux", Arch: "amd64"})
// Empty name results in "_linux_amd64_v1.0.0"
assert.Contains(t, name, "linux_amd64_v1.0.0")
})
t.Run("IsTag true but empty tag falls back to ShortSHA", func(t *testing.T) {
ci := &CIContext{IsTag: true, Tag: "", ShortSHA: "abc1234"}
name := ArtifactName("core", ci, Target{OS: "linux", Arch: "amd64"})
assert.Equal(t, "core_linux_amd64_abc1234", name)
})
t.Run("special chars in build name are preserved", func(t *testing.T) {
ci := &CIContext{IsTag: true, Tag: "v1.0.0"}
name := ArtifactName("core-build", ci, Target{OS: "linux", Arch: "amd64"})
assert.Equal(t, "core-build_linux_amd64_v1.0.0", name)
})
}
func TestCi_WriteArtifactMeta_Good(t *testing.T) {
fs := io.Local
t.Run("writes valid JSON with CI context", func(t *testing.T) {
dir := t.TempDir()
path := ax.Join(dir, "artifact_meta.json")
ci := &CIContext{
Ref: "refs/tags/v1.2.3",
SHA: "abc1234def5678",
ShortSHA: "abc1234",
Tag: "v1.2.3",
IsTag: true,
Repo: "dappcore/core",
Owner: "dappcore",
}
err := WriteArtifactMeta(fs, path, "core", Target{OS: "linux", Arch: "amd64"}, ci)
require.NoError(t, err)
content, readErr := ax.ReadFile(path)
require.NoError(t, readErr)
var meta map[string]any
require.NoError(t, json.Unmarshal(content, &meta))
assert.Equal(t, "core", meta["name"])
assert.Equal(t, "linux", meta["os"])
assert.Equal(t, "amd64", meta["arch"])
assert.Equal(t, "v1.2.3", meta["tag"])
assert.Equal(t, true, meta["is_tag"])
assert.Equal(t, "dappcore/core", meta["repo"])
assert.Equal(t, "refs/tags/v1.2.3", meta["ref"])
})
t.Run("writes valid JSON without CI context", func(t *testing.T) {
dir := t.TempDir()
path := ax.Join(dir, "artifact_meta.json")
err := WriteArtifactMeta(fs, path, "myapp", Target{OS: "darwin", Arch: "arm64"}, nil)
require.NoError(t, err)
content, readErr := ax.ReadFile(path)
require.NoError(t, readErr)
var meta map[string]any
require.NoError(t, json.Unmarshal(content, &meta))
assert.Equal(t, "myapp", meta["name"])
assert.Equal(t, "darwin", meta["os"])
assert.Equal(t, "arm64", meta["arch"])
assert.Equal(t, false, meta["is_tag"])
})
t.Run("output is pretty-printed JSON", func(t *testing.T) {
dir := t.TempDir()
path := ax.Join(dir, "artifact_meta.json")
err := WriteArtifactMeta(fs, path, "core", Target{OS: "windows", Arch: "amd64"}, nil)
require.NoError(t, err)
content, readErr := ax.ReadFile(path)
require.NoError(t, readErr)
// Pretty-printed JSON contains indentation
assert.Contains(t, string(content), "\n")
assert.Contains(t, string(content), " ")
})
}