Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .cspell-words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ readarray
recompiles
recvcheck
regen
releaseassets
relint
relitigates
relpath
Expand Down
1 change: 1 addition & 0 deletions .devtools/shell-allowlist.toml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ commands = [
"gh-post",
"gh-reviewer",
"guard-markdown",
"release-assets",
"shell-allowlist",
"ssh-sign",
"test-hooks",
Expand Down
20 changes: 20 additions & 0 deletions cmd/release-assets/main.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
283 changes: 283 additions & 0 deletions internal/releaseassets/assets.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading