From a7a19602dab69d1f987156fc7d8ab7a6c27f4277 Mon Sep 17 00:00:00 2001 From: Lucas Machado Date: Fri, 10 Jul 2026 20:01:26 +0200 Subject: [PATCH 1/2] ci: standardize PR quality gates --- .gauntlet.yml | 11 +++++ .github/workflows/ci.yaml | 41 ++++++++++++------ .structlint.yaml | 55 ++++++++++++++++++++++++ README.md | 11 +++-- install.sh | 17 ++++++++ integration/install_script_test.go | 69 ++++++++++++++++++++++++++++++ 6 files changed, 188 insertions(+), 16 deletions(-) create mode 100644 .gauntlet.yml create mode 100644 .structlint.yaml create mode 100755 install.sh create mode 100644 integration/install_script_test.go diff --git a/.gauntlet.yml b/.gauntlet.yml new file mode 100644 index 0000000..e77f8fd --- /dev/null +++ b/.gauntlet.yml @@ -0,0 +1,11 @@ +custom_gates: + # Structural policy is whole-run because findings may identify paths only. + structlint: + command: ["structlint", "validate", "--format", "github"] + parser: github-annotations + line_scoped: false + # Duplication findings carry locations and are scoped to changed lines. + dupehound: + command: ["dupehound", "scan", "--format", "github", "--quiet"] + parser: github-annotations + line_scoped: true diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c3dc580..00bb303 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,17 +1,25 @@ -name: CI +name: PR on: pull_request: - push: + types: [opened, edited, synchronize, reopened] branches: - main - workflow_dispatch: permissions: contents: read + pull-requests: write jobs: - test: + pr-title: + runs-on: ubuntu-latest + steps: + - uses: AxeForging/reusable-workflows/actions/pr-title-lint@291855e85ec985d1d318df82b8f9dce969b76933 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + pr-title: ${{ github.event.pull_request.title }} + + gauntlet: runs-on: ubuntu-latest steps: @@ -25,21 +33,28 @@ jobs: with: go-version: '1.25' + - name: Build candidate and install external quality gates + run: | + make build + QUALITY_BIN="$RUNNER_TEMP/quality-bin" + mkdir -p "$QUALITY_BIN" + cp ./dist/pipekit "$QUALITY_BIN/pipekit" + curl -fsSL https://raw.githubusercontent.com/AxeForging/structlint/main/install.sh | \ + STRUCTLINT_VERSION=v0.6.0 STRUCTLINT_INSTALL_DIR="$QUALITY_BIN" sh + curl -fsSL https://raw.githubusercontent.com/AxeForging/dupehound/main/install.sh | \ + DUPEHOUND_VERSION=v0.1.0 DUPEHOUND_INSTALL_DIR="$QUALITY_BIN" sh + echo "$QUALITY_BIN" >> "$GITHUB_PATH" + + - uses: AxeForging/gauntlet@v0.1.0 + with: + since: origin/${{ github.base_ref }} + - name: Install govulncheck run: go install golang.org/x/vuln/cmd/govulncheck@latest - - name: Unit tests - run: go test ./... - - name: Vulnerability scan run: make vuln - - name: Build pipekit - run: make build - - - name: Integration tests - run: go test ./integration/... -v - - name: Export env from JSON run: | ./dist/pipekit env from-json --flatten --uppercase-keys --to-github <<'JSON' diff --git a/.structlint.yaml b/.structlint.yaml new file mode 100644 index 0000000..c450ae6 --- /dev/null +++ b/.structlint.yaml @@ -0,0 +1,55 @@ +dir_structure: + allowedPaths: + - "." + - "actions/**" + - "domain/**" + - "helpers/**" + - "integration/**" + - "services/**" + - "docs/**" + - ".github/**" + requiredPaths: + - "actions" + - "domain" + - "services" + - "integration" + - "go.mod" + - "README.md" + +file_naming_pattern: + allowed: + - "*.go" + - "*.mod" + - "*.sum" + - "*.yaml" + - "*.yml" + - "*.md" + - "*.json" + - "*.sh" + - "*.svg" + - "*.png" + - "*.gif" + - "README*" + - "LICENSE*" + - "Makefile" + - ".gitignore" + - ".goreleaser.yml" + - ".structlint.yaml" + disallowed: + - "*.env*" + - ".env*" + - "*.key" + - "*.pem" + - "*.log" + - "*.tmp" + required: + - "go.mod" + - "README.md" + - ".gitignore" + - "*.go" + +ignore: + - ".git" + - "vendor" + - "bin" + - "dist" diff --git a/README.md b/README.md index e71b176..e1dbd81 100644 --- a/README.md +++ b/README.md @@ -29,13 +29,18 @@ One static binary, no runtime deps, works the same on Linux, macOS, and Windows. ## Quick install ```sh -# Linux x86_64 -curl -L https://github.com/AxeForging/pipekit/releases/latest/download/pipekit-linux-amd64.tar.gz | tar xz -sudo mv pipekit-linux-amd64 /usr/local/bin/pipekit +curl -fsSL https://raw.githubusercontent.com/AxeForging/pipekit/main/install.sh | sh ``` +The installer verifies the archive checksum. Set `PIPEKIT_VERSION=` to pin +a release or `PIPEKIT_INSTALL_DIR` to choose the destination. + ARM64, macOS, Windows, source builds → **[docs/INSTALL.md](docs/INSTALL.md)** +PR validation runs Structlint and Dupehound through Gauntlet. Structural +findings apply to the whole run; duplication findings are scoped to changed +lines. + --- ## 30-second tour diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..ebb40e1 --- /dev/null +++ b/install.sh @@ -0,0 +1,17 @@ +#!/bin/sh +set -eu +REPO="${PIPEKIT_REPO:-AxeForging/pipekit}"; VERSION="${PIPEKIT_VERSION:-latest}" +if [ -n "${PIPEKIT_INSTALL_DIR:-}" ]; then INSTALL_DIR="$PIPEKIT_INSTALL_DIR"; elif [ -d /usr/local/bin ] && [ -w /usr/local/bin ]; then INSTALL_DIR=/usr/local/bin; else INSTALL_DIR="${HOME:?HOME is required}/.local/bin"; fi +die() { printf 'pipekit 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 +if [ "$VERSION" = latest ]; then 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"; fi +asset="pipekit-${os}-${arch}.tar.gz"; binary="pipekit-${os}-${arch}"; base="https://github.com/${REPO}/releases/download/${VERSION}" +tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT HUP INT TERM +curl -fsSL "$base/$asset" -o "$tmp/$asset"; curl -fsSL "$base/checksums.txt" -o "$tmp/checksums.txt" +expected="$(awk -v a="$asset" '$2 == a || $2 == "*" a {print $1; exit}' "$tmp/checksums.txt")"; [ -n "$expected" ] || die "checksum not found" +if command -v sha256sum >/dev/null 2>&1; then actual="$(sha256sum "$tmp/$asset" | awk '{print $1}')"; elif command -v shasum >/dev/null 2>&1; then actual="$(shasum -a 256 "$tmp/$asset" | awk '{print $1}')"; else die "sha256sum or shasum is required"; fi +[ "$actual" = "$expected" ] || die "checksum verification failed"; tar -xzf "$tmp/$asset" -C "$tmp"; [ -f "$tmp/$binary" ] || die "archive does not contain $binary" +mkdir -p "$INSTALL_DIR"; install -m 0755 "$tmp/$binary" "$INSTALL_DIR/pipekit" +printf 'pipekit %s installed to %s/pipekit\n' "$VERSION" "$INSTALL_DIR" diff --git a/integration/install_script_test.go b/integration/install_script_test.go new file mode 100644 index 0000000..1de5480 --- /dev/null +++ b/integration/install_script_test.go @@ -0,0 +1,69 @@ +package integration_test + +import ( + "crypto/sha256" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestInstallScript_InstallsVerifiedRelease(t *testing.T) { + if runtime.GOOS == "windows" || (runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64") { + t.Skip("unsupported installer platform") + } + tmp := t.TempDir() + base := "pipekit-" + runtime.GOOS + "-" + runtime.GOARCH + payload := filepath.Join(tmp, "payload") + writeInstallerExecutable(t, filepath.Join(payload, base), "#!/bin/sh\nprintf 'installed pipekit\\n'\n") + asset := base + ".tar.gz" + archive := filepath.Join(tmp, asset) + if out, err := exec.Command("tar", "-czf", archive, "-C", payload, base).CombinedOutput(); err != nil { + t.Fatalf("archive: %v %s", err, out) + } + b, err := os.ReadFile(archive) + if err != nil { + t.Fatal(err) + } + sums := filepath.Join(tmp, "checksums.txt") + if err := os.WriteFile(sums, []byte(fmt.Sprintf("%x %s\n", sha256.Sum256(b), asset)), 0o644); err != nil { + t.Fatal(err) + } + bin := filepath.Join(tmp, "bin") + fake := `#!/bin/sh +set -eu +while [ "$#" -gt 0 ]; do case "$1" in -o) out="$2"; shift 2;; -*) shift;; *) url="$1"; shift;; esac; done +printf '%s\n' "$url" >> "$CURL_LOG"; case "$url" in */checksums.txt) cp "$SUMS" "$out";; *) cp "$ARCHIVE" "$out";; esac +` + writeInstallerExecutable(t, filepath.Join(bin, "curl"), fake) + dest, log := filepath.Join(tmp, "install"), filepath.Join(tmp, "curl.log") + cmd := exec.Command("sh", filepath.Join("..", "install.sh")) + cmd.Env = append(os.Environ(), "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), "PIPEKIT_VERSION=v0.1.0", "PIPEKIT_INSTALL_DIR="+dest, "ARCHIVE="+archive, "SUMS="+sums, "CURL_LOG="+log) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("install: %v %s", err, out) + } + out, err := exec.Command(filepath.Join(dest, "pipekit")).CombinedOutput() + if err != nil || strings.TrimSpace(string(out)) != "installed pipekit" { + t.Fatalf("binary: %v %q", err, out) + } + logged, err := os.ReadFile(log) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(logged), "/releases/download/v0.1.0/"+asset) { + t.Fatalf("wrong URL: %s", logged) + } +} + +func writeInstallerExecutable(t *testing.T, path, contents string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(contents), 0o755); err != nil { + t.Fatal(err) + } +} From 40b4f7dca0d9f6bdce759e0073b65d557a6dc208 Mon Sep 17 00:00:00 2001 From: Lucas Machado Date: Fri, 10 Jul 2026 20:05:00 +0200 Subject: [PATCH 2/2] fix: use patched Go 1.25 toolchain --- .github/workflows/ci.yaml | 2 +- .github/workflows/release.yaml | 2 +- go.mod | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 00bb303..27f1a0e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -31,7 +31,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.25' + go-version: '1.25.12' - name: Build candidate and install external quality gates run: | diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 432255f..8d9f8bc 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -23,7 +23,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.25' + go-version: '1.25.12' cache: true - name: Install govulncheck diff --git a/go.mod b/go.mod index c4b9fd1..19289a3 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/AxeForging/pipekit go 1.25.0 -toolchain go1.25.11 +toolchain go1.25.12 require ( github.com/Masterminds/semver/v3 v3.4.0