diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3f34786 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,52 @@ +name: Release + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + test: + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.release.tag_name }} + - uses: actions/setup-go@v7 + with: + go-version: "1.26.x" + - name: Install git-annex on Linux + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y git-annex + - name: Install git-annex on macOS + if: runner.os == 'macOS' + run: brew install git-annex + - run: go test ./... + + publish: + needs: test + runs-on: ubuntu-latest + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.release.tag_name }} + - uses: actions/setup-go@v7 + with: + go-version: "1.26.x" + - name: Build release archives + run: | + for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do + ./scripts/package-release.sh "$RELEASE_TAG" "${target%/*}" "${target#*/}" + done + - name: Upload release assets + run: gh release upload "$RELEASE_TAG" dist/* --clobber diff --git a/README.md b/README.md index 4af1349..77e8c4a 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,25 @@ make build ./bin/dfs health ``` +### Published releases + +Maintainers publish versioned Linux and macOS archives for amd64 and arm64 by +creating a GitHub release from `main`: + +```sh +gh release create v0.1.0-alpha.1 \ + --repo bitbeamer/dfs \ + --target main \ + --prerelease \ + --generate-notes +``` + +Publishing the release runs the full test suite on Linux and macOS, then adds +the four archives and their SHA-256 checksum files to that release. Extract an +archive and run `./bin/dfs`; keep its `scripts` directory beside `bin` so that +`dfs setup` can find the platform installer. Git, git-annex, and FUSE 3 or +macFUSE remain runtime prerequisites. + The binary uses the native Go FUSE protocol implementation and does not link against libfuse, but FUSE 3 or macFUSE is still required at runtime. See [INSTALL.md](INSTALL.md) for complete platform setup, macOS permissions, diff --git a/scripts/package-release.sh b/scripts/package-release.sh new file mode 100755 index 0000000..7ed0487 --- /dev/null +++ b/scripts/package-release.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + printf 'Usage: %s [output-directory]\n' "$0" >&2 +} + +if (( $# < 3 || $# > 4 )); then + usage + exit 2 +fi + +version=$1 +target_os=$2 +target_arch=$3 +output_directory=${4:-dist} + +if [[ ! "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z]+)*$ ]]; then + printf 'Release version must be a v-prefixed semantic version: %s\n' "$version" >&2 + exit 2 +fi +case "$target_os" in + linux) installer=install-cachyos.sh ;; + darwin) installer=install-macos.sh ;; + *) usage; exit 2 ;; +esac +case "$target_arch" in + amd64|arm64) ;; + *) usage; exit 2 ;; +esac + +repository=$(cd "$(dirname "$0")/.." && pwd -P) +mkdir -p "$output_directory" +output_directory=$(cd "$output_directory" && pwd -P) +archive_name="dfs-$version-$target_os-$target_arch" +temporary_directory=$(mktemp -d) +trap 'rm -rf "$temporary_directory"' EXIT +archive_root="$temporary_directory/$archive_name" +mkdir -p "$archive_root/bin" "$archive_root/scripts" + +( + cd "$repository" + CGO_ENABLED=0 GOOS="$target_os" GOARCH="$target_arch" go build \ + -trimpath \ + -ldflags "-s -w -X github.com/bitbeamer/dfs/internal/cli.Version=$version" \ + -o "$archive_root/bin/dfs" ./cmd/dfs +) +install -m 0755 "$repository/scripts/$installer" "$archive_root/scripts/$installer" +install -m 0644 "$repository/README.md" "$repository/INSTALL.md" "$repository/LICENSE" "$archive_root/" + +archive="$output_directory/$archive_name.tar.gz" +tar -C "$temporary_directory" -czf "$archive" "$archive_name" +if command -v sha256sum >/dev/null 2>&1; then + (cd "$output_directory" && sha256sum "$(basename "$archive")" >"$(basename "$archive").sha256") +else + (cd "$output_directory" && shasum -a 256 "$(basename "$archive")" >"$(basename "$archive").sha256") +fi + +printf 'Created %s\n' "$archive" diff --git a/scripts/package_release_test.go b/scripts/package_release_test.go new file mode 100644 index 0000000..0fca659 --- /dev/null +++ b/scripts/package_release_test.go @@ -0,0 +1,75 @@ +package scripts_test + +import ( + "archive/tar" + "compress/gzip" + "errors" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestPackageReleaseCreatesInstallableArchiveAndChecksum(t *testing.T) { + output := t.TempDir() + command := exec.Command("./package-release.sh", "v1.2.3-alpha.1", "linux", "amd64", output) + if combined, err := command.CombinedOutput(); err != nil { + t.Fatalf("package release: %v\n%s", err, combined) + } + + base := "dfs-v1.2.3-alpha.1-linux-amd64" + archive := filepath.Join(output, base+".tar.gz") + want := map[string]int64{ + base + "/bin/dfs": 0o755, + base + "/scripts/install-cachyos.sh": 0o755, + base + "/README.md": 0o644, + base + "/INSTALL.md": 0o644, + base + "/LICENSE": 0o644, + } + file, err := os.Open(archive) + if err != nil { + t.Fatal(err) + } + decompressed, err := gzip.NewReader(file) + if err != nil { + file.Close() + t.Fatal(err) + } + for reader := tar.NewReader(decompressed); ; { + header, err := reader.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + t.Fatal(err) + } + if mode, ok := want[header.Name]; ok { + if header.Mode&0o777 != mode { + t.Errorf("%s mode = %o, want %o", header.Name, header.Mode&0o777, mode) + } + delete(want, header.Name) + } + } + decompressed.Close() + file.Close() + if len(want) != 0 { + t.Fatalf("archive is missing entries: %v", want) + } + + checksum, err := os.ReadFile(archive + ".sha256") + if err != nil { + t.Fatal(err) + } + if !strings.HasSuffix(strings.TrimSpace(string(checksum)), " "+filepath.Base(archive)) { + t.Fatalf("checksum does not name archive: %q", checksum) + } +} + +func TestPackageReleaseRejectsInvalidTarget(t *testing.T) { + command := exec.Command("./package-release.sh", "latest", "windows", "386", t.TempDir()) + if err := command.Run(); err == nil { + t.Fatal("invalid release target unexpectedly succeeded") + } +}