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 +}