From f8098bb7dcdc07c8fab7ffdff8e898fa2c1e24fb Mon Sep 17 00:00:00 2001 From: Lucas Machado Date: Fri, 10 Jul 2026 18:25:27 +0200 Subject: [PATCH 1/2] feat: prepare public v0.1.0 release --- .github/workflows/pr.yml | 2 +- .github/workflows/release.yml | 4 +- LICENSE | 21 +++++ README.md | 14 +++- docs/specs/008-public-release.md | 59 ++++++++++++++ docs/specs/008-public-release.plan.md | 23 ++++++ go.mod | 2 +- install.sh | 74 ++++++++++++++++++ integration/install_script_test.go | 107 ++++++++++++++++++++++++++ 9 files changed, 299 insertions(+), 7 deletions(-) create mode 100644 LICENSE create mode 100644 docs/specs/008-public-release.md create mode 100644 docs/specs/008-public-release.plan.md create mode 100755 install.sh create mode 100644 integration/install_script_test.go diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 946bce3..21bd9a0 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -62,7 +62,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-go@v5 with: - go-version: "1.24" + go-version: "1.25" # Dogfood: gauntlet runs gauntlet against its own PR diff. - uses: ./ with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 80314d6..056223a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,9 @@ jobs: fetch-depth: 0 - uses: actions/setup-go@v5 with: - go-version: "1.24" + go-version: "1.25" + - run: go test -race ./... + - run: go test -tags=integration -race ./integration/... - uses: goreleaser/goreleaser-action@v6 with: version: latest diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5e4496b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 AxeForging + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 4b355f3..9da2210 100644 --- a/README.md +++ b/README.md @@ -16,14 +16,20 @@ gotest ERROR internal/engine/plan_test.go:33 test-failure TestP ## Install ```bash -# From release (recommended once v1 ships): -curl -sSfL https://github.com/AxeForging/gauntlet/releases/latest/download/gauntlet_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m).tar.gz | tar -xz -C /usr/local/bin +# Latest checksum-verified release: +curl -fsSL https://raw.githubusercontent.com/AxeForging/gauntlet/main/install.sh | sh + +# Pin a version or install somewhere else: +curl -fsSL https://raw.githubusercontent.com/AxeForging/gauntlet/main/install.sh | \ + GAUNTLET_VERSION=v0.1.0 GAUNTLET_INSTALL_DIR="$HOME/.local/bin" sh # From source: go install github.com/AxeForging/gauntlet/cmd/gauntlet@main ``` -Requires Go 1.24+ (for source install). Optional gate tools (skipped visibly if absent): `gofumpt`, `golangci-lint`, `prettier`, `eslint`, `tsc`, `vitest`. +Requires Go 1.25+ for source installation. Release binaries have no Go runtime +dependency. Optional gate tools (skipped visibly if absent): `gofumpt`, +`golangci-lint`, `prettier`, `eslint`, `tsc`, `vitest`. ## CI usage @@ -144,4 +150,4 @@ Spec: [docs/specs/001-gauntlet-core.md](docs/specs/001-gauntlet-core.md). Plan: ## License -TBD — private repo during development. +MIT — see [LICENSE](LICENSE). diff --git a/docs/specs/008-public-release.md b/docs/specs/008-public-release.md new file mode 100644 index 0000000..994d7b7 --- /dev/null +++ b/docs/specs/008-public-release.md @@ -0,0 +1,59 @@ +# 008 — Public v0.1.0 release + +## Context + +Gauntlet's core, CI action, custom gates, and shell-free pipelines are implemented and +validated, but the repository is private, has no license, and documents a release URL +that does not match its GoReleaser archive name. A public first release needs a complete, +tested installation path rather than relying on source installation from `main`. + +## Requirements + +1. The repository MUST ship an MIT license owned by AxeForging. +2. Go source, CI, and release builds MUST use the project-pinned Go 1.25 line. +3. A POSIX installer MUST detect supported OS/architecture pairs, download a selected or + latest release, verify SHA-256 checksums, and install to a configurable directory. +4. The installer MUST default to writable `/usr/local/bin`, otherwise `$HOME/.local/bin`, + and MUST explain when the selected directory is not on PATH. +5. A real executable regression test MUST install and run a fixture archive and verify + the pinned-version release URL. +6. README and composite-action documentation MUST match actual GoReleaser asset names. +7. Pull-request validation MUST remain PR-only; tag-triggered release validation MUST + test before publishing. +8. After merge, the repository MUST become public before tagging `v0.1.0`. +9. The published release MUST contain all configured archives and checksums, and the + unauthenticated public installer MUST install a binary reporting version `0.1.0`. + +## Non-goals + +- Windows archives in v0.1.0. +- Package-manager distribution. +- Removing source installation as a development option. + +## Design + +Use the established dupehound installer contract with Gauntlet's underscore-separated +GoReleaser asset names. Keep release creation tag-driven. The installer itself is tested +against a local fake release transport so CI is deterministic, then tested once against +the real public release as a rollout verification. + +## Data & API changes + +- Environment variables: `GAUNTLET_VERSION`, `GAUNTLET_INSTALL_DIR`, and + `GAUNTLET_REPO`. +- No runtime data or schema migration. + +## Test plan + +- Execute the installer regression with a fixture archive and checksum manifest. +- Run gofumpt, golangci-lint, `go test -race ./...`, and tagged integration tests. +- Verify GoReleaser configuration before merge. +- After publishing, run the raw GitHub installer unauthenticated into `/tmp` and execute + `gauntlet version`. + +## Rollout + +Merge release prep, change visibility to public, push annotated `v0.1.0`, monitor the +release workflow, inspect assets, and run the public installer. Roll back by deleting a +bad release/tag and correcting the installer before retagging; repository visibility is +not automatically rolled back. diff --git a/docs/specs/008-public-release.plan.md b/docs/specs/008-public-release.plan.md new file mode 100644 index 0000000..26332c1 --- /dev/null +++ b/docs/specs/008-public-release.plan.md @@ -0,0 +1,23 @@ +# Plan — 008 Public v0.1.0 release + +Spec: `docs/specs/008-public-release.md` + +- [x] Add MIT `LICENSE` and update README release language. +- [x] Align Go module and workflows to Go 1.25. +- [x] Add checksum-verifying `install.sh` and executable regression coverage. +- [x] Correct README/action artifact installation examples and verify GoReleaser config. +- [x] Run formatting, lint, race, and binary integration suites. +- [ ] Commit, push, open the release-prep PR, and wait for PR-only validation. +- [ ] Merge, make the repository public, and push annotated `v0.1.0`. +- [ ] Verify release assets and the unauthenticated public installer end to end. + +## Risks + +- Asset naming must remain identical across GoReleaser, installer, README, and action. +- Visibility change is externally observable and explicitly authorized by the release goal. + +## Done means + +- Gauntlet is public under MIT. +- `v0.1.0` is published with checksums and supported archives. +- A clean machine can install it with one curl command and run the reported version. diff --git a/go.mod b/go.mod index 3b0b2a6..6837517 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/AxeForging/gauntlet -go 1.24.6 +go 1.25 require ( github.com/goccy/go-yaml v1.19.2 diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..b724a36 --- /dev/null +++ b/install.sh @@ -0,0 +1,74 @@ +#!/bin/sh + +set -eu + +REPO="${GAUNTLET_REPO:-AxeForging/gauntlet}" +VERSION="${GAUNTLET_VERSION:-latest}" +if [ -n "${GAUNTLET_INSTALL_DIR:-}" ]; then + INSTALL_DIR="$GAUNTLET_INSTALL_DIR" +elif [ -d /usr/local/bin ] && [ -w /usr/local/bin ]; then + INSTALL_DIR=/usr/local/bin +else + INSTALL_DIR="${HOME:?HOME is required when /usr/local/bin is not writable}/.local/bin" +fi + +die() { + printf 'gauntlet installer: %s\n' "$*" >&2 + exit 1 +} + +command -v curl >/dev/null 2>&1 || die "curl is required" +command -v tar >/dev/null 2>&1 || die "tar is required" + +case "$(uname -s)" in + Linux) os=linux ;; + Darwin) os=darwin ;; + *) die "unsupported operating system: $(uname -s)" ;; +esac + +case "$(uname -m)" in + x86_64 | amd64) arch=amd64 ;; + aarch64 | arm64) arch=arm64 ;; + *) die "unsupported architecture: $(uname -m)" ;; +esac + +version_part="${VERSION#v}" +asset="gauntlet_${version_part}_${os}_${arch}.tar.gz" +if [ "$VERSION" = latest ]; then + base_url="https://github.com/${REPO}/releases/latest/download" + # The latest URL does not expose the version needed in the asset name. + VERSION="$(curl -fsSL -o /dev/null -w '%{url_effective}' "https://github.com/${REPO}/releases/latest" | awk -F/ '{print $NF}')" + [ -n "$VERSION" ] || die "could not resolve latest release version" + version_part="${VERSION#v}" + asset="gauntlet_${version_part}_${os}_${arch}.tar.gz" + base_url="https://github.com/${REPO}/releases/download/${VERSION}" +else + base_url="https://github.com/${REPO}/releases/download/${VERSION}" +fi + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT HUP INT TERM + +curl -fsSL "${base_url}/${asset}" -o "${tmp_dir}/${asset}" +curl -fsSL "${base_url}/checksums.txt" -o "${tmp_dir}/checksums.txt" + +expected="$(awk -v asset="$asset" '$2 == asset || $2 == "*" asset { print $1; exit }' "${tmp_dir}/checksums.txt")" +[ -n "$expected" ] || die "checksum for ${asset} not found" +if command -v sha256sum >/dev/null 2>&1; then + actual="$(sha256sum "${tmp_dir}/${asset}" | awk '{print $1}')" +elif command -v shasum >/dev/null 2>&1; then + actual="$(shasum -a 256 "${tmp_dir}/${asset}" | awk '{print $1}')" +else + die "sha256sum or shasum is required" +fi +[ "$actual" = "$expected" ] || die "checksum verification failed for ${asset}" + +tar -xzf "${tmp_dir}/${asset}" -C "$tmp_dir" +[ -f "${tmp_dir}/gauntlet" ] || die "release archive does not contain gauntlet" +mkdir -p "$INSTALL_DIR" +install -m 0755 "${tmp_dir}/gauntlet" "${INSTALL_DIR}/gauntlet" +printf 'gauntlet %s installed to %s/gauntlet\n' "$VERSION" "$INSTALL_DIR" +case ":${PATH}:" in + *":${INSTALL_DIR}:"*) ;; + *) printf 'Add %s to PATH to run gauntlet from any directory.\n' "$INSTALL_DIR" ;; +esac diff --git a/integration/install_script_test.go b/integration/install_script_test.go new file mode 100644 index 0000000..8712e64 --- /dev/null +++ b/integration/install_script_test.go @@ -0,0 +1,107 @@ +package integration + +import ( + "crypto/sha256" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestInstallScript_InstallsVerifiedPinnedRelease(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("install.sh targets POSIX systems") + } + osName, archName := installerPlatform(t) + tmp := t.TempDir() + payload := filepath.Join(tmp, "payload") + if err := os.Mkdir(payload, 0o755); err != nil { + t.Fatal(err) + } + binary := filepath.Join(payload, "gauntlet") + if err := os.WriteFile(binary, []byte("#!/bin/sh\nprintf 'installed gauntlet\\n'\n"), 0o755); err != nil { + t.Fatal(err) + } + asset := "gauntlet_0.1.0_" + osName + "_" + archName + ".tar.gz" + archive := filepath.Join(tmp, asset) + cmd := exec.Command("tar", "-czf", archive, "-C", payload, "gauntlet") + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("create fixture archive: %v\n%s", err, output) + } + archiveBytes, err := os.ReadFile(archive) + if err != nil { + t.Fatal(err) + } + checksums := filepath.Join(tmp, "checksums.txt") + line := fmt.Sprintf("%x %s\n", sha256.Sum256(archiveBytes), asset) + if err := os.WriteFile(checksums, []byte(line), 0o644); err != nil { + t.Fatal(err) + } + + fakeBin := filepath.Join(tmp, "fake-bin") + if err := os.Mkdir(fakeBin, 0o755); err != nil { + t.Fatal(err) + } + logPath := filepath.Join(tmp, "curl.log") + fakeCurl := `#!/bin/sh +set -eu +url="" +out="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) out="$2"; shift 2 ;; + -*) shift ;; + *) url="$1"; shift ;; + esac +done +printf '%s\n' "$url" >> "$FAKE_CURL_LOG" +case "$url" in + */checksums.txt) cp "$FIXTURE_CHECKSUMS" "$out" ;; + *) cp "$FIXTURE_ARCHIVE" "$out" ;; +esac +` + if err := os.WriteFile(filepath.Join(fakeBin, "curl"), []byte(fakeCurl), 0o755); err != nil { + t.Fatal(err) + } + + installDir := filepath.Join(tmp, "install") + cmd = exec.Command("sh", filepath.Join("..", "install.sh")) + cmd.Env = append(os.Environ(), + "PATH="+fakeBin+string(os.PathListSeparator)+os.Getenv("PATH"), + "GAUNTLET_VERSION=v0.1.0", + "GAUNTLET_INSTALL_DIR="+installDir, + "FIXTURE_ARCHIVE="+archive, + "FIXTURE_CHECKSUMS="+checksums, + "FAKE_CURL_LOG="+logPath, + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("install script failed: %v\n%s", err, output) + } + runOutput, err := exec.Command(filepath.Join(installDir, "gauntlet")).CombinedOutput() + if err != nil || strings.TrimSpace(string(runOutput)) != "installed gauntlet" { + t.Fatalf("installed binary: err=%v output=%q", err, runOutput) + } + logBytes, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + wantURL := "/releases/download/v0.1.0/" + asset + if !strings.Contains(string(logBytes), wantURL) { + t.Fatalf("versioned asset URL missing %q:\n%s", wantURL, logBytes) + } +} + +func installerPlatform(t *testing.T) (string, string) { + t.Helper() + if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { + t.Skipf("unsupported test OS %s", runtime.GOOS) + } + if runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" { + t.Skipf("unsupported test architecture %s", runtime.GOARCH) + } + return runtime.GOOS, runtime.GOARCH +} From bd5cc33cd3afe41f901199530c28a721052edad6 Mon Sep 17 00:00:00 2001 From: Lucas Machado Date: Fri, 10 Jul 2026 18:28:39 +0200 Subject: [PATCH 2/2] fix: allow installer shell script in structure policy --- .structlint.yaml | 1 + docs/specs/008-public-release.plan.md | 1 + 2 files changed, 2 insertions(+) diff --git a/.structlint.yaml b/.structlint.yaml index e7a7dd1..d654d51 100644 --- a/.structlint.yaml +++ b/.structlint.yaml @@ -38,6 +38,7 @@ file_naming_pattern: - "*.json" - "*.md" - "*.txt" + - "*.sh" - "*.diff" - "*.jsonl" - "Makefile" diff --git a/docs/specs/008-public-release.plan.md b/docs/specs/008-public-release.plan.md index 26332c1..eb63afc 100644 --- a/docs/specs/008-public-release.plan.md +++ b/docs/specs/008-public-release.plan.md @@ -7,6 +7,7 @@ Spec: `docs/specs/008-public-release.md` - [x] Add checksum-verifying `install.sh` and executable regression coverage. - [x] Correct README/action artifact installation examples and verify GoReleaser config. - [x] Run formatting, lint, race, and binary integration suites. +- [x] Extend Structlint's explicit naming policy to permit the release installer's `.sh` extension. - [ ] Commit, push, open the release-prep PR, and wait for PR-only validation. - [ ] Merge, make the repository public, and push annotated `v0.1.0`. - [ ] Verify release assets and the unauthenticated public installer end to end.