From 593fa2a01441ab8a71b789206731f4cc8d6b7bd3 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 21 Jun 2026 08:27:24 -0500 Subject: [PATCH 01/10] ci(release): add observability and arch assertions to desktop pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.10.0-beta.29 shipped an x86_64 devsy inside Devsy_mac_arm64.dmg even though the cp step logged the correct filename. The pipeline had no way to detect the mismatch — cp ran cleanly and electron-builder happily packaged whatever bytes were at desktop/resources/bin/devsy. Add three assertion points without changing the artifact handoff: 1. After download, a single Python step inventories every binary (path, size, sha256, file -b arch) and then performs the flatten. Python sidesteps the shellcheck issues with the shell-loop version and gives a structured per-binary log line. Fails on flatten collisions instead of silently overwriting. 2. After cp into desktop/resources/bin/devsy, compare src and dst sha256 (must match) and assert the arch matches the matrix entry's GOOS/GOARCH. Same assertion on the Windows path via Get-FileHash. 3. After electron-builder packages the .app, read the embedded CLI binary out of the produced bundle and re-verify its arch — the check that would have caught the original bug at the right point. --- .github/workflows/release.yml | 149 ++++++++++++++++++++++++++++++++-- 1 file changed, 140 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cbaa59737..a6db733c4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -110,23 +110,106 @@ jobs: path: ${{ runner.temp }}/devsy-bin/ merge-multiple: true - - name: flatten CLI artifacts + - name: inventory and flatten CLI artifacts shell: bash + env: + BIN_DIR: ${{ runner.temp }}/devsy-bin 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 + python3 <<'PY' + import hashlib + import os + import shutil + import subprocess + from pathlib import Path + + bin_dir = Path(os.environ["BIN_DIR"]) + + def file_arch(p: Path) -> str: + try: + return subprocess.check_output(["file", "-b", str(p)], text=True).strip() + except (FileNotFoundError, subprocess.CalledProcessError): + return "" + + def sha256(p: Path) -> str: + h = hashlib.sha256() + with p.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + print("::group::Downloaded CLI artifacts (pre-flatten)") + for p in sorted(bin_dir.rglob("*")): + if p.is_file(): + print(f"{p.relative_to(bin_dir)} size={p.stat().st_size} " + f"sha256={sha256(p)} arch={file_arch(p)}") + print("::endgroup::") + + for nested in sorted(p for p in bin_dir.rglob("*") if p.is_file() and p.parent != bin_dir): + dest = bin_dir / nested.name + if dest.exists(): + raise SystemExit(f"flatten collision: {nested} -> {dest} (already exists)") + shutil.move(str(nested), str(dest)) + + for d in sorted((p for p in bin_dir.rglob("*") if p.is_dir()), reverse=True): + try: + d.rmdir() + except OSError: + pass + + print("::group::Flattened CLI binaries") + for p in sorted(bin_dir.iterdir()): + if p.is_file(): + print(f"{p.name} size={p.stat().st_size} arch={file_arch(p)}") + print("::endgroup::") + PY - name: setup CLI binary (unix) if: runner.os != 'Windows' shell: bash + env: + MATRIX_NAME: ${{ matrix.name }} + MATRIX_GO_OS: ${{ matrix.go-os }} + MATRIX_GO_ARCH: ${{ matrix.go-arch }} run: | + set -euo pipefail TEMP_DIR="${{ runner.temp }}" + SRC="$TEMP_DIR/devsy-bin/devsy-${MATRIX_GO_OS}-${MATRIX_GO_ARCH}" + DST="desktop/resources/bin/devsy" + + if [ ! -f "$SRC" ]; then + echo "::error::expected CLI binary missing: $SRC" + ls -la "$TEMP_DIR/devsy-bin" + exit 1 + fi + 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 + cp "$SRC" "$DST" + chmod +x "$DST" + + src_sha=$(shasum -a 256 "$SRC" | awk '{print $1}') + dst_sha=$(shasum -a 256 "$DST" | awk '{print $1}') + arch_desc=$(file -b "$DST") + echo "matrix=${MATRIX_NAME} expected=devsy-${MATRIX_GO_OS}-${MATRIX_GO_ARCH}" + echo "src=$SRC sha256=$src_sha" + echo "dst=$DST sha256=$dst_sha" + echo "dst arch=$arch_desc" + + if [ "$src_sha" != "$dst_sha" ]; then + echo "::error::sha256 mismatch between source and destination" + exit 1 + fi - # Also stage binaries for dist/ upload and provider generation + case "${MATRIX_GO_OS}-${MATRIX_GO_ARCH}" in + darwin-arm64) echo "$arch_desc" | grep -q 'arm64' ;; + darwin-amd64) echo "$arch_desc" | grep -q 'x86_64' ;; + linux-amd64) echo "$arch_desc" | grep -q 'x86-64' ;; + linux-arm64) echo "$arch_desc" | grep -q 'aarch64' ;; + *) + echo "::warning::no arch assertion for ${MATRIX_GO_OS}-${MATRIX_GO_ARCH}" + ;; + esac + + # Stage binaries for dist/ upload and provider generation. mkdir -p dist/ find "$TEMP_DIR/devsy-bin/" -type f -name "devsy-*" -exec cp {} dist/ \; @@ -134,8 +217,23 @@ jobs: if: runner.os == 'Windows' shell: pwsh 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 + $src = "${{ runner.temp }}/devsy-bin/devsy-windows-amd64.exe" + $dst = "desktop/resources/bin/devsy.exe" + if (-not (Test-Path $src)) { + Write-Error "expected CLI binary missing: $src" + Get-ChildItem "${{ runner.temp }}/devsy-bin" + exit 1 + } + New-Item -ItemType Directory -Force -Path desktop/resources/bin | Out-Null + Copy-Item $src $dst + $srcSha = (Get-FileHash -Algorithm SHA256 $src).Hash + $dstSha = (Get-FileHash -Algorithm SHA256 $dst).Hash + Write-Host "src=$src sha256=$srcSha" + Write-Host "dst=$dst sha256=$dstSha" + if ($srcSha -ne $dstSha) { + Write-Error "sha256 mismatch between source and destination" + exit 1 + } - name: generate pro provider if: matrix.name == 'linux-x64' @@ -200,6 +298,39 @@ jobs: env: GH_TOKEN: ${{ github.token }} + - name: verify packaged macOS app embedded CLI arch + if: runner.os == 'macOS' + shell: bash + env: + MATRIX_GO_ARCH: ${{ matrix.go-arch }} + working-directory: desktop + run: | + set -euo pipefail + app_dir=$(find release -maxdepth 2 -type d -name 'Devsy.app' | head -n1) + if [ -z "$app_dir" ]; then + echo "::error::no Devsy.app bundle found under desktop/release/" + ls -la release || true + exit 1 + fi + embedded="$app_dir/Contents/Resources/bin/devsy" + if [ ! -f "$embedded" ]; then + echo "::error::embedded CLI binary missing: $embedded" + exit 1 + fi + arch_desc=$(file -b "$embedded") + sha=$(shasum -a 256 "$embedded" | awk '{print $1}') + echo "embedded=$embedded" + echo "sha256=$sha" + echo "arch=$arch_desc" + case "$MATRIX_GO_ARCH" in + arm64) echo "$arch_desc" | grep -q 'arm64' ;; + amd64) echo "$arch_desc" | grep -q 'x86_64' ;; + *) + echo "::error::unexpected matrix go-arch: $MATRIX_GO_ARCH" + exit 1 + ;; + esac + - name: upload desktop artifacts to release uses: softprops/action-gh-release@v3 with: From 382b699599938a7a9ea8f3caa50724c22c5e9cf4 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 21 Jun 2026 08:32:33 -0500 Subject: [PATCH 02/10] ci(release): port artifact inventory and arch verification to Go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop build job already runs actions/setup-go@v6, so the observability tools follow the existing hack/ convention instead of inlining shell or Python in the workflow. hack/binary_arch — pure-Go Mach-O / ELF / PE arch detector with tests covering all five matrix combos. hack/inventory_binaries — walks a dir, prints structured per-file entries (size, sha256, arch), optionally flattens with collision detection. hack/verify_binary_arch — asserts a binary matches expected GOOS/GOARCH. Workflow changes: - Replace the inline inventory step with go run ./hack/inventory_binaries. - Replace the bash arch-case assertion with go run ./hack/verify_binary_arch against desktop/resources/bin/devsy after cp. - Replace the post-package macOS bash verifier with the same Go tool against the embedded binary inside the produced .app bundle. Drops the host `file(1)` dependency, the shellcheck warnings, and gains unit-test coverage for the arch detection logic itself. --- .github/workflows/release.yml | 125 +++---------------- hack/binary_arch/binary_arch.go | 154 ++++++++++++++++++++++++ hack/binary_arch/binary_arch_test.go | 62 ++++++++++ hack/inventory_binaries/main.go | 174 +++++++++++++++++++++++++++ hack/verify_binary_arch/main.go | 38 ++++++ 5 files changed, 445 insertions(+), 108 deletions(-) create mode 100644 hack/binary_arch/binary_arch.go create mode 100644 hack/binary_arch/binary_arch_test.go create mode 100644 hack/inventory_binaries/main.go create mode 100644 hack/verify_binary_arch/main.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a6db733c4..46eead75c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -112,106 +112,30 @@ jobs: - name: inventory and flatten CLI artifacts shell: bash - env: - BIN_DIR: ${{ runner.temp }}/devsy-bin run: | - python3 <<'PY' - import hashlib - import os - import shutil - import subprocess - from pathlib import Path - - bin_dir = Path(os.environ["BIN_DIR"]) - - def file_arch(p: Path) -> str: - try: - return subprocess.check_output(["file", "-b", str(p)], text=True).strip() - except (FileNotFoundError, subprocess.CalledProcessError): - return "" - - def sha256(p: Path) -> str: - h = hashlib.sha256() - with p.open("rb") as f: - for chunk in iter(lambda: f.read(1024 * 1024), b""): - h.update(chunk) - return h.hexdigest() - - print("::group::Downloaded CLI artifacts (pre-flatten)") - for p in sorted(bin_dir.rglob("*")): - if p.is_file(): - print(f"{p.relative_to(bin_dir)} size={p.stat().st_size} " - f"sha256={sha256(p)} arch={file_arch(p)}") - print("::endgroup::") - - for nested in sorted(p for p in bin_dir.rglob("*") if p.is_file() and p.parent != bin_dir): - dest = bin_dir / nested.name - if dest.exists(): - raise SystemExit(f"flatten collision: {nested} -> {dest} (already exists)") - shutil.move(str(nested), str(dest)) - - for d in sorted((p for p in bin_dir.rglob("*") if p.is_dir()), reverse=True): - try: - d.rmdir() - except OSError: - pass - - print("::group::Flattened CLI binaries") - for p in sorted(bin_dir.iterdir()): - if p.is_file(): - print(f"{p.name} size={p.stat().st_size} arch={file_arch(p)}") - print("::endgroup::") - PY + go run ./hack/inventory_binaries -dir "${{ runner.temp }}/devsy-bin" -flatten - name: setup CLI binary (unix) if: runner.os != 'Windows' shell: bash env: - MATRIX_NAME: ${{ matrix.name }} MATRIX_GO_OS: ${{ matrix.go-os }} MATRIX_GO_ARCH: ${{ matrix.go-arch }} run: | set -euo pipefail - TEMP_DIR="${{ runner.temp }}" - SRC="$TEMP_DIR/devsy-bin/devsy-${MATRIX_GO_OS}-${MATRIX_GO_ARCH}" - DST="desktop/resources/bin/devsy" - - if [ ! -f "$SRC" ]; then - echo "::error::expected CLI binary missing: $SRC" - ls -la "$TEMP_DIR/devsy-bin" + src="${{ runner.temp }}/devsy-bin/devsy-${MATRIX_GO_OS}-${MATRIX_GO_ARCH}" + dst="desktop/resources/bin/devsy" + if [ ! -f "$src" ]; then + echo "::error::expected CLI binary missing: $src" + ls -la "${{ runner.temp }}/devsy-bin" exit 1 fi - mkdir -p desktop/resources/bin - cp "$SRC" "$DST" - chmod +x "$DST" - - src_sha=$(shasum -a 256 "$SRC" | awk '{print $1}') - dst_sha=$(shasum -a 256 "$DST" | awk '{print $1}') - arch_desc=$(file -b "$DST") - echo "matrix=${MATRIX_NAME} expected=devsy-${MATRIX_GO_OS}-${MATRIX_GO_ARCH}" - echo "src=$SRC sha256=$src_sha" - echo "dst=$DST sha256=$dst_sha" - echo "dst arch=$arch_desc" - - if [ "$src_sha" != "$dst_sha" ]; then - echo "::error::sha256 mismatch between source and destination" - exit 1 - fi - - case "${MATRIX_GO_OS}-${MATRIX_GO_ARCH}" in - darwin-arm64) echo "$arch_desc" | grep -q 'arm64' ;; - darwin-amd64) echo "$arch_desc" | grep -q 'x86_64' ;; - linux-amd64) echo "$arch_desc" | grep -q 'x86-64' ;; - linux-arm64) echo "$arch_desc" | grep -q 'aarch64' ;; - *) - echo "::warning::no arch assertion for ${MATRIX_GO_OS}-${MATRIX_GO_ARCH}" - ;; - esac - - # Stage binaries for dist/ upload and provider generation. + cp "$src" "$dst" + chmod +x "$dst" + go run ./hack/verify_binary_arch -file "$dst" -goos "$MATRIX_GO_OS" -goarch "$MATRIX_GO_ARCH" mkdir -p dist/ - find "$TEMP_DIR/devsy-bin/" -type f -name "devsy-*" -exec cp {} dist/ \; + find "${{ runner.temp }}/devsy-bin/" -type f -name "devsy-*" -exec cp {} dist/ \; - name: setup CLI binary (windows) if: runner.os == 'Windows' @@ -302,34 +226,19 @@ jobs: if: runner.os == 'macOS' shell: bash env: + MATRIX_GO_OS: ${{ matrix.go-os }} MATRIX_GO_ARCH: ${{ matrix.go-arch }} - working-directory: desktop run: | set -euo pipefail - app_dir=$(find release -maxdepth 2 -type d -name 'Devsy.app' | head -n1) - if [ -z "$app_dir" ]; then + 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 release || true - exit 1 - fi - embedded="$app_dir/Contents/Resources/bin/devsy" - if [ ! -f "$embedded" ]; then - echo "::error::embedded CLI binary missing: $embedded" + ls -la desktop/release || true exit 1 fi - arch_desc=$(file -b "$embedded") - sha=$(shasum -a 256 "$embedded" | awk '{print $1}') - echo "embedded=$embedded" - echo "sha256=$sha" - echo "arch=$arch_desc" - case "$MATRIX_GO_ARCH" in - arm64) echo "$arch_desc" | grep -q 'arm64' ;; - amd64) echo "$arch_desc" | grep -q 'x86_64' ;; - *) - echo "::error::unexpected matrix go-arch: $MATRIX_GO_ARCH" - exit 1 - ;; - esac + go run ./hack/verify_binary_arch \ + -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 diff --git a/hack/binary_arch/binary_arch.go b/hack/binary_arch/binary_arch.go new file mode 100644 index 000000000..4a8dd5c9c --- /dev/null +++ b/hack/binary_arch/binary_arch.go @@ -0,0 +1,154 @@ +// Package binaryarch detects the target architecture of a built executable +// by reading the leading bytes of its Mach-O or ELF header. Used by release +// tooling to assert that a packaged binary matches the matrix entry it was +// built for. +package binaryarch + +import ( + "debug/elf" + "debug/macho" + "encoding/binary" + "fmt" + "io" + "os" +) + +const ( + OSDarwin = "darwin" + OSLinux = "linux" + OSWindows = "windows" + + ArchAMD64 = "amd64" + ArchARM64 = "arm64" +) + +// Arch is the canonical "/" identifier for a built binary. +type Arch struct { + GOOS string + GOARCH string +} + +func (a Arch) String() string { return a.GOOS + "/" + a.GOARCH } + +// FromFile reads the binary header at path and returns its target Arch. +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 { + v := binary.LittleEndian.Uint32(h[:]) + switch v { + case macho.Magic32, macho.Magic64, macho.MagicFat: + return true + } + // Big-endian variants (rare for Apple Silicon era, kept for completeness). + switch binary.BigEndian.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) +} + +// peArch returns the architecture of a PE/COFF executable. Limited to the +// architectures the release pipeline actually ships. +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/binary_arch/binary_arch_test.go b/hack/binary_arch/binary_arch_test.go new file mode 100644 index 000000000..c7f7de346 --- /dev/null +++ b/hack/binary_arch/binary_arch_test.go @@ -0,0 +1,62 @@ +package binaryarch + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +// buildFixture cross-compiles a tiny program for goos/goarch and returns the +// output path. The fixture lives in t.TempDir so it's cleaned up automatically. +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/inventory_binaries/main.go b/hack/inventory_binaries/main.go new file mode 100644 index 000000000..f2ca28823 --- /dev/null +++ b/hack/inventory_binaries/main.go @@ -0,0 +1,174 @@ +// inventory_binaries walks a directory tree, prints a per-file inventory line +// (relative path, size, sha256, arch), and optionally flattens nested files +// into the root with collision detection. +// +// Usage: inventory_binaries [-flatten] -dir +package main + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "flag" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + + binaryarch "github.com/devsy-org/devsy/hack/binary_arch" +) + +func main() { + dir := flag.String("dir", "", "directory to inventory") + flatten := flag.Bool("flatten", false, + "after inventory, move nested files into root and remove empty subdirs") + flag.Parse() + if *dir == "" { + fmt.Fprintln(os.Stderr, "missing required -dir flag") + os.Exit(2) + } + if err := run(*dir, *flatten); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } +} + +func run(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 + err := filepath.WalkDir(root, 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 != 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 := binaryarch.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 moves nested files (those whose parent isn't root) into root. +// 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 + } + // Remove deepest first so parents become empty after their children go. + // os.Remove on a non-empty dir returns *os.PathError, which is fine to skip. + sort.Sort(sort.Reverse(sort.StringSlice(dirs))) + for _, d := range dirs { + _ = os.Remove(d) + } + return nil +} diff --git a/hack/verify_binary_arch/main.go b/hack/verify_binary_arch/main.go new file mode 100644 index 000000000..062cd5431 --- /dev/null +++ b/hack/verify_binary_arch/main.go @@ -0,0 +1,38 @@ +// verify_binary_arch asserts that the binary at -file targets the architecture +// at -goos / -goarch. Fails the process with exit 1 on mismatch so the calling +// CI step turns red. +// +// Usage: verify_binary_arch -file -goos -goarch +package main + +import ( + "flag" + "fmt" + "os" + + binaryarch "github.com/devsy-org/devsy/hack/binary_arch" +) + +func main() { + file := flag.String("file", "", "binary to verify") + goos := flag.String("goos", "", "expected GOOS") + goarch := flag.String("goarch", "", "expected GOARCH") + flag.Parse() + if *file == "" || *goos == "" || *goarch == "" { + fmt.Fprintln(os.Stderr, "missing required flag (-file, -goos, -goarch)") + os.Exit(2) + } + + got, err := binaryarch.FromFile(*file) + if err != nil { + fmt.Fprintf(os.Stderr, "::error::%s: %v\n", *file, err) + os.Exit(1) + } + want := binaryarch.Arch{GOOS: *goos, GOARCH: *goarch} + fmt.Printf("file=%s detected=%s expected=%s\n", *file, got, want) + if got != want { + fmt.Fprintf(os.Stderr, "::error::arch mismatch: %s is %s, expected %s\n", + *file, got, want) + os.Exit(1) + } +} From 0271eb13482eec06e68ca97ce84623e9bab8b6a1 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 21 Jun 2026 08:35:18 -0500 Subject: [PATCH 03/10] style(hack): strip non-load-bearing comments from new tools --- hack/binary_arch/binary_arch.go | 23 ++++++----------------- hack/binary_arch/binary_arch_test.go | 2 -- hack/inventory_binaries/main.go | 13 ++++--------- hack/verify_binary_arch/main.go | 6 +----- 4 files changed, 11 insertions(+), 33 deletions(-) diff --git a/hack/binary_arch/binary_arch.go b/hack/binary_arch/binary_arch.go index 4a8dd5c9c..bcd042d3c 100644 --- a/hack/binary_arch/binary_arch.go +++ b/hack/binary_arch/binary_arch.go @@ -1,7 +1,4 @@ -// Package binaryarch detects the target architecture of a built executable -// by reading the leading bytes of its Mach-O or ELF header. Used by release -// tooling to assert that a packaged binary matches the matrix entry it was -// built for. +// Package binaryarch detects the target architecture of a built executable. package binaryarch import ( @@ -22,7 +19,6 @@ const ( ArchARM64 = "arm64" ) -// Arch is the canonical "/" identifier for a built binary. type Arch struct { GOOS string GOARCH string @@ -30,7 +26,6 @@ type Arch struct { func (a Arch) String() string { return a.GOOS + "/" + a.GOARCH } -// FromFile reads the binary header at path and returns its target Arch. func FromFile(path string) (Arch, error) { f, err := os.Open(path) // #nosec G304 -- caller-controlled release tooling. if err != nil { @@ -58,15 +53,11 @@ func FromFile(path string) (Arch, error) { } func isMachO(h [4]byte) bool { - v := binary.LittleEndian.Uint32(h[:]) - switch v { - case macho.Magic32, macho.Magic64, macho.MagicFat: - return true - } - // Big-endian variants (rare for Apple Silicon era, kept for completeness). - switch binary.BigEndian.Uint32(h[:]) { - case macho.Magic32, macho.Magic64, macho.MagicFat: - return true + for _, order := range []binary.ByteOrder{binary.LittleEndian, binary.BigEndian} { + switch order.Uint32(h[:]) { + case macho.Magic32, macho.Magic64, macho.MagicFat: + return true + } } return false } @@ -107,8 +98,6 @@ func elfArch(f *os.File) (Arch, error) { return Arch{}, fmt.Errorf("unsupported elf machine: %v", ef.Machine) } -// peArch returns the architecture of a PE/COFF executable. Limited to the -// architectures the release pipeline actually ships. func peArch(f *os.File) (Arch, error) { machine, err := readPEMachine(f) if err != nil { diff --git a/hack/binary_arch/binary_arch_test.go b/hack/binary_arch/binary_arch_test.go index c7f7de346..7e4248e3e 100644 --- a/hack/binary_arch/binary_arch_test.go +++ b/hack/binary_arch/binary_arch_test.go @@ -7,8 +7,6 @@ import ( "testing" ) -// buildFixture cross-compiles a tiny program for goos/goarch and returns the -// output path. The fixture lives in t.TempDir so it's cleaned up automatically. func buildFixture(t *testing.T, goos, goarch string) string { t.Helper() diff --git a/hack/inventory_binaries/main.go b/hack/inventory_binaries/main.go index f2ca28823..b382180b0 100644 --- a/hack/inventory_binaries/main.go +++ b/hack/inventory_binaries/main.go @@ -1,8 +1,5 @@ -// inventory_binaries walks a directory tree, prints a per-file inventory line -// (relative path, size, sha256, arch), and optionally flattens nested files -// into the root with collision detection. -// -// Usage: inventory_binaries [-flatten] -dir +// Walks a directory tree, prints per-file inventory (path, size, sha256, arch), +// and optionally flattens nested files into the root. package main import ( @@ -122,8 +119,7 @@ func sha256File(p string) (string, error) { return hex.EncodeToString(h.Sum(nil)), nil } -// flattenInto moves nested files (those whose parent isn't root) into root. -// Fails on name collisions instead of silently overwriting. +// flattenInto fails on name collisions instead of silently overwriting. func flattenInto(root string, files []string) error { rootAbs, err := filepath.Abs(root) if err != nil { @@ -164,8 +160,7 @@ func removeEmptyDirs(root string) error { if err := filepath.WalkDir(root, walk); err != nil { return err } - // Remove deepest first so parents become empty after their children go. - // os.Remove on a non-empty dir returns *os.PathError, which is fine to skip. + // Deepest first so parents become removable after their children go. sort.Sort(sort.Reverse(sort.StringSlice(dirs))) for _, d := range dirs { _ = os.Remove(d) diff --git a/hack/verify_binary_arch/main.go b/hack/verify_binary_arch/main.go index 062cd5431..a0ab14a1a 100644 --- a/hack/verify_binary_arch/main.go +++ b/hack/verify_binary_arch/main.go @@ -1,8 +1,4 @@ -// verify_binary_arch asserts that the binary at -file targets the architecture -// at -goos / -goarch. Fails the process with exit 1 on mismatch so the calling -// CI step turns red. -// -// Usage: verify_binary_arch -file -goos -goarch +// Asserts that the binary at -file targets the architecture at -goos / -goarch. package main import ( From 173c237948b6ef9589fc3a0f351a408a69fcb5e3 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 21 Jun 2026 08:42:44 -0500 Subject: [PATCH 04/10] refactor(hack): consolidate release-artifact tools into one command Three sibling directories (binary_arch, inventory_binaries, verify_binary_arch) collapse into hack/release_artifacts with two subcommands: `inventory` and `verify`. The arch-detection logic stays in its own file and remains unit-tested; the two CLIs share the package and a single dispatcher in main.go. Workflow updated to invoke `go run ./hack/release_artifacts inventory ...` and `go run ./hack/release_artifacts verify ...`. --- .github/workflows/release.yml | 6 ++-- .../arch.go} | 3 +- .../arch_test.go} | 2 +- .../inventory.go} | 33 ++++++++---------- hack/release_artifacts/main.go | 33 ++++++++++++++++++ hack/release_artifacts/verify.go | 31 +++++++++++++++++ hack/verify_binary_arch/main.go | 34 ------------------- 7 files changed, 83 insertions(+), 59 deletions(-) rename hack/{binary_arch/binary_arch.go => release_artifacts/arch.go} (97%) rename hack/{binary_arch/binary_arch_test.go => release_artifacts/arch_test.go} (98%) rename hack/{inventory_binaries/main.go => release_artifacts/inventory.go} (82%) create mode 100644 hack/release_artifacts/main.go create mode 100644 hack/release_artifacts/verify.go delete mode 100644 hack/verify_binary_arch/main.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 46eead75c..8b29f4f5a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -113,7 +113,7 @@ jobs: - name: inventory and flatten CLI artifacts shell: bash run: | - go run ./hack/inventory_binaries -dir "${{ runner.temp }}/devsy-bin" -flatten + go run ./hack/release_artifacts inventory -dir "${{ runner.temp }}/devsy-bin" -flatten - name: setup CLI binary (unix) if: runner.os != 'Windows' @@ -133,7 +133,7 @@ jobs: mkdir -p desktop/resources/bin cp "$src" "$dst" chmod +x "$dst" - go run ./hack/verify_binary_arch -file "$dst" -goos "$MATRIX_GO_OS" -goarch "$MATRIX_GO_ARCH" + go run ./hack/release_artifacts verify -file "$dst" -goos "$MATRIX_GO_OS" -goarch "$MATRIX_GO_ARCH" mkdir -p dist/ find "${{ runner.temp }}/devsy-bin/" -type f -name "devsy-*" -exec cp {} dist/ \; @@ -236,7 +236,7 @@ jobs: ls -la desktop/release || true exit 1 fi - go run ./hack/verify_binary_arch \ + go run ./hack/release_artifacts verify \ -file "$app/Contents/Resources/bin/devsy" \ -goos "$MATRIX_GO_OS" -goarch "$MATRIX_GO_ARCH" diff --git a/hack/binary_arch/binary_arch.go b/hack/release_artifacts/arch.go similarity index 97% rename from hack/binary_arch/binary_arch.go rename to hack/release_artifacts/arch.go index bcd042d3c..5f30d3336 100644 --- a/hack/binary_arch/binary_arch.go +++ b/hack/release_artifacts/arch.go @@ -1,5 +1,4 @@ -// Package binaryarch detects the target architecture of a built executable. -package binaryarch +package main import ( "debug/elf" diff --git a/hack/binary_arch/binary_arch_test.go b/hack/release_artifacts/arch_test.go similarity index 98% rename from hack/binary_arch/binary_arch_test.go rename to hack/release_artifacts/arch_test.go index 7e4248e3e..f46eeab18 100644 --- a/hack/binary_arch/binary_arch_test.go +++ b/hack/release_artifacts/arch_test.go @@ -1,4 +1,4 @@ -package binaryarch +package main import ( "os" diff --git a/hack/inventory_binaries/main.go b/hack/release_artifacts/inventory.go similarity index 82% rename from hack/inventory_binaries/main.go rename to hack/release_artifacts/inventory.go index b382180b0..3ca61c3c4 100644 --- a/hack/inventory_binaries/main.go +++ b/hack/release_artifacts/inventory.go @@ -1,5 +1,3 @@ -// Walks a directory tree, prints per-file inventory (path, size, sha256, arch), -// and optionally flattens nested files into the root. package main import ( @@ -13,26 +11,23 @@ import ( "os" "path/filepath" "sort" - - binaryarch "github.com/devsy-org/devsy/hack/binary_arch" ) -func main() { - dir := flag.String("dir", "", "directory to inventory") - flatten := flag.Bool("flatten", false, +func cmdInventory(args []string) error { + fs := flag.NewFlagSet("inventory", flag.ExitOnError) + dir := fs.String("dir", "", "directory to inventory") + flatten := fs.Bool("flatten", false, "after inventory, move nested files into root and remove empty subdirs") - flag.Parse() - if *dir == "" { - fmt.Fprintln(os.Stderr, "missing required -dir flag") - os.Exit(2) + if err := fs.Parse(args); err != nil { + return err } - if err := run(*dir, *flatten); err != nil { - fmt.Fprintf(os.Stderr, "%v\n", err) - os.Exit(1) + if *dir == "" { + return errors.New("inventory: missing required -dir flag") } + return runInventory(*dir, *flatten) } -func run(dir string, flatten bool) error { +func runInventory(dir string, flatten bool) error { files, err := walkFiles(dir) if err != nil { return fmt.Errorf("walk %s: %w", dir, err) @@ -69,7 +64,7 @@ func printGroup(label, root string, files []string) error { func walkFiles(root string) ([]string, error) { var out []string - err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + walk := func(p string, d fs.DirEntry, err error) error { if err != nil { return err } @@ -77,8 +72,8 @@ func walkFiles(root string) ([]string, error) { out = append(out, p) } return nil - }) - if err != nil { + } + if err := filepath.WalkDir(root, walk); err != nil { return nil, err } sort.Strings(out) @@ -99,7 +94,7 @@ func printEntry(root, p string) error { return err } archStr := "" - if a, err := binaryarch.FromFile(p); err == nil { + 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) diff --git a/hack/release_artifacts/main.go b/hack/release_artifacts/main.go new file mode 100644 index 000000000..df2ed181d --- /dev/null +++ b/hack/release_artifacts/main.go @@ -0,0 +1,33 @@ +// release_artifacts: subcommands for inspecting CLI binaries staged by the +// release pipeline. See cmdInventory and cmdVerify. +package main + +import ( + "fmt" + "os" +) + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + var err error + switch os.Args[1] { + case "inventory": + err = cmdInventory(os.Args[2:]) + case "verify": + err = cmdVerify(os.Args[2:]) + default: + usage() + os.Exit(2) + } + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func usage() { + fmt.Fprintln(os.Stderr, "usage: release_artifacts {inventory|verify} [flags]") +} diff --git a/hack/release_artifacts/verify.go b/hack/release_artifacts/verify.go new file mode 100644 index 000000000..949fb2acd --- /dev/null +++ b/hack/release_artifacts/verify.go @@ -0,0 +1,31 @@ +package main + +import ( + "errors" + "flag" + "fmt" +) + +func cmdVerify(args []string) error { + fs := flag.NewFlagSet("verify", flag.ExitOnError) + file := fs.String("file", "", "binary to verify") + goos := fs.String("goos", "", "expected GOOS") + goarch := fs.String("goarch", "", "expected GOARCH") + if err := fs.Parse(args); err != nil { + return err + } + if *file == "" || *goos == "" || *goarch == "" { + return errors.New("verify: missing required flag (-file, -goos, -goarch)") + } + + got, err := FromFile(*file) + if err != nil { + return fmt.Errorf("::error::%s: %w", *file, err) + } + want := Arch{GOOS: *goos, GOARCH: *goarch} + 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 +} diff --git a/hack/verify_binary_arch/main.go b/hack/verify_binary_arch/main.go deleted file mode 100644 index a0ab14a1a..000000000 --- a/hack/verify_binary_arch/main.go +++ /dev/null @@ -1,34 +0,0 @@ -// Asserts that the binary at -file targets the architecture at -goos / -goarch. -package main - -import ( - "flag" - "fmt" - "os" - - binaryarch "github.com/devsy-org/devsy/hack/binary_arch" -) - -func main() { - file := flag.String("file", "", "binary to verify") - goos := flag.String("goos", "", "expected GOOS") - goarch := flag.String("goarch", "", "expected GOARCH") - flag.Parse() - if *file == "" || *goos == "" || *goarch == "" { - fmt.Fprintln(os.Stderr, "missing required flag (-file, -goos, -goarch)") - os.Exit(2) - } - - got, err := binaryarch.FromFile(*file) - if err != nil { - fmt.Fprintf(os.Stderr, "::error::%s: %v\n", *file, err) - os.Exit(1) - } - want := binaryarch.Arch{GOOS: *goos, GOARCH: *goarch} - fmt.Printf("file=%s detected=%s expected=%s\n", *file, got, want) - if got != want { - fmt.Fprintf(os.Stderr, "::error::arch mismatch: %s is %s, expected %s\n", - *file, got, want) - os.Exit(1) - } -} From 3555667699467b1b2d76e9090bbcd42cc1177214 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 21 Jun 2026 08:44:45 -0500 Subject: [PATCH 05/10] ci(release): drop redundant pre-package arch verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post-electron-builder verify already catches a wrong-arch embedded binary in the produced .app — the only thing the pre-package check provided was a faster failure on the cp's source, which only matters in the failure case. --- .github/workflows/release.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8b29f4f5a..3f900104a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -133,7 +133,6 @@ jobs: mkdir -p desktop/resources/bin cp "$src" "$dst" chmod +x "$dst" - go run ./hack/release_artifacts verify -file "$dst" -goos "$MATRIX_GO_OS" -goarch "$MATRIX_GO_ARCH" mkdir -p dist/ find "${{ runner.temp }}/devsy-bin/" -type f -name "devsy-*" -exec cp {} dist/ \; From e1c94db474c79d956e59e3a7b7931b45f3ac43bc Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 21 Jun 2026 09:11:27 -0500 Subject: [PATCH 06/10] ci(release): collapse setup steps via cross-platform stage subcommand Add a 'stage' subcommand to release_artifacts that handles the matrix- aware src/dst selection (including .exe for windows), the copy, and the post-cp arch verify in one call. Replaces the two platform-split 'setup CLI binary' steps with one cross-platform invocation. The windows path now goes through the same arch-detection assertion as unix instead of just sha256 comparison. Net: -45 lines of YAML, +50 lines of Go (with shared verify helper), arch coverage symmetric across all matrix entries. --- .github/workflows/release.yml | 46 +++++----------------- hack/release_artifacts/main.go | 4 +- hack/release_artifacts/stage.go | 66 ++++++++++++++++++++++++++++++++ hack/release_artifacts/verify.go | 12 +++--- 4 files changed, 85 insertions(+), 43 deletions(-) create mode 100644 hack/release_artifacts/stage.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3f900104a..e8c30202b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,49 +115,21 @@ jobs: run: | go run ./hack/release_artifacts inventory -dir "${{ runner.temp }}/devsy-bin" -flatten - - name: setup CLI binary (unix) + - name: stage embedded CLI binary + shell: bash + run: | + 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 }}" + + - name: stage CLI binaries for dist if: runner.os != 'Windows' shell: bash - env: - MATRIX_GO_OS: ${{ matrix.go-os }} - MATRIX_GO_ARCH: ${{ matrix.go-arch }} run: | - set -euo pipefail - src="${{ runner.temp }}/devsy-bin/devsy-${MATRIX_GO_OS}-${MATRIX_GO_ARCH}" - dst="desktop/resources/bin/devsy" - if [ ! -f "$src" ]; then - echo "::error::expected CLI binary missing: $src" - ls -la "${{ runner.temp }}/devsy-bin" - exit 1 - fi - mkdir -p desktop/resources/bin - cp "$src" "$dst" - chmod +x "$dst" mkdir -p dist/ find "${{ runner.temp }}/devsy-bin/" -type f -name "devsy-*" -exec cp {} dist/ \; - - name: setup CLI binary (windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - $src = "${{ runner.temp }}/devsy-bin/devsy-windows-amd64.exe" - $dst = "desktop/resources/bin/devsy.exe" - if (-not (Test-Path $src)) { - Write-Error "expected CLI binary missing: $src" - Get-ChildItem "${{ runner.temp }}/devsy-bin" - exit 1 - } - New-Item -ItemType Directory -Force -Path desktop/resources/bin | Out-Null - Copy-Item $src $dst - $srcSha = (Get-FileHash -Algorithm SHA256 $src).Hash - $dstSha = (Get-FileHash -Algorithm SHA256 $dst).Hash - Write-Host "src=$src sha256=$srcSha" - Write-Host "dst=$dst sha256=$dstSha" - if ($srcSha -ne $dstSha) { - Write-Error "sha256 mismatch between source and destination" - exit 1 - } - - name: generate pro provider if: matrix.name == 'linux-x64' working-directory: ./hack/pro diff --git a/hack/release_artifacts/main.go b/hack/release_artifacts/main.go index df2ed181d..d767a5213 100644 --- a/hack/release_artifacts/main.go +++ b/hack/release_artifacts/main.go @@ -16,6 +16,8 @@ func main() { switch os.Args[1] { case "inventory": err = cmdInventory(os.Args[2:]) + case "stage": + err = cmdStage(os.Args[2:]) case "verify": err = cmdVerify(os.Args[2:]) default: @@ -29,5 +31,5 @@ func main() { } func usage() { - fmt.Fprintln(os.Stderr, "usage: release_artifacts {inventory|verify} [flags]") + fmt.Fprintln(os.Stderr, "usage: release_artifacts {inventory|stage|verify} [flags]") } diff --git a/hack/release_artifacts/stage.go b/hack/release_artifacts/stage.go new file mode 100644 index 000000000..6bad99419 --- /dev/null +++ b/hack/release_artifacts/stage.go @@ -0,0 +1,66 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" +) + +func cmdStage(args []string) error { + fs := flag.NewFlagSet("stage", flag.ExitOnError) + srcDir := fs.String("src-dir", "", "directory containing the per-arch CLI binaries") + dstDir := fs.String("dst-dir", "", "directory the embedded binary is copied into") + goos := fs.String("goos", "", "GOOS for the binary to stage") + goarch := fs.String("goarch", "", "GOARCH for the binary to stage") + if err := fs.Parse(args); err != nil { + return err + } + if *srcDir == "" || *dstDir == "" || *goos == "" || *goarch == "" { + return errors.New("stage: missing required flag (-src-dir, -dst-dir, -goos, -goarch)") + } + return runStage(*srcDir, *dstDir, *goos, *goarch) +} + +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 index 949fb2acd..9bdacdc29 100644 --- a/hack/release_artifacts/verify.go +++ b/hack/release_artifacts/verify.go @@ -17,15 +17,17 @@ func cmdVerify(args []string) error { if *file == "" || *goos == "" || *goarch == "" { return errors.New("verify: missing required flag (-file, -goos, -goarch)") } + return verify(*file, Arch{GOOS: *goos, GOARCH: *goarch}) +} - got, err := FromFile(*file) +func verify(file string, want Arch) error { + got, err := FromFile(file) if err != nil { - return fmt.Errorf("::error::%s: %w", *file, err) + return fmt.Errorf("::error::%s: %w", file, err) } - want := Arch{GOOS: *goos, GOARCH: *goarch} - fmt.Printf("file=%s detected=%s expected=%s\n", *file, got, want) + 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 fmt.Errorf("::error::arch mismatch: %s is %s, expected %s", file, got, want) } return nil } From f4ffe66c3574d7affe0c0dccaa8425542faa828b Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 21 Jun 2026 09:16:43 -0500 Subject: [PATCH 07/10] refactor(hack): use urfave/cli/v3 for release_artifacts subcommands --- go.mod | 1 + go.sum | 2 ++ hack/release_artifacts/inventory.go | 29 ++++++++++------- hack/release_artifacts/main.go | 34 ++++++++------------ hack/release_artifacts/stage.go | 49 ++++++++++++++++++++--------- hack/release_artifacts/verify.go | 29 +++++++++-------- 6 files changed, 84 insertions(+), 60 deletions(-) diff --git a/go.mod b/go.mod index 3e858a6b2..c1a4bfc92 100644 --- a/go.mod +++ b/go.mod @@ -55,6 +55,7 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tonistiigi/fsutil v0.0.0-20251211185533-a2aa163d723f github.com/u-root/u-root v0.16.0 + github.com/urfave/cli/v3 v3.10.0 go.uber.org/atomic v1.11.0 go.uber.org/zap v1.27.1 golang.org/x/crypto v0.50.0 diff --git a/go.sum b/go.sum index 6d47dd858..0cb68b656 100644 --- a/go.sum +++ b/go.sum @@ -705,6 +705,8 @@ github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8 github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/urfave/cli/v3 v3.10.0 h1:0aU8yOObVDMkM13Cj4G+zb4P0PdeJMec65f81Ak1ioM= +github.com/urfave/cli/v3 v3.10.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4= github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= diff --git a/hack/release_artifacts/inventory.go b/hack/release_artifacts/inventory.go index 3ca61c3c4..b55d6fed2 100644 --- a/hack/release_artifacts/inventory.go +++ b/hack/release_artifacts/inventory.go @@ -1,30 +1,35 @@ package main import ( + "context" "crypto/sha256" "encoding/hex" "errors" - "flag" "fmt" "io" "io/fs" "os" "path/filepath" "sort" + + "github.com/urfave/cli/v3" ) -func cmdInventory(args []string) error { - fs := flag.NewFlagSet("inventory", flag.ExitOnError) - dir := fs.String("dir", "", "directory to inventory") - flatten := fs.Bool("flatten", false, - "after inventory, move nested files into root and remove empty subdirs") - if err := fs.Parse(args); err != nil { - return err - } - if *dir == "" { - return errors.New("inventory: missing required -dir flag") +func inventoryCmd() *cli.Command { + return &cli.Command{ + Name: "inventory", + Usage: "log size/sha256/arch for each file under -dir; optionally flatten", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "dir", Usage: "directory to inventory", Required: true}, + &cli.BoolFlag{ + Name: "flatten", + Usage: "after inventory, move nested files into root and remove empty subdirs", + }, + }, + Action: func(_ context.Context, c *cli.Command) error { + return runInventory(c.String("dir"), c.Bool("flatten")) + }, } - return runInventory(*dir, *flatten) } func runInventory(dir string, flatten bool) error { diff --git a/hack/release_artifacts/main.go b/hack/release_artifacts/main.go index d767a5213..d58865e54 100644 --- a/hack/release_artifacts/main.go +++ b/hack/release_artifacts/main.go @@ -1,35 +1,27 @@ // release_artifacts: subcommands for inspecting CLI binaries staged by the -// release pipeline. See cmdInventory and cmdVerify. +// release pipeline. See cmdInventory, cmdStage, and cmdVerify. package main import ( + "context" "fmt" "os" + + "github.com/urfave/cli/v3" ) func main() { - if len(os.Args) < 2 { - usage() - os.Exit(2) - } - var err error - switch os.Args[1] { - case "inventory": - err = cmdInventory(os.Args[2:]) - case "stage": - err = cmdStage(os.Args[2:]) - case "verify": - err = cmdVerify(os.Args[2:]) - default: - usage() - os.Exit(2) + cmd := &cli.Command{ + Name: "release_artifacts", + Usage: "release-pipeline tools for staging and verifying CLI binaries", + Commands: []*cli.Command{ + inventoryCmd(), + stageCmd(), + verifyCmd(), + }, } - if err != nil { + if err := cmd.Run(context.Background(), os.Args); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } - -func usage() { - fmt.Fprintln(os.Stderr, "usage: release_artifacts {inventory|stage|verify} [flags]") -} diff --git a/hack/release_artifacts/stage.go b/hack/release_artifacts/stage.go index 6bad99419..14e02647a 100644 --- a/hack/release_artifacts/stage.go +++ b/hack/release_artifacts/stage.go @@ -1,27 +1,48 @@ package main import ( - "errors" - "flag" + "context" "fmt" "io" "os" "path/filepath" + + "github.com/urfave/cli/v3" ) -func cmdStage(args []string) error { - fs := flag.NewFlagSet("stage", flag.ExitOnError) - srcDir := fs.String("src-dir", "", "directory containing the per-arch CLI binaries") - dstDir := fs.String("dst-dir", "", "directory the embedded binary is copied into") - goos := fs.String("goos", "", "GOOS for the binary to stage") - goarch := fs.String("goarch", "", "GOARCH for the binary to stage") - if err := fs.Parse(args); err != nil { - return err - } - if *srcDir == "" || *dstDir == "" || *goos == "" || *goarch == "" { - return errors.New("stage: missing required flag (-src-dir, -dst-dir, -goos, -goarch)") +func stageCmd() *cli.Command { + return &cli.Command{ + Name: "stage", + Usage: "copy the matching per-arch CLI binary into -dst-dir and verify its arch", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "src-dir", + Usage: "directory containing the per-arch CLI binaries", + Required: true, + }, + &cli.StringFlag{ + Name: "dst-dir", + Usage: "directory the embedded binary is copied into", + Required: true, + }, + &cli.StringFlag{ + Name: "goos", + Usage: "GOOS for the binary to stage", + Required: true, + }, + &cli.StringFlag{ + Name: "goarch", + Usage: "GOARCH for the binary to stage", + Required: true, + }, + }, + Action: func(_ context.Context, c *cli.Command) error { + return runStage( + c.String("src-dir"), c.String("dst-dir"), + c.String("goos"), c.String("goarch"), + ) + }, } - return runStage(*srcDir, *dstDir, *goos, *goarch) } func runStage(srcDir, dstDir, goos, goarch string) error { diff --git a/hack/release_artifacts/verify.go b/hack/release_artifacts/verify.go index 9bdacdc29..efa9b943c 100644 --- a/hack/release_artifacts/verify.go +++ b/hack/release_artifacts/verify.go @@ -1,23 +1,26 @@ package main import ( - "errors" - "flag" + "context" "fmt" + + "github.com/urfave/cli/v3" ) -func cmdVerify(args []string) error { - fs := flag.NewFlagSet("verify", flag.ExitOnError) - file := fs.String("file", "", "binary to verify") - goos := fs.String("goos", "", "expected GOOS") - goarch := fs.String("goarch", "", "expected GOARCH") - if err := fs.Parse(args); err != nil { - return err - } - if *file == "" || *goos == "" || *goarch == "" { - return errors.New("verify: missing required flag (-file, -goos, -goarch)") +func verifyCmd() *cli.Command { + return &cli.Command{ + Name: "verify", + Usage: "assert that the binary at -file targets -goos / -goarch", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "file", Usage: "binary to verify", Required: true}, + &cli.StringFlag{Name: "goos", Usage: "expected GOOS", Required: true}, + &cli.StringFlag{Name: "goarch", Usage: "expected GOARCH", Required: true}, + }, + Action: func(_ context.Context, c *cli.Command) error { + want := Arch{GOOS: c.String("goos"), GOARCH: c.String("goarch")} + return verify(c.String("file"), want) + }, } - return verify(*file, Arch{GOOS: *goos, GOARCH: *goarch}) } func verify(file string, want Arch) error { From a0de24f07d17682f8ff286174c5ea8b2c267b09e Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 21 Jun 2026 09:20:16 -0500 Subject: [PATCH 08/10] ci(release): add populate-dist subcommand and gate it correctly The previous 'stage CLI binaries for dist' step ran on macOS too even though only the linux-x64 matrix entry consumes dist/ (for pro provider generation). Replace with a cross-platform 'populate-dist' subcommand gated on matrix.name == 'linux-x64' instead of runner.os. Net: less wasted work on macOS desktop runs, file plumbing moved into the Go tool. Add a comment on the macOS-only post-package verifier explaining why it cannot be collapsed (extracting .AppImage / .deb / .rpm / NSIS would need archive readers). --- .github/workflows/release.yml | 10 +++-- hack/release_artifacts/main.go | 1 + hack/release_artifacts/populate_dist.go | 50 +++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 hack/release_artifacts/populate_dist.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e8c30202b..129769dd9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -123,12 +123,12 @@ jobs: -dst-dir desktop/resources/bin \ -goos "${{ matrix.go-os }}" -goarch "${{ matrix.go-arch }}" - - name: stage CLI binaries for dist - if: runner.os != 'Windows' + - name: populate dist for pro provider generation + if: matrix.name == 'linux-x64' shell: bash run: | - mkdir -p dist/ - find "${{ runner.temp }}/devsy-bin/" -type f -name "devsy-*" -exec cp {} dist/ \; + 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' @@ -193,6 +193,8 @@ 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 diff --git a/hack/release_artifacts/main.go b/hack/release_artifacts/main.go index d58865e54..211faf169 100644 --- a/hack/release_artifacts/main.go +++ b/hack/release_artifacts/main.go @@ -16,6 +16,7 @@ func main() { Usage: "release-pipeline tools for staging and verifying CLI binaries", Commands: []*cli.Command{ inventoryCmd(), + populateDistCmd(), stageCmd(), verifyCmd(), }, diff --git a/hack/release_artifacts/populate_dist.go b/hack/release_artifacts/populate_dist.go new file mode 100644 index 000000000..0b39a2ec4 --- /dev/null +++ b/hack/release_artifacts/populate_dist.go @@ -0,0 +1,50 @@ +package main + +import ( + "context" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/urfave/cli/v3" +) + +func populateDistCmd() *cli.Command { + return &cli.Command{ + Name: "populate-dist", + Usage: "copy every devsy-* binary from -src-dir into -dst-dir", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "src-dir", + Usage: "directory containing the per-arch CLI binaries", + Required: true, + }, + &cli.StringFlag{ + Name: "dst-dir", + Usage: "directory to populate", + Required: true, + }, + }, + Action: func(_ context.Context, c *cli.Command) error { + return runPopulateDist(c.String("src-dir"), c.String("dst-dir")) + }, + } +} + +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) + } + 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 + } + return copyExecutable(p, filepath.Join(dstDir, d.Name())) + } + return filepath.WalkDir(srcDir, walk) +} From 6db359129b30bb25f0dec3292702089b3ecb2848 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 21 Jun 2026 09:31:36 -0500 Subject: [PATCH 09/10] refactor(hack): switch release_artifacts to cobra (already a direct dep) cobra is already a direct dep used throughout cmd/ for the main devsy CLI. Using it for hack tools removes the urfave/cli/v3 dep, keeps the codebase to one CLI framework, and makes future hack tools a copy of an existing pattern. Workflow flags switch from -flag to --flag form (cobra is stricter than urfave on flag prefix). --- .github/workflows/release.yml | 14 +++---- go.mod | 1 - go.sum | 2 - hack/release_artifacts/inventory.go | 29 +++++++-------- hack/release_artifacts/main.go | 27 +++++++------- hack/release_artifacts/populate_dist.go | 34 +++++++---------- hack/release_artifacts/stage.go | 49 ++++++++----------------- hack/release_artifacts/verify.go | 29 ++++++++------- 8 files changed, 80 insertions(+), 105 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 129769dd9..bd5fdc049 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -113,22 +113,22 @@ jobs: - name: inventory and flatten CLI artifacts shell: bash run: | - go run ./hack/release_artifacts inventory -dir "${{ runner.temp }}/devsy-bin" -flatten + go run ./hack/release_artifacts inventory --dir "${{ runner.temp }}/devsy-bin" --flatten - name: stage embedded CLI binary shell: bash run: | 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 }}" + --src-dir "${{ runner.temp }}/devsy-bin" \ + --dst-dir desktop/resources/bin \ + --goos "${{ matrix.go-os }}" --goarch "${{ matrix.go-arch }}" - name: populate dist for pro provider generation if: matrix.name == 'linux-x64' shell: bash run: | go run ./hack/release_artifacts populate-dist \ - -src-dir "${{ runner.temp }}/devsy-bin" -dst-dir dist + --src-dir "${{ runner.temp }}/devsy-bin" --dst-dir dist - name: generate pro provider if: matrix.name == 'linux-x64' @@ -210,8 +210,8 @@ jobs: exit 1 fi go run ./hack/release_artifacts verify \ - -file "$app/Contents/Resources/bin/devsy" \ - -goos "$MATRIX_GO_OS" -goarch "$MATRIX_GO_ARCH" + --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 diff --git a/go.mod b/go.mod index c1a4bfc92..3e858a6b2 100644 --- a/go.mod +++ b/go.mod @@ -55,7 +55,6 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tonistiigi/fsutil v0.0.0-20251211185533-a2aa163d723f github.com/u-root/u-root v0.16.0 - github.com/urfave/cli/v3 v3.10.0 go.uber.org/atomic v1.11.0 go.uber.org/zap v1.27.1 golang.org/x/crypto v0.50.0 diff --git a/go.sum b/go.sum index 0cb68b656..6d47dd858 100644 --- a/go.sum +++ b/go.sum @@ -705,8 +705,6 @@ github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8 github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= -github.com/urfave/cli/v3 v3.10.0 h1:0aU8yOObVDMkM13Cj4G+zb4P0PdeJMec65f81Ak1ioM= -github.com/urfave/cli/v3 v3.10.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4= github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= diff --git a/hack/release_artifacts/inventory.go b/hack/release_artifacts/inventory.go index b55d6fed2..0fe26611d 100644 --- a/hack/release_artifacts/inventory.go +++ b/hack/release_artifacts/inventory.go @@ -1,7 +1,6 @@ package main import ( - "context" "crypto/sha256" "encoding/hex" "errors" @@ -12,24 +11,24 @@ import ( "path/filepath" "sort" - "github.com/urfave/cli/v3" + "github.com/spf13/cobra" ) -func inventoryCmd() *cli.Command { - return &cli.Command{ - Name: "inventory", - Usage: "log size/sha256/arch for each file under -dir; optionally flatten", - Flags: []cli.Flag{ - &cli.StringFlag{Name: "dir", Usage: "directory to inventory", Required: true}, - &cli.BoolFlag{ - Name: "flatten", - Usage: "after inventory, move nested files into root and remove empty subdirs", - }, - }, - Action: func(_ context.Context, c *cli.Command) error { - return runInventory(c.String("dir"), c.Bool("flatten")) +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 { diff --git a/hack/release_artifacts/main.go b/hack/release_artifacts/main.go index 211faf169..92c702e4c 100644 --- a/hack/release_artifacts/main.go +++ b/hack/release_artifacts/main.go @@ -1,27 +1,28 @@ // release_artifacts: subcommands for inspecting CLI binaries staged by the -// release pipeline. See cmdInventory, cmdStage, and cmdVerify. +// release pipeline. package main import ( - "context" "fmt" "os" - "github.com/urfave/cli/v3" + "github.com/spf13/cobra" ) func main() { - cmd := &cli.Command{ - Name: "release_artifacts", - Usage: "release-pipeline tools for staging and verifying CLI binaries", - Commands: []*cli.Command{ - inventoryCmd(), - populateDistCmd(), - stageCmd(), - verifyCmd(), - }, + root := &cobra.Command{ + Use: "release_artifacts", + Short: "release-pipeline tools for staging and verifying CLI binaries", + SilenceUsage: true, + SilenceErrors: true, } - if err := cmd.Run(context.Background(), os.Args); err != nil { + 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 index 0b39a2ec4..e9c6a5865 100644 --- a/hack/release_artifacts/populate_dist.go +++ b/hack/release_artifacts/populate_dist.go @@ -1,36 +1,30 @@ package main import ( - "context" "fmt" "io/fs" "os" "path/filepath" "strings" - "github.com/urfave/cli/v3" + "github.com/spf13/cobra" ) -func populateDistCmd() *cli.Command { - return &cli.Command{ - Name: "populate-dist", - Usage: "copy every devsy-* binary from -src-dir into -dst-dir", - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "src-dir", - Usage: "directory containing the per-arch CLI binaries", - Required: true, - }, - &cli.StringFlag{ - Name: "dst-dir", - Usage: "directory to populate", - Required: true, - }, - }, - Action: func(_ context.Context, c *cli.Command) error { - return runPopulateDist(c.String("src-dir"), c.String("dst-dir")) +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 { diff --git a/hack/release_artifacts/stage.go b/hack/release_artifacts/stage.go index 14e02647a..6079d027f 100644 --- a/hack/release_artifacts/stage.go +++ b/hack/release_artifacts/stage.go @@ -1,48 +1,31 @@ package main import ( - "context" "fmt" "io" "os" "path/filepath" - "github.com/urfave/cli/v3" + "github.com/spf13/cobra" ) -func stageCmd() *cli.Command { - return &cli.Command{ - Name: "stage", - Usage: "copy the matching per-arch CLI binary into -dst-dir and verify its arch", - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "src-dir", - Usage: "directory containing the per-arch CLI binaries", - Required: true, - }, - &cli.StringFlag{ - Name: "dst-dir", - Usage: "directory the embedded binary is copied into", - Required: true, - }, - &cli.StringFlag{ - Name: "goos", - Usage: "GOOS for the binary to stage", - Required: true, - }, - &cli.StringFlag{ - Name: "goarch", - Usage: "GOARCH for the binary to stage", - Required: true, - }, - }, - Action: func(_ context.Context, c *cli.Command) error { - return runStage( - c.String("src-dir"), c.String("dst-dir"), - c.String("goos"), c.String("goarch"), - ) +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 { diff --git a/hack/release_artifacts/verify.go b/hack/release_artifacts/verify.go index efa9b943c..f58f3a82b 100644 --- a/hack/release_artifacts/verify.go +++ b/hack/release_artifacts/verify.go @@ -1,26 +1,27 @@ package main import ( - "context" "fmt" - "github.com/urfave/cli/v3" + "github.com/spf13/cobra" ) -func verifyCmd() *cli.Command { - return &cli.Command{ - Name: "verify", - Usage: "assert that the binary at -file targets -goos / -goarch", - Flags: []cli.Flag{ - &cli.StringFlag{Name: "file", Usage: "binary to verify", Required: true}, - &cli.StringFlag{Name: "goos", Usage: "expected GOOS", Required: true}, - &cli.StringFlag{Name: "goarch", Usage: "expected GOARCH", Required: true}, - }, - Action: func(_ context.Context, c *cli.Command) error { - want := Arch{GOOS: c.String("goos"), GOARCH: c.String("goarch")} - return verify(c.String("file"), want) +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 { From c33090ebf447b50ea95ef9cee042dd83a2092e80 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 21 Jun 2026 17:14:02 -0500 Subject: [PATCH 10/10] fix(hack): detect basename collisions in populate-dist Brings populate-dist in line with inventory --flatten's existing collision-detection. goreleaser today emits unique basenames per matrix entry so this is currently a no-op; the check exists to fail loudly if a future matrix change collapses two binaries onto the same name. --- hack/release_artifacts/populate_dist.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/hack/release_artifacts/populate_dist.go b/hack/release_artifacts/populate_dist.go index e9c6a5865..bdcddc3fe 100644 --- a/hack/release_artifacts/populate_dist.go +++ b/hack/release_artifacts/populate_dist.go @@ -31,6 +31,7 @@ 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 @@ -38,6 +39,10 @@ func runPopulateDist(srcDir, dstDir string) error { 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)