diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index cbaa59737..bd5fdc049 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -110,32 +110,25 @@ jobs:
path: ${{ runner.temp }}/devsy-bin/
merge-multiple: true
- - name: flatten CLI artifacts
+ - name: inventory and flatten CLI artifacts
shell: bash
run: |
- BIN_DIR="${{ runner.temp }}/devsy-bin"
- find "$BIN_DIR" -mindepth 2 -type f -exec mv {} "$BIN_DIR/" \;
- find "$BIN_DIR" -mindepth 1 -type d -delete 2>/dev/null || true
+ go run ./hack/release_artifacts inventory --dir "${{ runner.temp }}/devsy-bin" --flatten
- - name: setup CLI binary (unix)
- if: runner.os != 'Windows'
+ - name: stage embedded CLI binary
shell: bash
run: |
- TEMP_DIR="${{ runner.temp }}"
- mkdir -p desktop/resources/bin
- cp "$TEMP_DIR/devsy-bin/devsy-${{ matrix.go-os }}-${{ matrix.go-arch }}" desktop/resources/bin/devsy
- chmod +x desktop/resources/bin/devsy
+ go run ./hack/release_artifacts stage \
+ --src-dir "${{ runner.temp }}/devsy-bin" \
+ --dst-dir desktop/resources/bin \
+ --goos "${{ matrix.go-os }}" --goarch "${{ matrix.go-arch }}"
- # Also stage binaries for dist/ upload and provider generation
- mkdir -p dist/
- find "$TEMP_DIR/devsy-bin/" -type f -name "devsy-*" -exec cp {} dist/ \;
-
- - name: setup CLI binary (windows)
- if: runner.os == 'Windows'
- shell: pwsh
+ - name: populate dist for pro provider generation
+ if: matrix.name == 'linux-x64'
+ shell: bash
run: |
- New-Item -ItemType Directory -Force -Path desktop/resources/bin
- Copy-Item "${{ runner.temp }}/devsy-bin/devsy-windows-amd64.exe" desktop/resources/bin/devsy.exe
+ go run ./hack/release_artifacts populate-dist \
+ --src-dir "${{ runner.temp }}/devsy-bin" --dst-dir dist
- name: generate pro provider
if: matrix.name == 'linux-x64'
@@ -200,6 +193,26 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
+ # macOS-only because .app is a directory bundle the CLI can read
+ # directly. AppImage / deb / rpm / NSIS would need archive extractors.
+ - name: verify packaged macOS app embedded CLI arch
+ if: runner.os == 'macOS'
+ shell: bash
+ env:
+ MATRIX_GO_OS: ${{ matrix.go-os }}
+ MATRIX_GO_ARCH: ${{ matrix.go-arch }}
+ run: |
+ set -euo pipefail
+ app=$(find desktop/release -maxdepth 2 -type d -name 'Devsy.app' | head -n1)
+ if [ -z "$app" ]; then
+ echo "::error::no Devsy.app bundle found under desktop/release/"
+ ls -la desktop/release || true
+ exit 1
+ fi
+ go run ./hack/release_artifacts verify \
+ --file "$app/Contents/Resources/bin/devsy" \
+ --goos "$MATRIX_GO_OS" --goarch "$MATRIX_GO_ARCH"
+
- name: upload desktop artifacts to release
uses: softprops/action-gh-release@v3
with:
diff --git a/hack/release_artifacts/arch.go b/hack/release_artifacts/arch.go
new file mode 100644
index 000000000..5f30d3336
--- /dev/null
+++ b/hack/release_artifacts/arch.go
@@ -0,0 +1,142 @@
+package main
+
+import (
+ "debug/elf"
+ "debug/macho"
+ "encoding/binary"
+ "fmt"
+ "io"
+ "os"
+)
+
+const (
+ OSDarwin = "darwin"
+ OSLinux = "linux"
+ OSWindows = "windows"
+
+ ArchAMD64 = "amd64"
+ ArchARM64 = "arm64"
+)
+
+type Arch struct {
+ GOOS string
+ GOARCH string
+}
+
+func (a Arch) String() string { return a.GOOS + "/" + a.GOARCH }
+
+func FromFile(path string) (Arch, error) {
+ f, err := os.Open(path) // #nosec G304 -- caller-controlled release tooling.
+ if err != nil {
+ return Arch{}, err
+ }
+ defer func() { _ = f.Close() }()
+
+ var head [4]byte
+ if _, err := io.ReadFull(f, head[:]); err != nil {
+ return Arch{}, fmt.Errorf("read header: %w", err)
+ }
+ if _, err := f.Seek(0, io.SeekStart); err != nil {
+ return Arch{}, err
+ }
+
+ switch {
+ case isMachO(head):
+ return machoArch(f)
+ case isPE(head):
+ return peArch(f)
+ case isELF(head):
+ return elfArch(f)
+ }
+ return Arch{}, fmt.Errorf("unrecognized binary header: %x", head)
+}
+
+func isMachO(h [4]byte) bool {
+ for _, order := range []binary.ByteOrder{binary.LittleEndian, binary.BigEndian} {
+ switch order.Uint32(h[:]) {
+ case macho.Magic32, macho.Magic64, macho.MagicFat:
+ return true
+ }
+ }
+ return false
+}
+
+func isPE(h [4]byte) bool { return h[0] == 'M' && h[1] == 'Z' }
+
+func isELF(h [4]byte) bool {
+ return h[0] == 0x7f && h[1] == 'E' && h[2] == 'L' && h[3] == 'F'
+}
+
+func machoArch(f *os.File) (Arch, error) {
+ mf, err := macho.NewFile(f)
+ if err != nil {
+ return Arch{}, fmt.Errorf("parse mach-o: %w", err)
+ }
+ defer func() { _ = mf.Close() }()
+ switch mf.Cpu {
+ case macho.CpuAmd64:
+ return Arch{GOOS: OSDarwin, GOARCH: ArchAMD64}, nil
+ case macho.CpuArm64:
+ return Arch{GOOS: OSDarwin, GOARCH: ArchARM64}, nil
+ }
+ return Arch{}, fmt.Errorf("unsupported mach-o cpu: %v", mf.Cpu)
+}
+
+func elfArch(f *os.File) (Arch, error) {
+ ef, err := elf.NewFile(f)
+ if err != nil {
+ return Arch{}, fmt.Errorf("parse elf: %w", err)
+ }
+ defer func() { _ = ef.Close() }()
+ switch ef.Machine {
+ case elf.EM_X86_64:
+ return Arch{GOOS: OSLinux, GOARCH: ArchAMD64}, nil
+ case elf.EM_AARCH64:
+ return Arch{GOOS: OSLinux, GOARCH: ArchARM64}, nil
+ }
+ return Arch{}, fmt.Errorf("unsupported elf machine: %v", ef.Machine)
+}
+
+func peArch(f *os.File) (Arch, error) {
+ machine, err := readPEMachine(f)
+ if err != nil {
+ return Arch{}, err
+ }
+ switch machine {
+ case peMachineAMD64:
+ return Arch{GOOS: OSWindows, GOARCH: ArchAMD64}, nil
+ case peMachineARM64:
+ return Arch{GOOS: OSWindows, GOARCH: ArchARM64}, nil
+ }
+ return Arch{}, fmt.Errorf("unsupported pe machine: 0x%x", machine)
+}
+
+const (
+ peMachineAMD64 uint16 = 0x8664
+ peMachineARM64 uint16 = 0xaa64
+)
+
+func readPEMachine(f *os.File) (uint16, error) {
+ if _, err := f.Seek(0x3c, io.SeekStart); err != nil {
+ return 0, err
+ }
+ var peOffset uint32
+ if err := binary.Read(f, binary.LittleEndian, &peOffset); err != nil {
+ return 0, fmt.Errorf("read pe offset: %w", err)
+ }
+ if _, err := f.Seek(int64(peOffset), io.SeekStart); err != nil {
+ return 0, err
+ }
+ var sig [4]byte
+ if _, err := io.ReadFull(f, sig[:]); err != nil {
+ return 0, fmt.Errorf("read pe signature: %w", err)
+ }
+ if sig != [4]byte{'P', 'E', 0, 0} {
+ return 0, fmt.Errorf("not a pe file: signature %x", sig)
+ }
+ var machine uint16
+ if err := binary.Read(f, binary.LittleEndian, &machine); err != nil {
+ return 0, fmt.Errorf("read pe machine: %w", err)
+ }
+ return machine, nil
+}
diff --git a/hack/release_artifacts/arch_test.go b/hack/release_artifacts/arch_test.go
new file mode 100644
index 000000000..f46eeab18
--- /dev/null
+++ b/hack/release_artifacts/arch_test.go
@@ -0,0 +1,60 @@
+package main
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+)
+
+func buildFixture(t *testing.T, goos, goarch string) string {
+ t.Helper()
+
+ src := filepath.Join(t.TempDir(), "prog.go")
+ if err := os.WriteFile(src, []byte("package main\nfunc main() {}\n"), 0o600); err != nil {
+ t.Fatalf("write fixture src: %v", err)
+ }
+
+ out := filepath.Join(t.TempDir(), "prog")
+ cmd := exec.Command("go", "build", "-o", out, src)
+ cmd.Env = append(os.Environ(),
+ "GOOS="+goos,
+ "GOARCH="+goarch,
+ "CGO_ENABLED=0",
+ )
+ if combined, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("cross-build %s/%s: %v\n%s", goos, goarch, err, combined)
+ }
+ return out
+}
+
+func TestFromFile(t *testing.T) {
+ cases := []Arch{
+ {GOOS: OSDarwin, GOARCH: ArchAMD64},
+ {GOOS: OSDarwin, GOARCH: ArchARM64},
+ {GOOS: OSLinux, GOARCH: ArchAMD64},
+ {GOOS: OSLinux, GOARCH: ArchARM64},
+ {GOOS: OSWindows, GOARCH: ArchAMD64},
+ }
+ for _, want := range cases {
+ t.Run(want.String(), func(t *testing.T) {
+ got, err := FromFile(buildFixture(t, want.GOOS, want.GOARCH))
+ if err != nil {
+ t.Fatalf("FromFile: %v", err)
+ }
+ if got != want {
+ t.Errorf("got %s, want %s", got, want)
+ }
+ })
+ }
+}
+
+func TestFromFileRejectsUnknownHeader(t *testing.T) {
+ p := filepath.Join(t.TempDir(), "garbage")
+ if err := os.WriteFile(p, []byte("not a binary"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := FromFile(p); err == nil {
+ t.Fatal("expected error for non-binary file")
+ }
+}
diff --git a/hack/release_artifacts/inventory.go b/hack/release_artifacts/inventory.go
new file mode 100644
index 000000000..0fe26611d
--- /dev/null
+++ b/hack/release_artifacts/inventory.go
@@ -0,0 +1,168 @@
+package main
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "sort"
+
+ "github.com/spf13/cobra"
+)
+
+func inventoryCmd() *cobra.Command {
+ var dir string
+ var flatten bool
+ cmd := &cobra.Command{
+ Use: "inventory",
+ Short: "log size/sha256/arch for each file under --dir; optionally flatten",
+ RunE: func(_ *cobra.Command, _ []string) error {
+ return runInventory(dir, flatten)
+ },
+ }
+ cmd.Flags().StringVar(&dir, "dir", "", "directory to inventory")
+ cmd.Flags().BoolVar(&flatten, "flatten", false,
+ "after inventory, move nested files into
root and remove empty subdirs")
+ _ = cmd.MarkFlagRequired("dir")
+ return cmd
+}
+
+func runInventory(dir string, flatten bool) error {
+ files, err := walkFiles(dir)
+ if err != nil {
+ return fmt.Errorf("walk %s: %w", dir, err)
+ }
+ if err := printGroup("CLI artifact inventory", dir, files); err != nil {
+ return err
+ }
+ if !flatten {
+ return nil
+ }
+ if err := flattenInto(dir, files); err != nil {
+ return fmt.Errorf("flatten: %w", err)
+ }
+ if err := removeEmptyDirs(dir); err != nil {
+ return fmt.Errorf("cleanup empty dirs: %w", err)
+ }
+ flat, err := walkFiles(dir)
+ if err != nil {
+ return fmt.Errorf("post-flatten walk: %w", err)
+ }
+ return printGroup("Flattened CLI binaries", dir, flat)
+}
+
+func printGroup(label, root string, files []string) error {
+ fmt.Printf("::group::%s\n", label)
+ defer fmt.Println("::endgroup::")
+ for _, p := range files {
+ if err := printEntry(root, p); err != nil {
+ return fmt.Errorf("%s: %w", p, err)
+ }
+ }
+ return nil
+}
+
+func walkFiles(root string) ([]string, error) {
+ var out []string
+ walk := func(p string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.Type().IsRegular() {
+ out = append(out, p)
+ }
+ return nil
+ }
+ if err := filepath.WalkDir(root, walk); err != nil {
+ return nil, err
+ }
+ sort.Strings(out)
+ return out, nil
+}
+
+func printEntry(root, p string) error {
+ rel, err := filepath.Rel(root, p)
+ if err != nil {
+ return err
+ }
+ st, err := os.Stat(p)
+ if err != nil {
+ return err
+ }
+ sum, err := sha256File(p)
+ if err != nil {
+ return err
+ }
+ archStr := ""
+ if a, err := FromFile(p); err == nil {
+ archStr = a.String()
+ }
+ fmt.Printf("%s size=%d sha256=%s arch=%s\n", rel, st.Size(), sum, archStr)
+ return nil
+}
+
+func sha256File(p string) (string, error) {
+ f, err := os.Open(p) // #nosec G304 -- caller-controlled release tooling.
+ if err != nil {
+ return "", err
+ }
+ defer func() { _ = f.Close() }()
+ h := sha256.New()
+ if _, err := io.Copy(h, f); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(h.Sum(nil)), nil
+}
+
+// flattenInto fails on name collisions instead of silently overwriting.
+func flattenInto(root string, files []string) error {
+ rootAbs, err := filepath.Abs(root)
+ if err != nil {
+ return err
+ }
+ for _, p := range files {
+ parent, err := filepath.Abs(filepath.Dir(p))
+ if err != nil {
+ return err
+ }
+ if parent == rootAbs {
+ continue
+ }
+ dest := filepath.Join(rootAbs, filepath.Base(p))
+ if _, err := os.Stat(dest); err == nil {
+ return fmt.Errorf("flatten collision: %s -> %s already exists", p, dest)
+ } else if !errors.Is(err, fs.ErrNotExist) {
+ return err
+ }
+ if err := os.Rename(p, dest); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func removeEmptyDirs(root string) error {
+ var dirs []string
+ walk := func(p string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() && p != root {
+ dirs = append(dirs, p)
+ }
+ return nil
+ }
+ if err := filepath.WalkDir(root, walk); err != nil {
+ return err
+ }
+ // Deepest first so parents become removable after their children go.
+ sort.Sort(sort.Reverse(sort.StringSlice(dirs)))
+ for _, d := range dirs {
+ _ = os.Remove(d)
+ }
+ return nil
+}
diff --git a/hack/release_artifacts/main.go b/hack/release_artifacts/main.go
new file mode 100644
index 000000000..92c702e4c
--- /dev/null
+++ b/hack/release_artifacts/main.go
@@ -0,0 +1,29 @@
+// release_artifacts: subcommands for inspecting CLI binaries staged by the
+// release pipeline.
+package main
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/spf13/cobra"
+)
+
+func main() {
+ root := &cobra.Command{
+ Use: "release_artifacts",
+ Short: "release-pipeline tools for staging and verifying CLI binaries",
+ SilenceUsage: true,
+ SilenceErrors: true,
+ }
+ root.AddCommand(
+ inventoryCmd(),
+ populateDistCmd(),
+ stageCmd(),
+ verifyCmd(),
+ )
+ if err := root.Execute(); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
diff --git a/hack/release_artifacts/populate_dist.go b/hack/release_artifacts/populate_dist.go
new file mode 100644
index 000000000..bdcddc3fe
--- /dev/null
+++ b/hack/release_artifacts/populate_dist.go
@@ -0,0 +1,49 @@
+package main
+
+import (
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/spf13/cobra"
+)
+
+func populateDistCmd() *cobra.Command {
+ var srcDir, dstDir string
+ cmd := &cobra.Command{
+ Use: "populate-dist",
+ Short: "copy every devsy-* binary from --src-dir into --dst-dir",
+ RunE: func(_ *cobra.Command, _ []string) error {
+ return runPopulateDist(srcDir, dstDir)
+ },
+ }
+ cmd.Flags().StringVar(&srcDir, "src-dir", "", "directory containing the per-arch CLI binaries")
+ cmd.Flags().StringVar(&dstDir, "dst-dir", "", "directory to populate")
+ for _, f := range []string{"src-dir", "dst-dir"} {
+ _ = cmd.MarkFlagRequired(f)
+ }
+ return cmd
+}
+
+func runPopulateDist(srcDir, dstDir string) error {
+ if err := os.MkdirAll(dstDir, 0o755); err != nil { // #nosec G301 -- release tooling.
+ return fmt.Errorf("mkdir %s: %w", dstDir, err)
+ }
+ seen := map[string]string{}
+ walk := func(p string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if !d.Type().IsRegular() || !strings.HasPrefix(d.Name(), "devsy-") {
+ return nil
+ }
+ if prev, ok := seen[d.Name()]; ok {
+ return fmt.Errorf("duplicate binary basename %q: %s and %s", d.Name(), prev, p)
+ }
+ seen[d.Name()] = p
+ return copyExecutable(p, filepath.Join(dstDir, d.Name()))
+ }
+ return filepath.WalkDir(srcDir, walk)
+}
diff --git a/hack/release_artifacts/stage.go b/hack/release_artifacts/stage.go
new file mode 100644
index 000000000..6079d027f
--- /dev/null
+++ b/hack/release_artifacts/stage.go
@@ -0,0 +1,70 @@
+package main
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+
+ "github.com/spf13/cobra"
+)
+
+func stageCmd() *cobra.Command {
+ var srcDir, dstDir, goos, goarch string
+ cmd := &cobra.Command{
+ Use: "stage",
+ Short: "copy the matching per-arch CLI binary into --dst-dir and verify its arch",
+ RunE: func(_ *cobra.Command, _ []string) error {
+ return runStage(srcDir, dstDir, goos, goarch)
+ },
+ }
+ cmd.Flags().StringVar(&srcDir, "src-dir", "", "directory containing the per-arch CLI binaries")
+ cmd.Flags().StringVar(&dstDir, "dst-dir", "", "directory the embedded binary is copied into")
+ cmd.Flags().StringVar(&goos, "goos", "", "GOOS for the binary to stage")
+ cmd.Flags().StringVar(&goarch, "goarch", "", "GOARCH for the binary to stage")
+ for _, f := range []string{"src-dir", "dst-dir", "goos", "goarch"} {
+ _ = cmd.MarkFlagRequired(f)
+ }
+ return cmd
+}
+
+func runStage(srcDir, dstDir, goos, goarch string) error {
+ srcName := fmt.Sprintf("devsy-%s-%s", goos, goarch)
+ dstName := "devsy"
+ if goos == OSWindows {
+ srcName += ".exe"
+ dstName = "devsy.exe"
+ }
+ src := filepath.Join(srcDir, srcName)
+ dst := filepath.Join(dstDir, dstName)
+
+ if _, err := os.Stat(src); err != nil {
+ return fmt.Errorf("expected CLI binary missing: %s: %w", src, err)
+ }
+ if err := os.MkdirAll(dstDir, 0o755); err != nil { // #nosec G301 -- release tooling.
+ return fmt.Errorf("mkdir %s: %w", dstDir, err)
+ }
+ if err := copyExecutable(src, dst); err != nil {
+ return fmt.Errorf("copy %s -> %s: %w", src, dst, err)
+ }
+ fmt.Printf("staged %s -> %s\n", src, dst)
+ return verify(dst, Arch{GOOS: goos, GOARCH: goarch})
+}
+
+func copyExecutable(src, dst string) error {
+ in, err := os.Open(src) // #nosec G304 -- caller-controlled release tooling.
+ if err != nil {
+ return err
+ }
+ defer func() { _ = in.Close() }()
+ const flags = os.O_WRONLY | os.O_CREATE | os.O_TRUNC
+ out, err := os.OpenFile(dst, flags, 0o755) // #nosec G302,G304 -- release tooling.
+ if err != nil {
+ return err
+ }
+ defer func() { _ = out.Close() }()
+ if _, err := io.Copy(out, in); err != nil {
+ return err
+ }
+ return out.Close()
+}
diff --git a/hack/release_artifacts/verify.go b/hack/release_artifacts/verify.go
new file mode 100644
index 000000000..f58f3a82b
--- /dev/null
+++ b/hack/release_artifacts/verify.go
@@ -0,0 +1,37 @@
+package main
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+)
+
+func verifyCmd() *cobra.Command {
+ var file, goos, goarch string
+ cmd := &cobra.Command{
+ Use: "verify",
+ Short: "assert that the binary at --file targets --goos / --goarch",
+ RunE: func(_ *cobra.Command, _ []string) error {
+ return verify(file, Arch{GOOS: goos, GOARCH: goarch})
+ },
+ }
+ cmd.Flags().StringVar(&file, "file", "", "binary to verify")
+ cmd.Flags().StringVar(&goos, "goos", "", "expected GOOS")
+ cmd.Flags().StringVar(&goarch, "goarch", "", "expected GOARCH")
+ for _, f := range []string{"file", "goos", "goarch"} {
+ _ = cmd.MarkFlagRequired(f)
+ }
+ return cmd
+}
+
+func verify(file string, want Arch) error {
+ got, err := FromFile(file)
+ if err != nil {
+ return fmt.Errorf("::error::%s: %w", file, err)
+ }
+ fmt.Printf("file=%s detected=%s expected=%s\n", file, got, want)
+ if got != want {
+ return fmt.Errorf("::error::arch mismatch: %s is %s, expected %s", file, got, want)
+ }
+ return nil
+}