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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 32 additions & 19 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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:
Expand Down
142 changes: 142 additions & 0 deletions hack/release_artifacts/arch.go
Original file line number Diff line number Diff line change
@@ -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
}
60 changes: 60 additions & 0 deletions hack/release_artifacts/arch_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading
Loading