From e9dd5a9b82d48ff9b6b6946b6103d01b6bc056a7 Mon Sep 17 00:00:00 2001 From: "tbhb.dev Agent" <334376894+tbhb-agent@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:10:53 -0500 Subject: [PATCH] feat(release): package platform binary assets Consumers need release bundles with a stable executable layout and checksums tied to the tagged source. Packaging locally establishes that asset contract before CI adopts publication and verification. Assisted-by: codex:gpt-6.1-sol Signed-off-by: Tony Burns --- .cspell-words.txt | 1 + .devtools/shell-allowlist.toml | 1 + cmd/release-assets/main.go | 20 ++ internal/releaseassets/assets.go | 283 ++++++++++++++++++ .../releaseassets/assets_internal_test.go | 243 +++++++++++++++ internal/releaseassets/inventory.json | 14 + 6 files changed, 562 insertions(+) create mode 100644 cmd/release-assets/main.go create mode 100644 internal/releaseassets/assets.go create mode 100644 internal/releaseassets/assets_internal_test.go create mode 100644 internal/releaseassets/inventory.json diff --git a/.cspell-words.txt b/.cspell-words.txt index 199ed823..baf368fa 100644 --- a/.cspell-words.txt +++ b/.cspell-words.txt @@ -328,6 +328,7 @@ readarray recompiles recvcheck regen +releaseassets relint relitigates relpath diff --git a/.devtools/shell-allowlist.toml b/.devtools/shell-allowlist.toml index dca50ee2..4c7f8279 100644 --- a/.devtools/shell-allowlist.toml +++ b/.devtools/shell-allowlist.toml @@ -79,6 +79,7 @@ commands = [ "gh-post", "gh-reviewer", "guard-markdown", + "release-assets", "shell-allowlist", "ssh-sign", "test-hooks", diff --git a/cmd/release-assets/main.go b/cmd/release-assets/main.go new file mode 100644 index 00000000..1f67ad47 --- /dev/null +++ b/cmd/release-assets/main.go @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Tony Burns + +// Command release-assets packages release bundles. +package main + +import ( + "context" + "fmt" + "os" + + "github.com/tbhb-dev/devtools/internal/releaseassets" +) + +func main() { + if err := releaseassets.Run(context.Background(), os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/internal/releaseassets/assets.go b/internal/releaseassets/assets.go new file mode 100644 index 00000000..71747320 --- /dev/null +++ b/internal/releaseassets/assets.go @@ -0,0 +1,283 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Tony Burns + +package releaseassets + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "time" + + _ "embed" +) + +const ( + commandArgs = 3 + executableMode = 0o755 +) + +const ( + metadataName = "release.json" + checksumName = "SHA256SUMS" +) + +//go:embed inventory.json +var inventoryJSON []byte + +type inventory struct { + Platforms []string `json:"platforms"` + Binaries []string `json:"binaries"` +} + +type metadata struct { + Version string `json:"version"` + Commit string `json:"commit"` + BuildDate string `json:"build_date"` + Inventory inventory `json:"inventory"` +} + +var ( + errEvidence = errors.New("release evidence differs") + errUsage = errors.New("usage: release-assets package TAG DIR") +) + +type command func(context.Context, []string, string, ...string) ([]byte, error) + +func execute(ctx context.Context, env []string, name string, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Env = append(os.Environ(), env...) + out, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("%s %v: %w: %s", name, args, err, out) + } + return out, nil +} + +// Run packages a tagged release into the requested output directory. +func Run(ctx context.Context, args []string) error { + if len(args) != commandArgs || args[0] != "package" { + return errUsage + } + return build(ctx, execute, args[1], args[2]) +} + +func archiveName(tag, platform string) string { + ext := ".tar.gz" + if strings.HasPrefix(platform, "windows/") { + ext = ".zip" + } + return "devtools_" + tag + "_" + strings.ReplaceAll(platform, "/", "_") + ext +} + +func executable(name, platform string) string { + if strings.HasPrefix(platform, "windows/") { + return name + ".exe" + } + return name +} + +func releaseMetadata(ctx context.Context, cmd command, tag string) (metadata, error) { + commit, err := cmd(ctx, nil, "git", "rev-parse", "HEAD") + if err != nil { + return metadata{}, fmt.Errorf("release metadata: %w", err) + } + tagged, err := cmd(ctx, nil, "git", "rev-parse", "refs/tags/"+tag+"^{commit}") + if err != nil { + return metadata{}, fmt.Errorf("release metadata: %w", err) + } + if !bytes.Equal(commit, tagged) { + return metadata{}, fmt.Errorf("tag does not name HEAD: %w", errEvidence) + } + date, err := cmd(ctx, nil, "git", "show", "-s", "--format=%cI", "HEAD") + if err != nil { + return metadata{}, fmt.Errorf("release metadata: %w", err) + } + stamp, err := time.Parse(time.RFC3339, strings.TrimSpace(string(date))) + if err != nil { + return metadata{}, fmt.Errorf("release metadata: %w", err) + } + var inv inventory + if err = json.Unmarshal(inventoryJSON, &inv); err != nil { + return metadata{}, fmt.Errorf("release metadata: %w", err) + } + return metadata{tag, strings.TrimSpace(string(commit)), stamp.UTC().Format(time.RFC3339), inv}, nil +} + +func build(ctx context.Context, cmd command, tag, dir string) error { + meta, err := releaseMetadata(ctx, cmd, tag) + if err != nil { + return fmt.Errorf("release assets: %w", err) + } + flags, err := cmd(ctx, nil, "bash", ".devtools/scripts/go-ldflags.sh") + if err != nil { + return fmt.Errorf("release assets: %w", err) + } + if err = os.MkdirAll(dir, 0o750); err != nil { + return fmt.Errorf("release assets: %w", err) + } + staging, err := os.MkdirTemp("", "devtools-release-*") + if err != nil { + return fmt.Errorf("release assets: %w", err) + } + defer os.RemoveAll(staging) + for _, platform := range meta.Inventory.Platforms { + if err = buildPlatform( + ctx, + cmd, + staging, + platform, + strings.TrimSpace(string(flags)), + meta.Inventory, + ); err != nil { + return fmt.Errorf("release assets: %w", err) + } + if err = pack(dir, staging, platform, meta); err != nil { + return fmt.Errorf("release assets: %w", err) + } + } + raw, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return fmt.Errorf("release assets: %w", err) + } + if err = os.WriteFile(filepath.Join(dir, metadataName), append(raw, '\n'), 0o600); err != nil { + return fmt.Errorf("release assets: %w", err) + } + sums, err := checksums(dir, meta) + if err != nil { + return fmt.Errorf("release assets: %w", err) + } + if err = os.WriteFile(filepath.Join(dir, checksumName), sums, 0o600); err != nil { + return fmt.Errorf("release assets: %w", err) + } + return nil +} + +func buildPlatform(ctx context.Context, cmd command, staging, platform, flags string, inv inventory) error { + parts := strings.Split(platform, "/") + for _, bin := range inv.Binaries { + _, err := cmd( + ctx, + []string{"CGO_ENABLED=0", "GOOS=" + parts[0], "GOARCH=" + parts[1]}, + "go", + "build", + "-trimpath", + "-buildvcs=false", + "-ldflags", + flags, + "-o", + filepath.Join(staging, executable(bin, platform)), + "./cmd/"+bin, + ) + if err != nil { + return fmt.Errorf("release assets: %w", err) + } + } + return nil +} + +func assetNames(meta metadata) []string { + names := make([]string, 0, 1+len(meta.Inventory.Platforms)) + names = append(names, metadataName) + for _, platform := range meta.Inventory.Platforms { + names = append(names, archiveName(meta.Version, platform)) + } + slices.Sort(names) + return names +} + +func checksums(dir string, meta metadata) ([]byte, error) { + var sums strings.Builder + for _, name := range assetNames(meta) { + data, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + return nil, fmt.Errorf("release assets: %w", err) + } + fmt.Fprintf(&sums, "%x %s\n", sha256.Sum256(data), name) + } + return []byte(sums.String()), nil +} + +func pack(dir, staging, platform string, meta metadata) error { + var buf bytes.Buffer + if strings.HasPrefix(platform, "windows/") { + if err := packZIP(&buf, staging, platform, meta); err != nil { + return fmt.Errorf("release assets: %w", err) + } + } else { + if err := packTar(&buf, staging, meta); err != nil { + return fmt.Errorf("release assets: %w", err) + } + } + return wrapClose(os.WriteFile(filepath.Join(dir, archiveName(meta.Version, platform)), buf.Bytes(), 0o600)) +} + +func packZIP(buf *bytes.Buffer, staging, platform string, meta metadata) error { + archive := zip.NewWriter(buf) + for _, bin := range meta.Inventory.Binaries { + name := executable(bin, platform) + data, err := os.ReadFile(filepath.Join(staging, name)) + if err != nil { + return fmt.Errorf("release assets: %w", err) + } + header := &zip.FileHeader{Name: name, Method: zip.Deflate} + header.SetMode(executableMode) + header.Modified, err = time.Parse(time.RFC3339, meta.BuildDate) + if err != nil { + return fmt.Errorf("release assets: %w", err) + } + entry, err := archive.CreateHeader(header) + if err != nil { + return fmt.Errorf("release assets: %w", err) + } + if _, err = entry.Write(data); err != nil { + return fmt.Errorf("release assets: %w", err) + } + } + return wrapClose(archive.Close()) +} + +func packTar(buf *bytes.Buffer, staging string, meta metadata) error { + compressed := gzip.NewWriter(buf) + archive := tar.NewWriter(compressed) + stamp, err := time.Parse(time.RFC3339, meta.BuildDate) + if err != nil { + return fmt.Errorf("release assets: %w", err) + } + for _, bin := range meta.Inventory.Binaries { + data, readErr := os.ReadFile(filepath.Join(staging, bin)) + if readErr != nil { + return fmt.Errorf("release assets: %w", readErr) + } + if err = archive.WriteHeader( + &tar.Header{Name: bin, Mode: executableMode, Size: int64(len(data)), ModTime: stamp}, + ); err != nil { + return fmt.Errorf("release assets: %w", err) + } + if _, err = archive.Write(data); err != nil { + return fmt.Errorf("release assets: %w", err) + } + } + if err = archive.Close(); err != nil { + return fmt.Errorf("release assets: %w", err) + } + return wrapClose(compressed.Close()) +} + +func wrapClose(err error) error { + if err != nil { + return fmt.Errorf("release file operation: %w", err) + } + return nil +} diff --git a/internal/releaseassets/assets_internal_test.go b/internal/releaseassets/assets_internal_test.go new file mode 100644 index 00000000..2f8abb05 --- /dev/null +++ b/internal/releaseassets/assets_internal_test.go @@ -0,0 +1,243 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Tony Burns + +package releaseassets + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +const ( + testTag = "v1.2.3" + testCommit = "0123456789012345678901234567890123456789" + testDate = "2026-10-26T12:00:00Z" +) + +func requireNoError(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } +} + +func testInventory(t *testing.T) inventory { + t.Helper() + var inv inventory + requireNoError(t, json.Unmarshal(inventoryJSON, &inv)) + return inv +} + +func buildFixture(t *testing.T) (string, metadata) { + t.Helper() + dir := t.TempDir() + requireNoError(t, build(t.Context(), fakeBuild, testTag, dir)) + return dir, metadata{testTag, testCommit, testDate, testInventory(t)} +} + +func fakeBuild(_ context.Context, _ []string, name string, args ...string) ([]byte, error) { + switch name { + case "git": + if args[0] == "show" { + return []byte(testDate), nil + } + return []byte(testCommit), nil + case "bash": + return []byte("-s -w -buildid="), nil + case "go": + out := slices.Index(args, "-o") + return nil, wrapClose(os.WriteFile(args[out+1], []byte("executable fixture"), 0o600)) + default: + return nil, errUsage + } +} + +func TestBundles(t *testing.T) { + t.Parallel() + dir, meta := buildFixture(t) + checkEvidence(t, dir, meta) + // The archive interfaces are explicit, including root names and .exe. + if !slices.Equal( + meta.Inventory.Platforms, + []string{"linux/amd64", "linux/arm64", "darwin/arm64", "windows/amd64"}, + ) || + !slices.Equal( + meta.Inventory.Binaries, + []string{ + "agentcontext", + "agenthooks", + "agentrepo", + "agentstore", + "commit-lint", + "gh-post", + "gh-reviewer", + "guard-markdown", + "ssh-sign", + }, + ) { + t.Fatal(meta.Inventory) + } + for _, platform := range meta.Inventory.Platforms { + if platform == "windows/amd64" && executable("agentrepo", platform) != "agentrepo.exe" { + t.Fatal(platform) + } + checkArchive(t, dir, platform, meta) + } + second := t.TempDir() + requireNoError(t, build(t.Context(), fakeBuild, testTag, second)) + for _, name := range append(assetNames(meta), checksumName) { + first, err := os.ReadFile(filepath.Join(dir, name)) + requireNoError(t, err) + again, err := os.ReadFile(filepath.Join(second, name)) + requireNoError(t, err) + if string(first) != string(again) { + t.Fatalf("non-reproducible %s", name) + } + } +} + +func checkEvidence(t *testing.T, dir string, expected metadata) { + t.Helper() + raw, err := os.ReadFile(filepath.Join(dir, metadataName)) + requireNoError(t, err) + var actual metadata + requireNoError(t, json.Unmarshal(raw, &actual)) + if actual.Version != expected.Version || actual.Commit != expected.Commit || + actual.BuildDate != expected.BuildDate || + !slices.Equal(actual.Inventory.Binaries, expected.Inventory.Binaries) || + !slices.Equal(actual.Inventory.Platforms, expected.Inventory.Platforms) { + t.Fatalf("metadata: %+v", actual) + } + sums, err := os.ReadFile(filepath.Join(dir, checksumName)) + requireNoError(t, err) + lines := strings.Split(strings.TrimSpace(string(sums)), "\n") + names := assetNames(expected) + if len(lines) != len(names) { + t.Fatal("checksum inventory", lines) + } + for i, name := range names { + data, readErr := os.ReadFile(filepath.Join(dir, name)) + requireNoError(t, readErr) + want := fmt.Sprintf("%x %s", sha256.Sum256(data), name) + if lines[i] != want { + t.Fatalf("checksum: %s, want %s", lines[i], want) + } + } + entries, err := os.ReadDir(dir) + requireNoError(t, err) + if len(entries) != len(names)+1 { + t.Fatal("unexpected output inventory", entries) + } +} + +func checkArchive(t *testing.T, dir, platform string, meta metadata) { + t.Helper() + raw, err := os.ReadFile(filepath.Join(dir, archiveName(meta.Version, platform))) + requireNoError(t, err) + var names []string + check := func(name string, mode os.FileMode, data []byte) { + t.Helper() + if mode.Perm() != executableMode || string(data) != "executable fixture" { + t.Fatalf("entry %s: %v %q", name, mode, data) + } + names = append(names, name) + } + if strings.HasPrefix(platform, "windows/") { + archive, openErr := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) + requireNoError(t, openErr) + for _, entry := range archive.File { + stream, entryErr := entry.Open() + requireNoError(t, entryErr) + data, readErr := io.ReadAll(stream) + requireNoError(t, readErr) + requireNoError(t, stream.Close()) + check(entry.Name, entry.Mode(), data) + } + } else { + stream, openErr := gzip.NewReader(bytes.NewReader(raw)) + requireNoError(t, openErr) + defer stream.Close() + archive := tar.NewReader(stream) + for { + header, nextErr := archive.Next() + if errors.Is(nextErr, io.EOF) { + break + } + requireNoError(t, nextErr) + data, readErr := io.ReadAll(archive) + requireNoError(t, readErr) + check(header.Name, header.FileInfo().Mode(), data) + } + } + expected := make([]string, 0, len(meta.Inventory.Binaries)) + for _, bin := range meta.Inventory.Binaries { + expected = append(expected, executable(bin, platform)) + } + if !slices.Equal(names, expected) { + t.Fatalf("archive entries: %v, want %v", names, expected) + } +} + +func TestBuildFailures(t *testing.T) { + t.Parallel() + for _, stage := range []string{"HEAD", "tag", "date", "flags", "build"} { + t.Run(stage, func(t *testing.T) { + t.Parallel() + failure := errEvidence + runner := func(ctx context.Context, env []string, name string, args ...string) ([]byte, error) { + selected := buildStage(name, args) == stage + if selected { + return []byte("partial output"), failure + } + return fakeBuild(ctx, env, name, args...) + } + if err := build(t.Context(), runner, testTag, t.TempDir()); !errors.Is(err, failure) { + t.Fatalf("got %v", err) + } + }) + } + t.Run("tag differs", func(t *testing.T) { + t.Parallel() + runner := func(ctx context.Context, env []string, name string, args ...string) ([]byte, error) { + if name == "git" && strings.Contains(args[len(args)-1], "refs/tags/") { + return []byte("different"), nil + } + return fakeBuild(ctx, env, name, args...) + } + if err := build(t.Context(), runner, testTag, t.TempDir()); !errors.Is(err, errEvidence) { + t.Fatalf("got %v", err) + } + }) +} + +func buildStage(name string, args []string) string { + switch name { + case "bash": + return "flags" + case "go": + return "build" + case "git": + if args[0] == "show" { + return "date" + } + if args[1] == "HEAD" { + return "HEAD" + } + return "tag" + default: + return "" + } +} diff --git a/internal/releaseassets/inventory.json b/internal/releaseassets/inventory.json new file mode 100644 index 00000000..864558a4 --- /dev/null +++ b/internal/releaseassets/inventory.json @@ -0,0 +1,14 @@ +{ + "platforms": ["linux/amd64", "linux/arm64", "darwin/arm64", "windows/amd64"], + "binaries": [ + "agentcontext", + "agenthooks", + "agentrepo", + "agentstore", + "commit-lint", + "gh-post", + "gh-reviewer", + "guard-markdown", + "ssh-sign" + ] +}