diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index e0db80c..265ca65 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -6,6 +6,6 @@ ## Checklist -- [ ] `go build ./...`, `go vet ./...`, and `go test ./...` pass +- [ ] `go -C src build ./...`, `go -C src vet ./...`, and `go -C src test ./...` pass - [ ] Added/updated tests for new logic - [ ] Updated docs / CHANGELOG where relevant diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b9f9832..28154d9 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,7 +1,7 @@ version: 2 updates: - package-ecosystem: gomod - directory: "/" + directory: "/src" schedule: interval: weekly day: monday @@ -24,14 +24,3 @@ updates: github-actions-minor-and-patch: patterns: ["*"] update-types: [minor, patch] - - package-ecosystem: docker - directory: / - schedule: - interval: weekly - day: monday - time: "06:00" - timezone: America/New_York - groups: - docker-minor-and-patch: - patterns: ["*"] - update-types: [minor, patch] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5ecf96..071a16c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,9 +51,12 @@ jobs: with: go-version: "1.25" check-latest: true - - run: go build ./... - - run: go vet ./... - - run: go test -race -coverprofile=coverage.txt ./... + cache-dependency-path: src/go.sum + # The module lives in src/: a bare `go test ./...` at the root finds no + # packages and passes, so every command names it. + - run: go -C src build ./... + - run: go -C src vet ./... + - run: go -C src test -race -coverprofile=../coverage.txt ./... lint: runs-on: ubuntu-latest @@ -62,9 +65,11 @@ jobs: - uses: actions/setup-go@v7 with: go-version: "1.25" + cache-dependency-path: src/go.sum - uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: version: latest + working-directory: src govulncheck: runs-on: ubuntu-latest @@ -73,10 +78,11 @@ jobs: - uses: actions/setup-go@v7 with: go-version: "1.25" + cache-dependency-path: src/go.sum # Pinned: govulncheck @latest (v1.4.0) segfaults under the go1.25 toolchain. - run: go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 - run: | - "$(go env GOPATH)/bin/govulncheck" ./... + cd src && "$(go env GOPATH)/bin/govulncheck" ./... gosec: runs-on: ubuntu-latest @@ -85,6 +91,7 @@ jobs: - uses: actions/setup-go@v7 with: go-version: "1.25" + cache-dependency-path: src/go.sum # Build gosec with the runner's Go — the gosec docker action bundles an # older Go that cannot load a `go 1.24` module. # probaci is a CI orchestrator: it runs subprocesses (G204) and reads/writes @@ -96,17 +103,23 @@ jobs: # newest release whose go.mod accepts 1.25; bump with the go-version. - run: go install github.com/securego/gosec/v2/cmd/gosec@v2.29.0 - run: | - "$(go env GOPATH)/bin/gosec" -exclude=G104,G204,G301,G302,G304,G306 ./... + cd src && "$(go env GOPATH)/bin/gosec" -exclude=G104,G204,G301,G302,G304,G306 ./... gitleaks: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version: "1.25" + cache-dependency-path: src/go.sum # Run gitleaks directly (the gitleaks-action requires a paid license for - # org repos). Scans the working tree and honors .gitleaks.toml. + # org repos). Scans the working tree and honors .gitleaks.toml. Built from + # source at a named release rather than run from a container image, which + # this repository no longer uses anywhere. + - run: go install github.com/zricethezav/gitleaks/v8@v8.30.1 - run: | - docker run --rm -v "${{ github.workspace }}:/repo" -w /repo \ - zricethezav/gitleaks:latest dir . --no-banner --redact --exit-code 1 + "$(go env GOPATH)/bin/gitleaks" dir . --no-banner --redact --exit-code 1 # Dogfood: probaci proves itself. Scoped to the platform/security stages that # aren't already covered by the dedicated jobs above (lint/govulncheck/gosec/ @@ -118,4 +131,35 @@ jobs: - uses: actions/setup-go@v7 with: go-version: "1.25" - - run: go run ./cmd/probaci run --ci --only detect,workflow-lint,versions,secrets + cache-dependency-path: src/go.sum + # go -C src starts probaci inside src/; it walks up to the git root and + # proves the whole repository, as it did from the root. + - run: go -C src run ./cmd/probaci run --ci --only detect,workflow-lint,versions,secrets + + # Cross-compile every target into build// on every pull request (and on + # demand) and attach the binaries to the run, so a reviewer can try the exact + # binaries before merging. It builds with src/scripts/build-all.sh and + # publishes nothing; no GoReleaser runs here. `make cross` ends with + # `make check-build`, so a stray file in build/ fails the job. + snapshot: + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: [test, lint] + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - uses: actions/setup-go@v7 + with: + go-version: "1.25" + cache-dependency-path: src/go.sum + - run: make cross + - run: cd build && sha256sum -c checksums.txt + - uses: actions/upload-artifact@v7 + with: + name: "probaci-snapshot-${{ github.event.pull_request.number || github.run_id }}" + if-no-files-found: error + retention-days: 14 + path: | + build/*/probaci_* + build/checksums.txt diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 77a9c22..1bafc16 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -18,8 +18,15 @@ jobs: actions: read steps: - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version: "1.25" + cache-dependency-path: src/go.sum + # The module lives in src/, which autobuild does not look for: manual mode + # builds it explicitly, so the scan never reports clean on zero code. - uses: github/codeql-action/init@v4 with: languages: go - - uses: github/codeql-action/autobuild@v4 + build-mode: manual + - run: go -C src build ./... - uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 80f2c78..2d82eee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,7 +2,7 @@ name: release # Every release is tag-driven: GoReleaser cross-compiles the full matrix, builds # the archives/packages and a reproducible source tarball, generates SBOMs and -# checksums, pushes the container image, cosign-signs the checksums, creates the +# checksums, cosign-signs the checksums, creates the # GitHub Release (body from the tagged CHANGELOG section), and updates the # Homebrew/Scoop manifests. A build-provenance attestation is then signed for # every artifact via the checksums file. @@ -32,6 +32,12 @@ concurrency: permissions: contents: read +# Our own modules are fetched from git, never through proxy.golang.org or +# sum.golang.org, so the build itself is not what feeds the public proxy a tag. +env: + GOPRIVATE: github.com/simtabi/* + GONOSUMDB: github.com/simtabi/* + jobs: goreleaser: runs-on: ubuntu-latest @@ -41,7 +47,6 @@ jobs: contents: write # create the GitHub Release, upload assets id-token: write # cosign keyless signing + provenance via OIDC attestations: write # write the build-provenance attestation - packages: write # push the container image steps: - name: Refuse without confirmation or a release tag env: @@ -62,27 +67,38 @@ jobs: - uses: actions/setup-go@v7 with: go-version: "1.25" + cache-dependency-path: src/go.sum - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 # syft powers GoReleaser's SBOM generation. - uses: anchore/sbom-action/download-syft@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3 - # QEMU + buildx build the multi-arch dockers_v2 images; login pushes to ghcr. - - uses: docker/setup-qemu-action@v4 - - uses: docker/setup-buildx-action@v4 - - name: Log in to ghcr.io - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + # `go install` needs a tag named after the module's subdirectory: the + # module is github.com/simtabi/probaci/src, so Go resolves src/vX.Y.Z and + # ignores vX.Y.Z. OSS GoReleaser rejects the prefixed tag as non-semver, + # so the release runs off vX.Y.Z and mirrors src/vX.Y.Z onto the same + # commit here, where it cannot be forgotten. This workflow starts on no + # tag push, so pushing src/... starts no second run. A re-run finds the + # tag already there and leaves it alone: a public tag is never moved. + - name: Mirror the tag Go resolves the module through + run: | + alias_tag="src/${GITHUB_REF_NAME}" + if git rev-parse -q --verify "refs/tags/${alias_tag}" >/dev/null; then + echo "${alias_tag} already exists" + else + git tag "${alias_tag}" "${GITHUB_REF_NAME}^{commit}" + git push origin "refs/tags/${alias_tag}" + echo "pushed ${alias_tag}" + fi + # The notes go to $RUNNER_TEMP, never the checkout: an untracked file in + # the working tree makes GoReleaser refuse to release a dirty tree. - name: Extract CHANGELOG section for this tag - run: ./scripts/extract-changelog.sh "${GITHUB_REF_NAME}" > release_notes.md + run: ./src/scripts/extract-changelog.sh "${GITHUB_REF_NAME}" > "$RUNNER_TEMP/release_notes.md" - name: Run GoReleaser uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7.3.0 with: # Pin the GoReleaser series so the config schema and the tool stay in # sync (avoids `latest` deprecating config keys out from under a release). version: "~> v2.4" - args: release --clean --release-notes=release_notes.md + args: release --clean --config src/.goreleaser.yaml --release-notes=${{ runner.temp }}/release_notes.md env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # PAT with write access to the Homebrew tap + Scoop bucket repos. @@ -97,4 +113,4 @@ jobs: - name: Attest build provenance uses: actions/attest-build-provenance@v4 with: - subject-checksums: build/dist/checksums.txt + subject-checksums: .cache/goreleaser/checksums.txt diff --git a/.gitignore b/.gitignore index a96c080..46a10b8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,14 @@ /probaci /bin/ /dist/ -/build/dist/ +# Generated build output. build/ keeps its tracked inputs (targets.txt, and +# nfpm.yaml if one is added); everything else in it is generated, one folder +# per OS (see the Makefile). +/build/* +!/build/targets.txt +!/build/nfpm.yaml +# GoReleaser's scratch dist (src/.goreleaser.yaml `dist:`). +/.cache/ *.exe *.test *.out diff --git a/CHANGELOG.md b/CHANGELOG.md index 6568911..604755c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,31 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). if an automatic trigger returns. - The README and installation guide build probaci from source only: no download, install-script, package-manager or `go install` path is offered while binary releases are on hold. +- The Go module moved into `src/`, and its path is now `github.com/simtabi/probaci/src`. Importers + of `pkg/probaci` use `github.com/simtabi/probaci/src/pkg/probaci`. A release tags `src/vX.Y.Z` + beside `vX.Y.Z` on the same commit, since that is the tag Go resolves the module through. +- `make build` writes `build//probaci` (macOS as `macos`) instead of `bin/probaci`. + `make cross` and `make dist` write one folder per OS under `build/`, with + `build/checksums.txt` over `{os}/{file}` paths and archives only in `build/archives/`; + `build/dist/` is gone, and GoReleaser's scratch output moved to `.cache/goreleaser/`. +- The Makefile is the one task definition, and every Taskfile task forwards to it. +- `probaci docs` reads a copy of `README.md` and `docs/` kept in `src/embedded/` (`make sync-docs`), + because the module in `src/` cannot embed files above it; a test fails when the copy drifts. +- The CI gitleaks scan runs a gitleaks v8.30.1 binary instead of the `zricethezav/gitleaks` image. + +### Added + +- Tests that fail when `build/` holds anything but OS folders, inputs and checksums, when a + container image or docker step returns, and when the docs offer `go install`. The release + trigger test now also requires the refusal to be the workflow's first step. +- A CI `snapshot` job that runs `make cross` on every pull request and uploads the binaries. + +### Removed + +- The container image. The `Dockerfile`, the GoReleaser `dockers_v2` block + (`ghcr.io/simtabi/probaci`, linux/amd64 and linux/arm64), the release workflow's QEMU, buildx + and ghcr.io login steps and its `packages: write` permission, and Dependabot's docker ecosystem + are gone. No image was ever published, so nothing on ghcr.io is affected. ## [0.1.0] - 2026-06-18 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f2b3415..35aad4a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,27 +9,39 @@ Thanks for your interest in improving probaci. ```sh git clone https://github.com/simtabi/probaci cd probaci -go build ./... # or: task build / make build (version-stamped) -go test ./... -go vet ./... -golangci-lint run # or: task lint +make build # build//probaci, version-stamped (or: task build) +make test # go -C src test -race ./... +make vet +make lint # golangci-lint, run in src/ ``` +The Go module lives in `src/`, so a bare `go test ./...` at the repository root +finds no packages and passes; name the module with `go -C src …`. `build/` holds +one folder per OS and nothing else, and `make check-build` fails on anything +else. + probaci dogfoods itself — once built, run it on the repo: ```sh -go run ./cmd/probaci run --skip workflow-run +go -C src run ./cmd/probaci run --skip workflow-run ``` -Build local release deliverables and validate the release config: +Cross-compile and validate the release config (none of these publishes): ```sh -task bundle # dist/ archives + checksums (no publish) -task release-check # goreleaser check (uses the pinned GoReleaser series) +make cross # build// binaries + build/checksums.txt +make dist # the same, plus build/archives/ +make release-check # goreleaser check --config src/.goreleaser.yaml ``` +`probaci docs` renders an embedded copy of `README.md` and `docs/` from +`src/embedded/`. After editing either, run `make sync-docs`; a test fails when +the copy drifts. + ## Project layout +Everything Go lives in `src/`: + - `cmd/probaci` — entrypoint - `internal/cli` — Cobra commands (thin; parse flags, drive the engine) - `internal/stage` — the pipeline engine and stage implementations @@ -40,8 +52,10 @@ task release-check # goreleaser check (uses the pinned GoReleaser series) - `internal/config`, `paths`, `logging`, `secret`, `ui`, `report`, `tui`, `run` (RunID), `discover` (repo-root walk-up) - `pkg/probaci` — the public, embeddable API -- `install.sh` / `install.ps1` — end-user installers (keep asset names in sync - with the GoReleaser archive `name_template`) +- `internal/release` — tests guarding the release trigger, the `build/` layout, + the absence of a container image, and the docs +- `scripts/install.sh` / `scripts/install.ps1` — end-user installers (keep asset + names in sync with the GoReleaser archive `name_template`) ## Adding things diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 57ef23b..0000000 --- a/Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -# Minimal runtime image for probaci. GoReleaser builds the binary and copies it -# into the build context; this just wraps it. probaci itself brokers other tools -# through the host container runtime, so this image is for running probaci as a -# command, not for nested builds. -FROM gcr.io/distroless/static:nonroot -COPY probaci /usr/bin/probaci -ENTRYPOINT ["/usr/bin/probaci"] diff --git a/Makefile b/Makefile index 0d23b44..7913d93 100644 --- a/Makefile +++ b/Makefile @@ -1,48 +1,103 @@ -# Thin wrapper over Taskfile.yml — the real task definitions live there -# (one source of truth). When go-task is installed, every target just delegates -# to `task `; otherwise it falls back to the Go toolchain directly, so -# `make build` works with nothing but Go installed. - -TASK := $(shell command -v task 2>/dev/null) -BIN := bin/probaci -PKG := ./cmd/probaci - -VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) -LDFLAGS := -s -w -X github.com/simtabi/probaci/internal/version.Version=$(VERSION) - -.PHONY: build install test cover vet lint fmt tidy bundle release-check snapshot clean help - -ifeq ($(TASK),) -# --- fallback: invoke the Go toolchain directly --- -build: - go build -ldflags "$(LDFLAGS)" -o $(BIN) $(PKG) -install: - go install $(PKG) -test: - go test -race ./... -cover: - go test -coverprofile=coverage.txt ./... -vet: - go vet ./... -fmt: - gofmt -w . -tidy: - go mod tidy -lint: - golangci-lint run -release-check: - goreleaser check -bundle: - goreleaser release --snapshot --clean --skip=publish,sign,docker,sbom -snapshot: bundle -clean: - rm -rf bin dist coverage.txt -else -# --- delegate to Taskfile.yml (single source of truth) --- -build install test cover vet lint fmt tidy bundle release-check snapshot clean: - @$(TASK) $@ -endif - -help: - @echo "probaci make targets (delegate to Taskfile.yml when go-task is present):" - @echo " build install test cover vet lint fmt tidy bundle release-check snapshot clean" +# probaci -- Makefile. Run `make help`. This is the one definition of every +# task; Taskfile.yml forwards to it for anyone who prefers `task `. +# +# The Go module lives in src/, so every go command names it with -C src. A bare +# `go test ./...` at the root finds no packages and passes. +# +# build/ holds one folder per OS and nothing else (build/macos, build/linux, +# build/windows, plus targets.txt, checksums.txt and, after `make dist`, +# archives/). The host build is build//probaci, macOS written as macos +# and .exe on Windows. `make check-build` (TestBuildLayout) fails on anything +# else. +# +# Nothing here publishes. Releases are dispatched by hand (docs/release.md). + +SHELL := /usr/bin/env bash +.DEFAULT_GOAL := help + +BINARY := probaci +PKG := github.com/simtabi/probaci/src/internal/version +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none) +DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) +LDFLAGS := -s -w -X $(PKG).Version=$(VERSION) -X $(PKG).Commit=$(COMMIT) -X $(PKG).Date=$(DATE) + +HOST_GOOS := $(shell go env GOOS) +HOST_OS := $(if $(filter darwin,$(HOST_GOOS)),macos,$(HOST_GOOS)) +EXE := $(if $(filter windows,$(HOST_GOOS)),.exe,) +BIN := build/$(HOST_OS)/$(BINARY)$(EXE) + +.PHONY: help build build-local install run test cover vet fmt lint tidy cross dist check-build \ + sync-docs release-check bundle snapshot clean + +help: ## Show this help + @grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) \ + | awk 'BEGIN{FS=":.*?## "}; {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}' + +build: build-local ## Build $(BIN) (the same as build-local) + +build-local: ## Build $(BIN) with the host's Go toolchain + CGO_ENABLED=0 go -C src build -trimpath -ldflags "$(LDFLAGS)" -o ../$(BIN) ./cmd/$(BINARY) + @$(MAKE) --no-print-directory check-build + +install: ## Install probaci into GOBIN from this checkout (go -C src install) + go -C src install -ldflags "$(LDFLAGS)" ./cmd/$(BINARY) + +run: build-local ## Build, then run the binary (pass ARGS="...") + ./$(BIN) $(ARGS) + +test: ## Run the tests with the race detector (includes the release, build-layout and no-container guards) + go -C src test -race ./... -count=1 + +cover: ## Run the tests with coverage + go -C src test -coverprofile=../coverage.txt ./... + go -C src tool cover -func=../coverage.txt | tail -1 + +vet: ## go vet + go -C src vet ./... + +fmt: ## gofmt + gofmt -w src + +lint: ## golangci-lint (must be installed) + cd src && golangci-lint run ./... + +tidy: ## go mod tidy + go -C src mod tidy + +cross: ## Cross-compile every target in build/targets.txt into build// + ARCHIVES=0 VERSION=$(VERSION) COMMIT=$(COMMIT) DATE=$(DATE) ./src/scripts/build-all.sh + @$(MAKE) --no-print-directory check-build + +dist: ## cross, plus .tar.gz/.zip archives in build/archives/ (publishes nothing) + ARCHIVES=1 VERSION=$(VERSION) COMMIT=$(COMMIT) DATE=$(DATE) ./src/scripts/build-all.sh + @$(MAKE) --no-print-directory check-build + +check-build: ## Fail when build/ holds anything but the OS folders, inputs, checksums.txt and archives/ + go -C src test ./internal/release -run 'TestBuildLayout$$' -count=1 + +# The module in src/ cannot embed files above it, so `probaci docs` reads a copy +# of README.md and docs/ in src/embedded/. TestEmbeddedDocsMatchTheRepository +# fails when the copy drifts; this refreshes it. +sync-docs: ## Copy README.md and docs/ into src/embedded/ for `probaci docs` + rm -rf src/embedded + mkdir -p src/embedded/docs/tools + cp README.md src/embedded/ + cp docs/*.md src/embedded/docs/ + cp docs/tools/*.md src/embedded/docs/tools/ + +release-check: ## Validate the GoReleaser configuration (a check only; builds nothing) + goreleaser check --config src/.goreleaser.yaml + +# A local GoReleaser snapshot, kept for checking the release config end to end. +# It writes into GoReleaser's scratch dist (.cache/goreleaser), never build/, +# and skips every publishing step. +bundle: ## Local GoReleaser snapshot into .cache/goreleaser (publishes nothing) + goreleaser release --config src/.goreleaser.yaml --snapshot --clean --skip=publish,sign,sbom + +snapshot: bundle ## Alias for bundle + +clean: ## Remove generated output (build/ inputs are kept) and GoReleaser's scratch dist + rm -rf bin .cache coverage.txt \ + build/macos build/linux build/windows build/freebsd build/openbsd build/netbsd \ + build/archives build/checksums.txt diff --git a/README.md b/README.md index 89782a4..9b4f2fa 100644 --- a/README.md +++ b/README.md @@ -40,12 +40,12 @@ probaci is built to catch the common causes automatically: probaci is built from source: binary releases are on hold, so there is no download, install-script, package-manager or `go install` channel to use. You need -Go (the version `go.mod` names), `git` and `make`. +Go (the version `src/go.mod` names), `git` and `make`. ```sh git clone https://github.com/simtabi/probaci ~/src/probaci -make -C ~/src/probaci build # -> bin/probaci -~/src/probaci/bin/probaci install # copies itself onto PATH (~/.local/bin) +make -C ~/src/probaci build # -> build//probaci (macos, linux or windows) +~/src/probaci/build/*/probaci install # copies itself onto PATH (~/.local/bin) probaci doctor ``` diff --git a/Taskfile.yml b/Taskfile.yml index a50ecdc..d52f579 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -1,86 +1,78 @@ -# Canonical dev tasks for probaci. Run with `task ` (https://taskfile.dev). -# The Makefile forwards to these, and `go build`/`go install` work standalone — -# one definition, three entrypoints. +# Dev tasks for probaci, for anyone who prefers `task ` +# (https://taskfile.dev). Every task forwards to the Makefile, which is the one +# definition: the module lives in src/ and build/ holds one folder per OS, and +# keeping those rules in one file is what stops the two from drifting. version: "3" -vars: - BIN: bin/probaci - PKG: ./cmd/probaci - MODULE: github.com/simtabi/probaci - # Version stamp for non-GoReleaser builds (falls back to "dev" outside git). - VERSION: - sh: git describe --tags --always --dirty 2>/dev/null || echo dev - LDFLAGS: -s -w -X github.com/simtabi/probaci/internal/version.Version={{.VERSION}} - tasks: default: cmds: [task: build] build: - desc: Build the probaci binary into ./bin (version-stamped) - cmds: - - go build -ldflags "{{.LDFLAGS}}" -o {{.BIN}} {{.PKG}} + desc: Build build//probaci (version-stamped) + cmds: [make build] install: - desc: Install probaci into GOBIN/PATH via the Go toolchain - cmds: - - go install {{.PKG}} + desc: Install probaci into GOBIN from this checkout + cmds: [make install] run: desc: Run probaci from source (pass args after --) cmds: - - go run {{.PKG}} {{.CLI_ARGS}} + - go -C src run ./cmd/probaci {{.CLI_ARGS}} test: desc: Run the test suite with the race detector - cmds: - - go test -race ./... + cmds: [make test] cover: desc: Run tests with coverage - cmds: - - go test -coverprofile=coverage.txt ./... - - go tool cover -func=coverage.txt | tail -1 + cmds: [make cover] vet: desc: Run go vet - cmds: - - go vet ./... + cmds: [make vet] lint: desc: Run golangci-lint (must be installed) - cmds: - - golangci-lint run + cmds: [make lint] fmt: desc: Format the codebase - cmds: - - gofmt -w . + cmds: [make fmt] tidy: - desc: Tidy go.mod/go.sum - cmds: - - go mod tidy + desc: Tidy src/go.mod and src/go.sum + cmds: [make tidy] + + cross: + desc: Cross-compile every target in build/targets.txt into build// + cmds: [make cross] + + dist: + desc: cross, plus archives in build/archives/ (publishes nothing) + cmds: [make dist] + + check-build: + desc: Fail when build/ holds anything but the OS folders, inputs and checksums + cmds: [make check-build] + + sync-docs: + desc: Copy README.md and docs/ into src/embedded/ for `probaci docs` + cmds: [make sync-docs] release-check: desc: Validate the GoReleaser configuration - cmds: - - goreleaser check + cmds: [make release-check] bundle: - desc: Build local release deliverables (archives + checksums) into ./dist - cmds: - # Skip release-only steps (publish/sign/docker/sbom) so this runs offline - # with no OIDC/cosign/registry/syft needed; produces dist/*.tar.gz, *.zip, - # and checksums.txt. - - goreleaser release --snapshot --clean --skip=publish,sign,docker,sbom + desc: Local GoReleaser snapshot into .cache/goreleaser (publishes nothing) + cmds: [make bundle] snapshot: - desc: Alias for `bundle` (local multi-arch deliverables, no publish) - cmds: - - task: bundle + desc: Alias for `bundle` + cmds: [make snapshot] clean: desc: Remove build artifacts - cmds: - - rm -rf bin dist coverage.txt + cmds: [make clean] diff --git a/assets_test.go b/assets_test.go deleted file mode 100644 index debfa66..0000000 --- a/assets_test.go +++ /dev/null @@ -1,21 +0,0 @@ -package probaci - -import "testing" - -func TestDocLookup(t *testing.T) { - for _, topic := range DocTopics() { - if _, ok := Doc(topic); !ok { - t.Errorf("topic %q should resolve to an embedded doc", topic) - } - } - // Default + aliases. - if md, ok := Doc(""); !ok || md == "" { - t.Fatal("empty topic should default to overview") - } - if _, ok := Doc("config"); !ok { - t.Fatal("alias 'config' should resolve") - } - if _, ok := Doc("nope"); ok { - t.Fatal("unknown topic should not resolve") - } -} diff --git a/build/targets.txt b/build/targets.txt index 14714ae..8b56c8b 100644 --- a/build/targets.txt +++ b/build/targets.txt @@ -1,8 +1,8 @@ -# Release targets for scripts/build-all.sh — "GOOS GOARCH [GOARM]". -# Regenerated on every build. GoReleaser (.goreleaser.yaml) is the canonical -# release path; this drives the hand-rolled fallback. Names use {os}_{arch} with -# darwin → macos, arm → armvN, and .exe on windows; a macos_universal (Intel + -# Apple Silicon) fat binary is produced on macOS. +# Release targets for src/scripts/build-all.sh -- "GOOS GOARCH [GOARM]". +# GoReleaser (src/.goreleaser.yaml) is the release path; this drives `make cross` +# and `make dist`. Output goes to build// with names {binary}_{os}_{arch}, +# darwin written as macos, arm as armvN, and .exe on windows; a macos_universal +# (Intel + Apple Silicon) binary is produced on macOS. linux amd64 linux 386 linux arm64 diff --git a/docs/architecture.md b/docs/architecture.md index 3c42b14..f8dca03 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -33,6 +33,11 @@ runs inside a pinned container. ## Packages +The Go module lives in `src/` (module path `github.com/simtabi/probaci/src`); +the paths below are relative to it. The module root package embeds a copy of +`README.md` and `docs/` from `src/embedded/` for `probaci docs`, refreshed by +`make sync-docs`. + | Package | Responsibility | |---------|----------------| | `internal/cli` | Cobra commands; thin — parse flags, drive the engine | diff --git a/docs/installation.md b/docs/installation.md index 80d282f..550ae99 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -8,25 +8,27 @@ your VCS. ## Build from source -You need Go (the version `go.mod` names), `git`, and `make` (or +You need Go (the version `src/go.mod` names), `git`, and `make` (or [go-task](https://taskfile.dev)). ```sh git clone https://github.com/simtabi/probaci ~/src/probaci -make -C ~/src/probaci build # -> bin/probaci -~/src/probaci/bin/probaci install # copies itself to ~/.local/bin (Windows: %LOCALAPPDATA%\Programs\probaci) +make -C ~/src/probaci build # -> build//probaci (macos, linux or windows) +~/src/probaci/build/*/probaci install # copies itself to ~/.local/bin (Windows: %LOCALAPPDATA%\Programs\probaci) probaci doctor ``` `probaci install --system` installs machine-wide (`/usr/local/bin`, `%ProgramFiles%`, needs elevation), `--dir DIR` picks an explicit directory, and -`probaci uninstall [--purge]` removes it again. Copying `bin/probaci` onto your -`PATH` by hand works just as well. +`probaci uninstall [--purge]` removes it again. Copying `build//probaci` onto +your `PATH` by hand works just as well. `` is the host's OS, written `macos` +rather than `darwin`. -Without `make`, the dependency-free primitive is -`go -C ~/src/probaci build -o bin/probaci ./cmd/probaci`. On Windows, run the same -`git clone` and that `go build` (with `-o bin\probaci.exe`) from PowerShell, then -run `bin\probaci.exe install`. +The Go module lives in `src/`, so without `make` the dependency-free primitive is +`go -C ~/src/probaci/src build -o ../build/linux/probaci ./cmd/probaci` (name the +folder for your OS). On Windows, run the same `git clone` and +`go -C src build -o ..\build\windows\probaci.exe ./cmd/probaci` from PowerShell +in the checkout, then run `build\windows\probaci.exe install`. ### Pinning a version @@ -44,7 +46,7 @@ make -C ~/src/probaci build ```sh git -C ~/src/probaci pull --ff-only make -C ~/src/probaci build -~/src/probaci/bin/probaci install +~/src/probaci/build/*/probaci install ``` `probaci version` reports the version the build was stamped with (`git describe`), @@ -53,9 +55,10 @@ so a build from a branch is still identifiable. ### Binary releases No binary release has been published. The release tooling stays in the tree: -the GoReleaser config, and `scripts/install.sh` / `scripts/install.ps1`, which +the GoReleaser config (`src/.goreleaser.yaml`), and `src/scripts/install.sh` / +`src/scripts/install.ps1`, which download release archives. The release workflow would also publish Homebrew and -Scoop manifests, deb/rpm packages and a `ghcr.io/simtabi/probaci` container image. +Scoop manifests and deb/rpm packages. probaci no longer builds a container image. None of those channels is offered while binary releases are on hold; see [release.md](release.md). @@ -91,16 +94,18 @@ machine-wide install (`probaci install --system`, which needs elevation). ## Development builds -One task definition, three entrypoints: +The Makefile is the one task definition; `task ` (Taskfile.yml) forwards +to it. The Go module lives in `src/`, and `build/` holds one folder per OS: ```sh -task build # go-task (Taskfile.yml) -make build # delegates to task, or falls back to go -go build -o bin/probaci ./cmd/probaci # the dependency-free primitive +make build # build//probaci, e.g. build/macos/probaci +make cross # every target in build/targets.txt into build//, plus build/checksums.txt +make dist # make cross, plus .tar.gz/.zip archives in build/archives/ +go -C src build -o ../build/linux/probaci ./cmd/probaci # the dependency-free primitive ``` -Produce local release deliverables (archives + checksums in `dist/`) with -`task bundle`. See [release.md](release.md). +Verify a cross build with `cd build && shasum -a 256 -c checksums.txt`. None of +these publishes anything. See [release.md](release.md). --- diff --git a/docs/release.md b/docs/release.md index 35be87f..0d45eb4 100644 --- a/docs/release.md +++ b/docs/release.md @@ -10,40 +10,48 @@ sync. ## Local deliverables (no publishing) -Build the downloadable archives locally — useful for testing the binaries users -will get: +Build the binaries and archives locally, useful for testing what users would +get: ```sh -task bundle # or: make bundle +make cross # build//probaci__[.exe] plus build/checksums.txt +make dist # the same, plus archives and their checksums in build/archives/ ``` -This runs `goreleaser release --snapshot --clean --skip=publish,sign,docker,sbom` -and writes `dist/probaci___.{tar.gz,zip}` plus `checksums.txt` -(no OIDC/cosign/registry/syft needed). Validate the config with -`task release-check` (`goreleaser check`). +Both run `src/scripts/build-all.sh`, never GoReleaser, and publish nothing. +`build/` holds one folder per OS (`macos`, never `darwin`) and nothing else; +`make check-build` fails on anything else, and +`cd build && shasum -a 256 -c checksums.txt` verifies the binaries. + +To exercise the GoReleaser config end to end, `make bundle` runs +`goreleaser release --config src/.goreleaser.yaml --snapshot --clean --skip=publish,sign,sbom` +into GoReleaser's scratch dist, `.cache/goreleaser/` (gitignored, never +`build/`), with no OIDC/cosign/registry/syft needed. Validate the config with +`make release-check` (`goreleaser check --config src/.goreleaser.yaml`). ## Install scripts -`install.sh` / `install.ps1` (repo root) download the matching release archive, +`src/scripts/install.sh` / `src/scripts/install.ps1` download the matching release archive, verify its sha256 against `checksums.txt`, and install onto PATH. Their asset names must match the GoReleaser archive `name_template` (`probaci___`); keep them in sync. Test a script against a local bundle without publishing: ```sh -task bundle -PROBACI_VERSION=0.0.0-SNAPSHOT- PROBACI_BASE_URL="file://$PWD/dist" \ - PROBACI_INSTALL_DIR=/tmp/probaci sh install.sh +make bundle +PROBACI_VERSION=0.0.0-SNAPSHOT- PROBACI_BASE_URL="file://$PWD/.cache/goreleaser" \ + PROBACI_INSTALL_DIR=/tmp/probaci sh src/scripts/install.sh ``` ## Pinning tool-image digests -The built-in tool images ship pinned by digest (`internal/tool/digests.json`, +The built-in tool images ship pinned by digest (`src/internal/tool/digests.json`, embedded into the binary). Refresh the pins periodically — run in a connected environment with a container runtime + python3 and a built `probaci`: ```sh -PROBACI=./bin/probaci ./scripts/pin-digests.sh > internal/tool/digests.json +make build +PROBACI="$(ls build/*/probaci)" ./src/scripts/pin-digests.sh > src/internal/tool/digests.json ``` Images that fail to pull are reported and left unpinned (advisory). Commit the @@ -70,7 +78,8 @@ regenerated `digests.json`. tag, before anything is built. 4. The `release` workflow runs GoReleaser, which builds the matrix, signs with cosign (keyless via OIDC), attaches an SBOM and SLSA provenance, and publishes - the GitHub Release, container image, deb/rpm, and Homebrew/Scoop manifests. + the GitHub Release, deb/rpm, and Homebrew/Scoop manifests. No container image + is built. ## Build matrix @@ -93,7 +102,15 @@ repo). Run it before every release. ## Version stamping The binary embeds version metadata via `-ldflags` into -`internal/version`; `probaci version` prints it. +`github.com/simtabi/probaci/src/internal/version`; `probaci version` prints it. + +## Module path and tags + +The Go module lives in `src/`, so its path is `github.com/simtabi/probaci/src`. +Go resolves a subdirectory module only through a tag named after the directory, +so every release carries two tags on the same commit: `vX.Y.Z`, which the release +runs from, and `src/vX.Y.Z`. The release workflow creates the `src/` tag itself +before GoReleaser runs; neither tag is ever moved. --- diff --git a/release_workflow_test.go b/release_workflow_test.go deleted file mode 100644 index 0dbcd0d..0000000 --- a/release_workflow_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package probaci - -import ( - "os" - "path/filepath" - "regexp" - "strings" - "testing" -) - -// Go releases are git only until the owner says otherwise, so release.yml must -// never start on its own: no push trigger of any kind, and a dispatch that -// refuses to publish without confirm=release-go. A tag push that started -// GoReleaser is how an unwanted release began elsewhere on 2026-11-02. -func TestReleaseRunsOnlyOnAConfirmedDispatch(t *testing.T) { - wf, err := os.ReadFile(filepath.Join(".github", "workflows", "release.yml")) - if err != nil { - t.Fatal(err) - } - if regexp.MustCompile(`(?m)^[ \t]*(push|schedule|pull_request|release)\s*:`).Match(wf) { - t.Error("release.yml has an automatic trigger; it must run on workflow_dispatch only") - } - for _, want := range []string{"workflow_dispatch:", "confirm:", `"${CONFIRM}" != "release-go"`, "refs/tags/v[0-9]*"} { - if !strings.Contains(string(wf), want) { - t.Errorf("release.yml lacks %q: a release needs an explicit, confirmed dispatch on a tag", want) - } - } -} diff --git a/scripts/build-all.sh b/scripts/build-all.sh deleted file mode 100755 index dce80d1..0000000 --- a/scripts/build-all.sh +++ /dev/null @@ -1,170 +0,0 @@ -#!/usr/bin/env bash -# -# Hand-rolled cross-compile build (GoReleaser is the canonical release path). -# Regenerates build/targets.txt from an embedded matrix and produces, under -# build/dist/: -# -# probaci_{os}_{arch}[.exe] flat, versionless, ready-to-run binaries -# probaci_macos_universal Intel + Apple Silicon fat binary (on macOS) -# checksums.txt SHA-256 over the bare binaries (bare names) -# archives/probaci_{os}_{arch}.{tar.gz,zip} binary + LICENSE/README/CHANGELOG -# archives/checksums.txt SHA-256 over the archives -# -# Naming: darwin -> macos, arm -> armvN, .exe on windows. Static -# (CGO_ENABLED=0), reproducible (-trimpath), stripped (-s -w), with version -# metadata embedded. -# -# The whole build/ folder is DELETED and REGENERATED on every run — its files -# (targets.txt and all outputs) are rewritten from scratch, so nothing stale -# from a removed/renamed target or a previous run ever survives. -# -# VERSION=v1.2.3 ./scripts/build-all.sh # normal -# ARCHIVES=0 ./scripts/build-all.sh # binaries only, skip archives -# TARGETS=path/to/list ./scripts/build-all.sh # use an external target list -# -# Portable to bash 3.2 (the macOS system bash): no mapfile / wait -n / assoc arrays. -set -euo pipefail - -cd "$(dirname "$0")/.." - -BINARY="probaci" -PKG="github.com/simtabi/probaci/internal/version" -BUILD_DIR="build" -OUT="${BUILD_DIR}/dist" -ARCHIVE_DIR="${OUT}/archives" -TARGETS_FILE="${BUILD_DIR}/targets.txt" -WITH_ARCHIVES="${ARCHIVES:-1}" - -# The canonical target matrix. Regenerated into build/targets.txt each run; used -# as the fallback when no targets.txt (or --TARGETS override) is present. -DEFAULT_TARGETS='# Release targets for scripts/build-all.sh — "GOOS GOARCH [GOARM]". -# Regenerated on every build. GoReleaser (.goreleaser.yaml) is the canonical -# release path; this drives the hand-rolled fallback. Names use {os}_{arch} with -# darwin → macos, arm → armvN, and .exe on windows; a macos_universal (Intel + -# Apple Silicon) fat binary is produced on macOS. -linux amd64 -linux 386 -linux arm64 -linux arm 6 -linux arm 7 -darwin amd64 -darwin arm64 -windows amd64 -windows 386 -windows arm64' - -# --- preflight --------------------------------------------------------------- -command -v go >/dev/null 2>&1 || { echo "error: 'go' is not on PATH" >&2; exit 1; } - -# Capture the effective target matrix BEFORE wiping build/, in priority order: -# an explicit TARGETS override, then the existing build/targets.txt, then the -# embedded default. It is regenerated back into build/targets.txt below. -if [ -n "${TARGETS:-}" ] && [ -f "$TARGETS" ]; then - MATRIX="$(cat "$TARGETS")" -elif [ -f "$TARGETS_FILE" ]; then - MATRIX="$(cat "$TARGETS_FILE")" -else - MATRIX="$DEFAULT_TARGETS" -fi - -# Portable SHA-256: Linux ships sha256sum, macOS/BSD ship shasum. -sha256() { - if command -v sha256sum >/dev/null 2>&1; then sha256sum "$@" - elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$@" - else echo "error: no sha256sum or shasum found" >&2; return 1; fi -} - -VERSION="${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo dev)}" -COMMIT="${COMMIT:-$(git rev-parse --short HEAD 2>/dev/null || echo none)}" -DATE="${DATE:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" -LDFLAGS="-s -w -X ${PKG}.Version=${VERSION} -X ${PKG}.Commit=${COMMIT} -X ${PKG}.Date=${DATE}" - -# Extra files bundled into each archive (whichever exist). -EXTRAS="" -for f in LICENSE README.md CHANGELOG.md; do - [ -f "$f" ] && EXTRAS="${EXTRAS} ${f}" -done - -# --- fresh build folder ------------------------------------------------------ -echo "==> ${BINARY} ${VERSION} (${COMMIT}) — regenerating ${BUILD_DIR}/" -case "$BUILD_DIR" in ''|/|.|..) echo "error: unsafe BUILD_DIR='$BUILD_DIR'" >&2; exit 1 ;; esac -rm -rf "$BUILD_DIR" -mkdir -p "$OUT" -[ "$WITH_ARCHIVES" = "1" ] && mkdir -p "$ARCHIVE_DIR" -printf '%s\n' "$MATRIX" > "$TARGETS_FILE" # regenerate the target list -echo "==> regenerated ${TARGETS_FILE}" - -# make_archive -make_archive() { - [ "$WITH_ARCHIVES" = "1" ] || return 0 - bin="$1"; goos="$2" - base="${bin%.exe}" # probaci_macos_arm64 - stage="$(mktemp -d)" - cp "${OUT}/${bin}" "${stage}/${bin}" - # shellcheck disable=SC2086 - [ -n "$EXTRAS" ] && cp $EXTRAS "${stage}/" - if [ "$goos" = "windows" ]; then - if command -v zip >/dev/null 2>&1; then - ( cd "$stage" && zip -qr "${base}.zip" . ) - mv "${stage}/${base}.zip" "${ARCHIVE_DIR}/" - else - echo " ! 'zip' not found — skipping ${base}.zip" >&2 - fi - else - tar -czf "${ARCHIVE_DIR}/${base}.tar.gz" -C "$stage" . - fi - rm -rf "$stage" -} - -# --- build every target ------------------------------------------------------ -count=0 -have_macos_amd64=0 -have_macos_arm64=0 - -# `|| [ -n "$goos" ]` processes a final line with no trailing newline; the \r in -# IFS tolerates CRLF line endings. -while IFS=$' \t\r' read -r goos goarch goarm || [ -n "${goos:-}" ]; do - case "$goos" in ''|\#*) continue ;; esac # skip blanks and comments - - os_token="$goos"; [ "$goos" = "darwin" ] && os_token="macos" - arch_token="$goarch"; [ "$goarch" = "arm" ] && arch_token="armv${goarm}" - ext=""; [ "$goos" = "windows" ] && ext=".exe" - - name="${BINARY}_${os_token}_${arch_token}${ext}" - printf ' -> %-32s' "$name" - - GOOS="$goos" GOARCH="$goarch" GOARM="${goarm:-}" CGO_ENABLED=0 \ - go build -trimpath -ldflags "$LDFLAGS" -o "${OUT}/${name}" ./cmd/probaci - echo "ok" - - make_archive "$name" "$goos" - count=$((count + 1)) - [ "$name" = "${BINARY}_macos_amd64" ] && have_macos_amd64=1 - [ "$name" = "${BINARY}_macos_arm64" ] && have_macos_arm64=1 -done < "$TARGETS_FILE" - -[ "$count" -gt 0 ] || { echo "error: no targets built (empty matrix?)" >&2; exit 1; } - -# --- macOS universal (Intel + Apple Silicon in one binary) ------------------- -if [ "$have_macos_amd64" = 1 ] && [ "$have_macos_arm64" = 1 ]; then - if command -v lipo >/dev/null 2>&1; then - uni="${BINARY}_macos_universal" - printf ' -> %-32s' "$uni" - lipo -create -output "${OUT}/${uni}" \ - "${OUT}/${BINARY}_macos_amd64" "${OUT}/${BINARY}_macos_arm64" - echo "ok" - make_archive "$uni" "darwin" - else - echo " ! 'lipo' not available (non-macOS host) — skipping macos_universal" >&2 - fi -fi - -# --- checksums (bare names so install scripts / self-update can look up) ------ -( cd "$OUT" && sha256 ${BINARY}_* > checksums.txt ) -echo "==> wrote ${OUT}/checksums.txt" -if [ "$WITH_ARCHIVES" = "1" ] && ls "${ARCHIVE_DIR}/${BINARY}_"* >/dev/null 2>&1; then - ( cd "$ARCHIVE_DIR" && sha256 ${BINARY}_* > checksums.txt ) - echo "==> wrote ${ARCHIVE_DIR}/checksums.txt" -fi - -echo "==> done: ${count} binaries in ${OUT}$( [ "$WITH_ARCHIVES" = "1" ] && echo ", archives in ${ARCHIVE_DIR}" )" diff --git a/.golangci.yml b/src/.golangci.yml similarity index 100% rename from .golangci.yml rename to src/.golangci.yml diff --git a/.goreleaser.yaml b/src/.goreleaser.yaml similarity index 86% rename from .goreleaser.yaml rename to src/.goreleaser.yaml index 578bb2d..9f5f44e 100644 --- a/.goreleaser.yaml +++ b/src/.goreleaser.yaml @@ -1,28 +1,35 @@ +# Run from the repository root: goreleaser ... --config src/.goreleaser.yaml. +# The module lives in src/ (builds[].dir), and archive files (LICENSE, README) +# are read from the root, which a config run from inside src/ cannot reach. +# # GoReleaser configuration — cross-platform/arch builds, checksums, cosign # signing, SLSA provenance, and an SBOM per release. version: 2 project_name: probaci -# Keep all build tooling under a single build/ folder: inputs (targets.txt) at -# build/ root, generated artifacts under build/dist/ (gitignored). -dist: build/dist +# GoReleaser writes into a scratch dist, .cache/goreleaser/ (gitignored), never +# into build/. build/ holds one folder per OS and nothing else; `make cross` and +# `make dist` write it with src/scripts/build-all.sh. A dispatched release +# uploads straight from this dist, so release assets stay flat. +dist: .cache/goreleaser before: hooks: - - go mod tidy + - go -C src mod tidy builds: - id: probaci + dir: src main: ./cmd/probaci binary: probaci env: - CGO_ENABLED=0 ldflags: - -s -w - - -X github.com/simtabi/probaci/internal/version.Version={{.Version}} - - -X github.com/simtabi/probaci/internal/version.Commit={{.ShortCommit}} - - -X github.com/simtabi/probaci/internal/version.Date={{.Date}} + - -X github.com/simtabi/probaci/src/internal/version.Version={{.Version}} + - -X github.com/simtabi/probaci/src/internal/version.Commit={{.ShortCommit}} + - -X github.com/simtabi/probaci/src/internal/version.Date={{.Date}} goos: [linux, darwin, windows] goarch: ["amd64", "arm64", "arm", "386"] goarm: ["6", "7"] @@ -83,7 +90,7 @@ checksum: name_template: "checksums.txt" algorithm: sha256 -# Local `goreleaser release --snapshot` (see `task bundle`) gets a sane version. +# Local `goreleaser release --snapshot` (see `make bundle`) gets a sane version. snapshot: version_template: "{{ .Version }}-SNAPSHOT-{{ .ShortCommit }}" @@ -114,24 +121,6 @@ signs: artifacts: checksum output: true -# Multi-arch container images built and pushed to ghcr.io in one buildx step, -# with the multi-platform manifest created automatically. dockers_v2 replaces -# the deprecated dockers + docker_manifests blocks. -dockers_v2: - - id: probaci - ids: [probaci] - images: - - ghcr.io/simtabi/probaci - tags: - - "{{ .Version }}" - - latest - platforms: - - linux/amd64 - - linux/arm64 - dockerfile: Dockerfile - labels: - org.opencontainers.image.source: "https://github.com/simtabi/probaci" - homebrew_casks: # The tap is shared across all Simtabi products: GoReleaser writes only this # product's Casks/probaci.rb into simtabi/homebrew-tap, leaving every other diff --git a/assets.go b/src/assets.go similarity index 70% rename from assets.go rename to src/assets.go index d1e0402..dd07ba1 100644 --- a/assets.go +++ b/src/assets.go @@ -1,6 +1,12 @@ // Package probaci (module root) embeds the documentation so `probaci docs` can // render it in-terminal even from an installed binary far from the repo. The // library API lives in pkg/probaci; this root package only ships assets. +// +// The module lives in src/, and //go:embed cannot reach above the package that +// embeds, so README.md and docs/ (which stay at the repository root, where +// GitHub and the docs site read them) are mirrored into src/embedded/ by +// `make sync-docs`. TestEmbeddedDocsMatchTheRepository fails when the mirror +// drifts from the originals. package probaci import ( @@ -8,10 +14,14 @@ import ( "io/fs" ) -//go:embed README.md docs/*.md docs/tools/*.md +//go:embed embedded/README.md embedded/docs/*.md embedded/docs/tools/*.md var docsFS embed.FS -// docTopics maps a user-facing topic name (and aliases) to its embedded file. +// embedRoot is the directory, inside docsFS, that mirrors the repository root. +const embedRoot = "embedded/" + +// docTopics maps a user-facing topic name (and aliases) to its file, by its +// path from the repository root. var docTopics = map[string]string{ "overview": "README.md", "readme": "README.md", @@ -38,7 +48,7 @@ func Doc(topic string) (string, bool) { if !ok { return "", false } - data, err := fs.ReadFile(docsFS, path) + data, err := fs.ReadFile(docsFS, embedRoot+path) if err != nil { return "", false } diff --git a/src/assets_test.go b/src/assets_test.go new file mode 100644 index 0000000..53f7dc9 --- /dev/null +++ b/src/assets_test.go @@ -0,0 +1,80 @@ +package probaci + +import ( + "bytes" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDocLookup(t *testing.T) { + for _, topic := range DocTopics() { + if _, ok := Doc(topic); !ok { + t.Errorf("topic %q should resolve to an embedded doc", topic) + } + } + // Default + aliases. + if md, ok := Doc(""); !ok || md == "" { + t.Fatal("empty topic should default to overview") + } + if _, ok := Doc("config"); !ok { + t.Fatal("alias 'config' should resolve") + } + if _, ok := Doc("nope"); ok { + t.Fatal("unknown topic should not resolve") + } +} + +// The embedded docs are a copy of README.md and docs/ (the module in src/ +// cannot embed files above it), so the copy must match the originals file for +// file, in both directions. `make sync-docs` refreshes it. +func TestEmbeddedDocsMatchTheRepository(t *testing.T) { + originals := []string{"README.md"} + for _, pattern := range []string{"docs/*.md", "docs/tools/*.md"} { + matches, err := filepath.Glob(filepath.Join("..", filepath.FromSlash(pattern))) + if err != nil { + t.Fatal(err) + } + for _, m := range matches { + rel, _ := filepath.Rel("..", m) + originals = append(originals, filepath.ToSlash(rel)) + } + } + // README plus six docs pages on 2026-11-07; a glob looking in the wrong + // place finds none and would pass an empty mirror. + if len(originals) < 5 { + t.Fatalf("found %d docs files at the repository root, want at least 5", len(originals)) + } + + embedded := map[string]bool{} + err := fs.WalkDir(docsFS, "embedded", func(path string, d fs.DirEntry, err error) error { + if err == nil && !d.IsDir() { + embedded[strings.TrimPrefix(path, embedRoot)] = true + } + return err + }) + if err != nil { + t.Fatal(err) + } + + for _, rel := range originals { + want, err := os.ReadFile(filepath.Join("..", filepath.FromSlash(rel))) + if err != nil { + t.Fatal(err) + } + got, err := fs.ReadFile(docsFS, embedRoot+rel) + if err != nil { + t.Errorf("%s is not embedded; run `make sync-docs`", rel) + continue + } + if !bytes.Equal(got, want) { + t.Errorf("src/embedded/%s differs from %s; run `make sync-docs`", rel, rel) + } + delete(embedded, rel) + } + for rel := range embedded { + t.Errorf("src/embedded/%s has no original at the repository root; run `make sync-docs`", rel) + } +} diff --git a/cmd/probaci/main.go b/src/cmd/probaci/main.go similarity index 87% rename from cmd/probaci/main.go rename to src/cmd/probaci/main.go index d48c69f..adff06b 100644 --- a/cmd/probaci/main.go +++ b/src/cmd/probaci/main.go @@ -6,7 +6,7 @@ import ( "fmt" "os" - "github.com/simtabi/probaci/internal/cli" + "github.com/simtabi/probaci/src/internal/cli" ) func main() { diff --git a/src/embedded/README.md b/src/embedded/README.md new file mode 100644 index 0000000..9b4f2fa --- /dev/null +++ b/src/embedded/README.md @@ -0,0 +1,144 @@ +# probaci + +> **Prove your pipeline before you push.** + +``` +┌──────────────────────────────────────────────────────────┐ +│ probaci │ +│ Prove your pipeline before you push │ +│ one binary · any VCS · any CI · every check │ +│ in a container │ +└──────────────────────────────────────────────────────────┘ +``` + +**probaci** — from Latin *probāre*, "to test / to prove" (pronounced +**proh-BAH-see**). It *proves* your CI is green before you push: it runs the same +checks your CI runs, locally, with every tool brokered through a container, so a +red check surfaces on your machine in seconds instead of after a +commit–push–wait cycle. In short: it tests the tests. + +It is a single, static, cross-platform Go binary. The only hard local +dependencies are a container runtime (Docker, rootless Docker, or Podman) and +your VCS — every CI tool and language toolchain runs in a pinned container, so +there is no "install N tools and hope their versions match CI." + +## Why checks pass locally but fail in CI + +probaci is built to catch the common causes automatically: + +- **Uncommitted/untracked files** — your tests pass on files CI never sees. The + `clean-clone` stage exports the *committed* state and tests that. +- **Dependency drift** — a locally-installed package that isn't in the lockfile. + `clean-clone` installs from the lockfile in a clean container. +- **Runtime version mismatch** — the `versions` stage compares the runtime your + workflow pins to what you have. +- **Workflow file bugs** — the `workflow-lint` stage statically checks them. +- **Runner-environment differences** — the `workflow-run` stage runs the real + pipeline in containers. + +## Install + +probaci is built from source: binary releases are on hold, so there is no +download, install-script, package-manager or `go install` channel to use. You need +Go (the version `src/go.mod` names), `git` and `make`. + +```sh +git clone https://github.com/simtabi/probaci ~/src/probaci +make -C ~/src/probaci build # -> build//probaci (macos, linux or windows) +~/src/probaci/build/*/probaci install # copies itself onto PATH (~/.local/bin) +probaci doctor +``` + +Pinning a version, updating, Windows, and PATH guidance are in +[docs/installation.md](docs/installation.md). + +## Quick start guide and usage + +### Getting started + +1. Check the runtime, and what probaci detects in the repository: + `probaci doctor`. +2. Write a `probaci.json` for the repository, detected from its contents: + `probaci init` (`--force` overwrites an existing one). +3. Optionally, write the user-global config from defaults: + `probaci config init`. + +### Usage + +```sh +probaci doctor # runtime, detected languages/platforms/VCS +probaci init # write a probaci.json for this repo +probaci run # run the pipeline on the current directory +probaci run ./api ./web # run on several repositories +probaci tui # interactive dashboard +``` + +Positional arguments are **repository paths**; stage selection is via flags: + +```sh +probaci run --only secrets,lint ./api ./web +probaci run --skip workflow-run +probaci run --repos ./api,./web --jobs 2 # comma form (CI/env friendly) +``` + +The full command reference is in [probaci tool](docs/tools/probaci.md); everything +else is in the [documentation index](#documentation). + +## The pipeline + +Stages run cheapest-and-highest-signal first, so most problems surface before +the slow container workflow run: + +`detect → workflow-lint → secrets → versions → lint → clean-clone → audit → sast +→ dockerfile-lint → yaml-lint → container-scan → commitlint → workflow-run` + +Each stage is enabled/ordered in `probaci.json`. See +[docs/configuration.md](docs/configuration.md). + +## Supported CI platforms + +| Tier | Platforms | +|------|-----------| +| lint + local run | GitHub Actions, GitLab CI, Gitea/Forgejo, CircleCI, Drone, Woodpecker | +| lint / validate | Bitbucket, Azure Pipelines, Jenkins, Travis, Buildkite | + +…across cloud, self-hosted, and enterprise endpoints. Adding a platform is an +adapter; adding a tool is a config entry. + +## Version control + +git, Mercurial (hg), Subversion (svn), Perforce/Helix (p4), Fossil, +Bazaar (bzr), Sapling (sl), and CVS (detect-only). `clean-clone` exports the +committed state on whichever you use. + +## Configuration + +probaci layers config: built-in defaults → `~/.config/probaci/config.json` +(user) → `./probaci.json` (project) → `PROBACI_*` env → CLI flags. The user home +is consistent across Linux/macOS/Windows and fully overridable. See +[docs/configuration.md](docs/configuration.md) and `probaci config --help` +(`path`, `show`, `init`, `edit`, `validate`, `reset`, `restore`, `migrate`). + +## Security + +Every brokered container runs least-privilege by default (non-root, all +capabilities dropped, no-new-privileges, read-only rootfs, offline network for +scanners, resource caps), images are pinned by digest, secrets are redacted from +all output and logs, and releases are signed with provenance + SBOM. Details in +[docs/security.md](docs/security.md) and [SECURITY.md](SECURITY.md). + + +## Documentation + +| Page | What it covers | +|------|----------------| +| [Installation](docs/installation.md) | install methods, platforms, architectures | +| [Configuration](docs/configuration.md) | `probaci.json`, precedence, the config home | +| [Architecture](docs/architecture.md) | the broker model, packages, data flow | +| [Security](docs/security.md) | hardening defaults, the socket-mount caveat | +| [Release](docs/release.md) | tag-driven releases, signing, channels | +| [probaci tool](docs/tools/probaci.md) | command reference | + +## License + +MIT © Simtabi LLC. See [LICENSE](LICENSE). diff --git a/src/embedded/docs/architecture.md b/src/embedded/docs/architecture.md new file mode 100644 index 0000000..f8dca03 --- /dev/null +++ b/src/embedded/docs/architecture.md @@ -0,0 +1,65 @@ +# Architecture + +probaci orchestrates checks; **the container broker is the universal runtime**. +The binary itself is small and pure-Go; every CI tool and language toolchain +runs inside a pinned container. + +## Data flow + +``` + ┌─────────┐ load + merge ┌──────────┐ + │ CLI │ ───────────────► │ config │ + │ / TUI │ └────┬─────┘ + └────┬────┘ │ + │ RunOptions ▼ + ▼ ┌──────────┐ + ┌──────────┐ per repo │ detect │ languages + │ engine │ ───────────────► │ vcs │ provider + │ (stage) │ │ platform │ CI adapters + └────┬─────┘ └──────────┘ + │ each stage + ▼ + ┌──────────┐ docker run --rm ┌───────────────┐ + │ broker │ ──────────────────► │ pinned tool │ + │ (docker/ │ least-privilege │ container │ + │ podman) │ └───────────────┘ + └────┬─────┘ + │ result.Result (+ streamed lines) + ▼ + ┌──────────┐ + │ report │ text / --json / TUI + └──────────┘ +``` + +## Packages + +The Go module lives in `src/` (module path `github.com/simtabi/probaci/src`); +the paths below are relative to it. The module root package embeds a copy of +`README.md` and `docs/` from `src/embedded/` for `probaci docs`, refreshed by +`make sync-docs`. + +| Package | Responsibility | +|---------|----------------| +| `internal/cli` | Cobra commands; thin — parse flags, drive the engine | +| `internal/tui` | Bubble Tea dashboard subscribing to engine events | +| `internal/stage` | the pipeline engine and stage implementations | +| `internal/platform` | one adapter per CI service (tiered: run / lint / detect) | +| `internal/vcs` | version-control providers (git, hg, svn, p4, fossil, …) | +| `internal/docker` | the least-privilege container broker (docker/rootless/podman) | +| `internal/tool` | the data-driven tool registry | +| `internal/detect` | language detection and default commands | +| `internal/config` | schema, layered load/merge/validate, management ops | +| `internal/paths` | the cross-OS user home resolver | +| `internal/secret` | the central redactor | +| `internal/ui` / `report` | styling and human/JSON output | +| `pkg/probaci` | the public, embeddable API | + +## Engine ↔ observers + +The engine emits a structured `Event` stream as it runs. Both the CLI and the +TUI subscribe to the same stream, so there is exactly one runner and no +duplicated orchestration logic. + +--- + +[← Docs index](../README.md#documentation) diff --git a/src/embedded/docs/configuration.md b/src/embedded/docs/configuration.md new file mode 100644 index 0000000..055a042 --- /dev/null +++ b/src/embedded/docs/configuration.md @@ -0,0 +1,116 @@ +# Configuration + +## Precedence + +probaci merges configuration layers, lowest to highest: + +``` +built-in defaults + → system config (/etc/probaci/config.json, %ProgramData%\probaci\config.json) + → ~/.config/probaci/config.json (user-global) + → ./probaci.json (per-project) + → PROBACI_* environment + → CLI flags +``` + +A missing file is never an error — probaci falls back to embedded defaults. The +**system layer** lets an admin set machine-wide defaults on a shared host while +each user's `~/.config/probaci` (and per-project config) override them. Override +the system path with `PROBACI_SYSTEM_DIR`. + +## Single- vs multi-user & concurrency + +- **Per-user isolation**: each user's config, logs, cache, and secrets live under + their own home; no user can read or clobber another's state. +- **Concurrent runs are safe**: each invocation gets a `RunID`; logs are written + per-run under `logs//___.log` + (readable text, pruned after 14 days), so parallel processes never contend. + Config-mutating commands take an advisory lock; writes are atomic. +- **Containers are labelled** (`probaci`, `probaci.user`, `probaci.run`); + `probaci clean` reclaims only your leaked containers (`--all` for everyone). +- **Parallelism** across repositories is bounded by `--jobs`. + +## The user home + +Resolved consistently across OSes (and fully overridable): + +``` +$PROBACI_HOME → $XDG_CONFIG_HOME/probaci → ~/.config/probaci (Linux/macOS) + %AppData%\probaci (Windows) +``` + +``` +~/.config/probaci/ +├── config.json user-global config +├── config.schema.json exported JSON Schema +├── tools.json optional tool-registry overrides +├── logs/ rotating run logs +├── cache/ image/results cache +└── secrets/ 0600 secret files (never committed) +``` + +Manage it with `probaci config`: + +| Command | Effect | +|---------|--------| +| `config path` | print the resolved home and active files | +| `config show [--sources]` | print the effective merged config | +| `config init` | write the user-global config from defaults | +| `config edit` | open it in `$EDITOR` | +| `config validate` | validate against the schema | +| `config reset` | reset to factory defaults (backs up first) | +| `config restore [--from FILE]` | restore from the latest backup | +| `config migrate [file]` | import a legacy `ci-local.config.json` | + +## probaci.json + +```jsonc +{ + "version": 1, + "members": [], // workspace globs; bare `run` expands them + "project": { + "languages": [], "install_cmd": "", "test_cmd": "", "lint_cmd": "", "audit_cmd": "" + }, + "docker": { + "enabled": true, + "backend": "auto", // auto|docker|rootless|podman + "pull": "missing", // missing|always|never + "network": "", + "resources": { "memory": "", "cpus": "", "pids_limit": 512 }, + "allow_socket_mount": false // opt-in for nested-Docker runners + }, + "security": { + "verify_images": "advisory", // advisory|strict + "allow_unsigned": [], + "registry_mirror": "" + }, + "platforms": { + "github": { "enabled": true, "base_url": "" }, + "gitlab": { "enabled": true, "base_url": "https://gitlab.example.com", "token_env": "GITLAB_TOKEN" } + }, + "stages": [ { "name": "secrets", "enabled": true, "options": {} } ], + "tools": { + "gitleaks": { "image": "zricethezav/gitleaks", "tag": "v8.x", "digest": "sha256:…" } + }, + "secrets_file": ".probaci/secrets", + "env_file": ".env", + "tui": { "theme": "auto" } +} +``` + +## Targeting repositories + +Positional arguments are repository **paths**; stage selection is via flags. + +```sh +probaci run ./api ./web # space-separated paths (glob-friendly) +probaci run --repos ./api,./web # comma form for CI/env +probaci run -C ../service # run as if started elsewhere +``` + +For monorepos, declare `members` globs in a root `probaci.json`; a bare +`probaci run` expands them. + +--- + +[← Docs index](../README.md#documentation) diff --git a/src/embedded/docs/installation.md b/src/embedded/docs/installation.md new file mode 100644 index 0000000..550ae99 --- /dev/null +++ b/src/embedded/docs/installation.md @@ -0,0 +1,112 @@ +# Installation + +probaci is a single static binary, built from source. Binary releases are on +hold until the maintainer decides otherwise, so this page offers no download, +install-script, package-manager or `go install` path. The only hard runtime +dependencies are a container runtime (Docker, rootless Docker, or Podman) and +your VCS. + +## Build from source + +You need Go (the version `src/go.mod` names), `git`, and `make` (or +[go-task](https://taskfile.dev)). + +```sh +git clone https://github.com/simtabi/probaci ~/src/probaci +make -C ~/src/probaci build # -> build//probaci (macos, linux or windows) +~/src/probaci/build/*/probaci install # copies itself to ~/.local/bin (Windows: %LOCALAPPDATA%\Programs\probaci) +probaci doctor +``` + +`probaci install --system` installs machine-wide (`/usr/local/bin`, +`%ProgramFiles%`, needs elevation), `--dir DIR` picks an explicit directory, and +`probaci uninstall [--purge]` removes it again. Copying `build//probaci` onto +your `PATH` by hand works just as well. `` is the host's OS, written `macos` +rather than `darwin`. + +The Go module lives in `src/`, so without `make` the dependency-free primitive is +`go -C ~/src/probaci/src build -o ../build/linux/probaci ./cmd/probaci` (name the +folder for your OS). On Windows, run the same `git clone` and +`go -C src build -o ..\build\windows\probaci.exe ./cmd/probaci` from PowerShell +in the checkout, then run `build\windows\probaci.exe install`. + +### Pinning a version + +A version is a tag: check one out to build exactly that release (`git -C ~/src/probaci tag` +lists them). + +```sh +git -C ~/src/probaci fetch --tags +git -C ~/src/probaci checkout vX.Y.Z +make -C ~/src/probaci build +``` + +### Updating + +```sh +git -C ~/src/probaci pull --ff-only +make -C ~/src/probaci build +~/src/probaci/build/*/probaci install +``` + +`probaci version` reports the version the build was stamped with (`git describe`), +so a build from a branch is still identifiable. + +### Binary releases + +No binary release has been published. The release tooling stays in the tree: +the GoReleaser config (`src/.goreleaser.yaml`), and `src/scripts/install.sh` / +`src/scripts/install.ps1`, which +download release archives. The release workflow would also publish Homebrew and +Scoop manifests and deb/rpm packages. probaci no longer builds a container image. +None of those channels is offered while binary releases are on hold; see +[release.md](release.md). + +## Run it globally + +Once the install directory is on your `PATH`, run `probaci` from anywhere — it +auto-discovers the repository root by walking up from the current directory +(ddev-style), so `probaci run` works from any subdirectory. + +If `probaci install` warns that the directory isn't on your `PATH`, add it: + +| Shell / OS | Add to PATH | +|---|---| +| bash | `echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc` | +| zsh | `echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc` | +| fish | `fish_add_path ~/.local/bin` | +| Windows (PowerShell) | `[Environment]::SetEnvironmentVariable('Path', "$dir;$env:Path", 'User')` | + +`/usr/local/bin` is already on `PATH` for most systems — prefer it for a +machine-wide install (`probaci install --system`, which needs elevation). + +## Supported platforms and architectures + +| OS | Architectures | +|----|---------------| +| Linux | amd64, arm64, arm (v6/v7), 386 | +| macOS | amd64, arm64 | +| Windows | amd64, arm64, 386 | + +> The container broker requires a working container runtime. On 32-bit and some +> Windows configurations, runtime availability varies — probaci's `--no-docker` +> mode runs the non-container checks and skips the rest. + +## Development builds + +The Makefile is the one task definition; `task ` (Taskfile.yml) forwards +to it. The Go module lives in `src/`, and `build/` holds one folder per OS: + +```sh +make build # build//probaci, e.g. build/macos/probaci +make cross # every target in build/targets.txt into build//, plus build/checksums.txt +make dist # make cross, plus .tar.gz/.zip archives in build/archives/ +go -C src build -o ../build/linux/probaci ./cmd/probaci # the dependency-free primitive +``` + +Verify a cross build with `cd build && shasum -a 256 -c checksums.txt`. None of +these publishes anything. See [release.md](release.md). + +--- + +[← Docs index](../README.md#documentation) diff --git a/src/embedded/docs/release.md b/src/embedded/docs/release.md new file mode 100644 index 0000000..0d45eb4 --- /dev/null +++ b/src/embedded/docs/release.md @@ -0,0 +1,117 @@ +# Release + +Releases are tag-driven and built by GoReleaser, and binary releases are on +hold. A `vX.Y.Z` tag records a version and publishes nothing; a binary release is +built from a tag only when dispatched by hand with a confirmation (see +[Cutting a release](#cutting-a-release)). Until binary releases resume, users +build from source ([installation](installation.md#build-from-source)). The CI +GoReleaser version is pinned (`~> v2.4`) so the config schema and tool stay in +sync. + +## Local deliverables (no publishing) + +Build the binaries and archives locally, useful for testing what users would +get: + +```sh +make cross # build//probaci__[.exe] plus build/checksums.txt +make dist # the same, plus archives and their checksums in build/archives/ +``` + +Both run `src/scripts/build-all.sh`, never GoReleaser, and publish nothing. +`build/` holds one folder per OS (`macos`, never `darwin`) and nothing else; +`make check-build` fails on anything else, and +`cd build && shasum -a 256 -c checksums.txt` verifies the binaries. + +To exercise the GoReleaser config end to end, `make bundle` runs +`goreleaser release --config src/.goreleaser.yaml --snapshot --clean --skip=publish,sign,sbom` +into GoReleaser's scratch dist, `.cache/goreleaser/` (gitignored, never +`build/`), with no OIDC/cosign/registry/syft needed. Validate the config with +`make release-check` (`goreleaser check --config src/.goreleaser.yaml`). + +## Install scripts + +`src/scripts/install.sh` / `src/scripts/install.ps1` download the matching release archive, +verify its sha256 against `checksums.txt`, and install onto PATH. Their asset +names must match the GoReleaser archive `name_template` +(`probaci___`); keep them in sync. Test a script against a +local bundle without publishing: + +```sh +make bundle +PROBACI_VERSION=0.0.0-SNAPSHOT- PROBACI_BASE_URL="file://$PWD/.cache/goreleaser" \ + PROBACI_INSTALL_DIR=/tmp/probaci sh src/scripts/install.sh +``` + +## Pinning tool-image digests + +The built-in tool images ship pinned by digest (`src/internal/tool/digests.json`, +embedded into the binary). Refresh the pins periodically — run in a connected +environment with a container runtime + python3 and a built `probaci`: + +```sh +make build +PROBACI="$(ls build/*/probaci)" ./src/scripts/pin-digests.sh > src/internal/tool/digests.json +``` + +Images that fail to pull are reported and left unpinned (advisory). Commit the +regenerated `digests.json`. + +## Cutting a release + +1. Update `CHANGELOG.md` (move Unreleased → the new version). +2. Tag and push: + ```sh + git tag v0.1.0 + git push origin v0.1.0 + ``` + Pushing a tag publishes nothing: binary releases are on hold, and probaci is + built from source until the owner decides otherwise. +3. Only when a binary release has been approved for this version, dispatch the + `release` workflow on the tag: + ```sh + gh workflow run release.yml --repo simtabi/probaci --ref vX.Y.Z -f confirm=release-go + ``` + `vX.Y.Z` must be a tag that is already on GitHub. This publishes for real, so + run it only for a version the owner approved. The workflow's first step + refuses to run without `confirm=release-go`, or on anything but a `v[0-9]*` + tag, before anything is built. +4. The `release` workflow runs GoReleaser, which builds the matrix, signs with + cosign (keyless via OIDC), attaches an SBOM and SLSA provenance, and publishes + the GitHub Release, deb/rpm, and Homebrew/Scoop manifests. No container image + is built. + +## Build matrix + +linux `amd64/arm64/arm(v6,v7)/386`, darwin `amd64/arm64`, windows `amd64/arm64/386`. +Pure Go, `CGO_ENABLED=0`. + +## First release (short form) + +1. Make the repo public on GitHub. +2. Create the `pypi`/`npm`-equivalent here: a GitHub Release flow via OIDC (no + long-lived registry credentials). +3. Add the write deploy keys `HOMEBREW_TAP_DEPLOY_KEY` (on `simtabi/homebrew-tap`) and `SCOOP_BUCKET_DEPLOY_KEY` (on `simtabi/scoop-bucket`), each a repository secret that reaches its one repository and nothing else (`TAP_GITHUB_TOKEN` is only a fallback, and the + Winget pull request's token), plus any registry tokens as needed. +4. Tag `v0.1.0`, then dispatch the release on it as in + [Cutting a release](#cutting-a-release). + +The full org-wide gate is `/opensource/shipping-checklist.md` (local; not in any +repo). Run it before every release. + +## Version stamping + +The binary embeds version metadata via `-ldflags` into +`github.com/simtabi/probaci/src/internal/version`; `probaci version` prints it. + +## Module path and tags + +The Go module lives in `src/`, so its path is `github.com/simtabi/probaci/src`. +Go resolves a subdirectory module only through a tag named after the directory, +so every release carries two tags on the same commit: `vX.Y.Z`, which the release +runs from, and `src/vX.Y.Z`. The release workflow creates the `src/` tag itself +before GoReleaser runs; neither tag is ever moved. + +--- + +[← Docs index](../README.md#documentation) diff --git a/src/embedded/docs/security.md b/src/embedded/docs/security.md new file mode 100644 index 0000000..cd0ced9 --- /dev/null +++ b/src/embedded/docs/security.md @@ -0,0 +1,61 @@ +# Security + +probaci executes third-party container images, runs CI tools over repository +content, and handles tokens. Hardening spans four layers. To report a +vulnerability, see [SECURITY.md](../SECURITY.md). + +## 1. Command execution — no shell + +The original bash tool used `eval`; probaci eliminates that class entirely. +Tools run via `exec` with **argument vectors only** — config values are never +concatenated into a shell string. Config is schema-validated (unknown fields +rejected), and target paths are resolved and confined to the repository root. + +## 2. Least-privilege containers + +Every brokered run defaults to: + +``` +--rm --cap-drop ALL --security-opt no-new-privileges +--read-only --tmpfs /tmp (writable scratch only) +--user (non-root, on Linux) +--pids-limit / --memory / --cpus +--network none (offline unless the tool needs the network) +-v :/workspace:ro (read-only repo; clean-clone uses an isolated rw export) +``` + +Backends: Docker (default), **rootless Docker**, and **Podman** — choose in +config or `--backend`. + +### The socket-mount caveat + +Nested-Docker runners (`act`, `gitlab-ci-local`) may need the container socket. +probaci **never** mounts it for its own tool runs; enabling it for those runners +is opt-in (`docker.allow_socket_mount`) and is best done on the rootless/Podman +backend. + +## 3. Secrets + +- Passed via env file or stdin, **never argv**. +- Secret files live `0600` under `~/.config/probaci/secrets/` and are + git-ignored by default. +- A central **redactor** scrubs known secret values and token-shaped strings + from stdout, `--json`, and the rotating log file. + +## 4. Image trust + +- Images are referenced by **digest** where pinned (tamper-evident). +- **cosign verification is advisory by default** (verify + warn); `strict` mode + refuses unverified images unless allow-listed. +- A private `registry_mirror` can be configured. + +## Supply chain (releases) + +Releases are reproducible and carry SHA-256 checksums, **cosign** signatures, +an **SBOM**, and **SLSA provenance**, published via OIDC trusted publishing. CI +runs `govulncheck`, `golangci-lint`, and `gitleaks`, with all Actions pinned by +commit SHA and `permissions: contents: read` by default. + +--- + +[← Docs index](../README.md#documentation) diff --git a/src/embedded/docs/tools/probaci.md b/src/embedded/docs/tools/probaci.md new file mode 100644 index 0000000..7c37649 --- /dev/null +++ b/src/embedded/docs/tools/probaci.md @@ -0,0 +1,61 @@ +# probaci — command reference + +``` +probaci [command] [flags] [PATH...] +``` + +## Commands + +| Command | Description | +|---------|-------------| +| `run [PATH...]` | run the pipeline on one or more repositories (default: `.`) | +| `stage [PATH...]` | run a single stage | +| `tui [PATH...]` | launch the interactive dashboard | +| `doctor [PATH]` | runtime, detected platforms/languages/VCS, config provenance | +| `init [PATH]` | write a `probaci.json` detected from the repo | +| `platforms [PATH]` | list supported CI platforms and which are detected | +| `vcs [PATH]` | show the detected version-control provider(s) | +| `tools` | list the tool registry and resolved images | +| `docs [topic]` | documentation pointers | +| `logs [--self]` | probaci's own log location (or remote-CI failures) | +| `clean` | prune probaci-labelled container artifacts | +| `config …` | path / show / init / edit / validate / reset / restore / migrate | +| `version` | print version information | + +## Global flags + +| Flag | Description | +|------|-------------| +| `--only a,b` / `--skip a,b` | stage selection (comma-separated) | +| `--repos a,b` | comma-separated repo paths (alternative to positionals) | +| `-C, --chdir DIR` | run as if started in DIR | +| `--jobs N` | repositories to process concurrently | +| `--platform NAME` | restrict to one CI platform | +| `--config FILE` | override the project config file | +| `--backend` | `auto\|docker\|rootless\|podman` | +| `--pull` | `missing\|always\|never` | +| `--no-docker` | disable the broker (degraded mode) | +| `--full-image` | heavy runner image for `workflow-run` | +| `--dry-run` | print the plan without executing | +| `--ci` | non-interactive, plain output | +| `--json` | machine-readable output | +| `-v, --verbose` | increase log verbosity (repeatable) | + +## Stages + +`detect`, `workflow-lint`, `secrets`, `versions`, `lint`, `clean-clone`, +`audit`, `sast`, `dockerfile-lint`, `yaml-lint`, `container-scan`, `commitlint`, +`workflow-run`. + +## Exit codes + +| Code | Meaning | +|------|---------| +| 0 | all clear | +| 1 | a stage failed | +| 2 | usage/config error | +| 125 | container runtime unavailable | + +--- + +[← Docs index](../../README.md#documentation) diff --git a/go.mod b/src/go.mod similarity index 98% rename from go.mod rename to src/go.mod index 7cce2df..3ab7797 100644 --- a/go.mod +++ b/src/go.mod @@ -1,4 +1,4 @@ -module github.com/simtabi/probaci +module github.com/simtabi/probaci/src go 1.25.0 diff --git a/go.sum b/src/go.sum similarity index 100% rename from go.sum rename to src/go.sum diff --git a/internal/cli/config.go b/src/internal/cli/config.go similarity index 98% rename from internal/cli/config.go rename to src/internal/cli/config.go index 97fb7e4..05e5fe2 100644 --- a/internal/cli/config.go +++ b/src/internal/cli/config.go @@ -5,8 +5,8 @@ import ( "os" "os/exec" - "github.com/simtabi/probaci/internal/config" - "github.com/simtabi/probaci/internal/paths" + "github.com/simtabi/probaci/src/internal/config" + "github.com/simtabi/probaci/src/internal/paths" "github.com/spf13/cobra" ) diff --git a/internal/cli/doctor.go b/src/internal/cli/doctor.go similarity index 92% rename from internal/cli/doctor.go rename to src/internal/cli/doctor.go index 05504b3..529dbf7 100644 --- a/internal/cli/doctor.go +++ b/src/internal/cli/doctor.go @@ -5,9 +5,9 @@ import ( "fmt" "strings" - "github.com/simtabi/probaci/internal/detect" - "github.com/simtabi/probaci/internal/platform" - "github.com/simtabi/probaci/internal/vcs" + "github.com/simtabi/probaci/src/internal/detect" + "github.com/simtabi/probaci/src/internal/platform" + "github.com/simtabi/probaci/src/internal/vcs" "github.com/spf13/cobra" ) diff --git a/internal/cli/errors.go b/src/internal/cli/errors.go similarity index 100% rename from internal/cli/errors.go rename to src/internal/cli/errors.go diff --git a/internal/cli/info.go b/src/internal/cli/info.go similarity index 95% rename from internal/cli/info.go rename to src/internal/cli/info.go index 4ade806..bd61763 100644 --- a/internal/cli/info.go +++ b/src/internal/cli/info.go @@ -11,13 +11,13 @@ import ( "strings" "github.com/charmbracelet/glamour" - "github.com/simtabi/probaci" - "github.com/simtabi/probaci/internal/paths" - "github.com/simtabi/probaci/internal/platform" - "github.com/simtabi/probaci/internal/tool" - "github.com/simtabi/probaci/internal/ui" - "github.com/simtabi/probaci/internal/vcs" - "github.com/simtabi/probaci/internal/version" + probaci "github.com/simtabi/probaci/src" + "github.com/simtabi/probaci/src/internal/paths" + "github.com/simtabi/probaci/src/internal/platform" + "github.com/simtabi/probaci/src/internal/tool" + "github.com/simtabi/probaci/src/internal/ui" + "github.com/simtabi/probaci/src/internal/vcs" + "github.com/simtabi/probaci/src/internal/version" "github.com/spf13/cobra" ) diff --git a/internal/cli/init.go b/src/internal/cli/init.go similarity index 97% rename from internal/cli/init.go rename to src/internal/cli/init.go index d97ff14..8b5ff84 100644 --- a/internal/cli/init.go +++ b/src/internal/cli/init.go @@ -8,8 +8,8 @@ import ( "github.com/charmbracelet/huh" "github.com/mattn/go-isatty" - "github.com/simtabi/probaci/internal/config" - "github.com/simtabi/probaci/internal/detect" + "github.com/simtabi/probaci/src/internal/config" + "github.com/simtabi/probaci/src/internal/detect" "github.com/spf13/cobra" ) diff --git a/internal/cli/install.go b/src/internal/cli/install.go similarity index 100% rename from internal/cli/install.go rename to src/internal/cli/install.go diff --git a/internal/cli/root.go b/src/internal/cli/root.go similarity index 96% rename from internal/cli/root.go rename to src/internal/cli/root.go index 266696e..671ecb2 100644 --- a/internal/cli/root.go +++ b/src/internal/cli/root.go @@ -10,9 +10,9 @@ import ( "strings" "github.com/mattn/go-isatty" - "github.com/simtabi/probaci/internal/ui" - "github.com/simtabi/probaci/internal/version" - "github.com/simtabi/probaci/pkg/probaci" + "github.com/simtabi/probaci/src/internal/ui" + "github.com/simtabi/probaci/src/internal/version" + "github.com/simtabi/probaci/src/pkg/probaci" "github.com/spf13/cobra" ) diff --git a/internal/cli/run.go b/src/internal/cli/run.go similarity index 90% rename from internal/cli/run.go rename to src/internal/cli/run.go index caaa7f2..4acb523 100644 --- a/internal/cli/run.go +++ b/src/internal/cli/run.go @@ -6,12 +6,12 @@ import ( "os" "strings" - "github.com/simtabi/probaci/internal/platform" - "github.com/simtabi/probaci/internal/report" - "github.com/simtabi/probaci/internal/result" - "github.com/simtabi/probaci/internal/stage" - "github.com/simtabi/probaci/internal/ui" - "github.com/simtabi/probaci/pkg/probaci" + "github.com/simtabi/probaci/src/internal/platform" + "github.com/simtabi/probaci/src/internal/report" + "github.com/simtabi/probaci/src/internal/result" + "github.com/simtabi/probaci/src/internal/stage" + "github.com/simtabi/probaci/src/internal/ui" + "github.com/simtabi/probaci/src/pkg/probaci" "github.com/spf13/cobra" ) diff --git a/internal/cli/stage.go b/src/internal/cli/stage.go similarity index 87% rename from internal/cli/stage.go rename to src/internal/cli/stage.go index 9be5049..5033d2c 100644 --- a/internal/cli/stage.go +++ b/src/internal/cli/stage.go @@ -4,9 +4,9 @@ import ( "context" "fmt" - "github.com/simtabi/probaci/internal/platform" - "github.com/simtabi/probaci/internal/report" - "github.com/simtabi/probaci/internal/stage" + "github.com/simtabi/probaci/src/internal/platform" + "github.com/simtabi/probaci/src/internal/report" + "github.com/simtabi/probaci/src/internal/stage" "github.com/spf13/cobra" ) diff --git a/internal/cli/targets.go b/src/internal/cli/targets.go similarity index 95% rename from internal/cli/targets.go rename to src/internal/cli/targets.go index 5d936a8..4e5f16d 100644 --- a/internal/cli/targets.go +++ b/src/internal/cli/targets.go @@ -5,8 +5,8 @@ import ( "os" "path/filepath" - "github.com/simtabi/probaci/internal/config" - "github.com/simtabi/probaci/internal/discover" + "github.com/simtabi/probaci/src/internal/config" + "github.com/simtabi/probaci/src/internal/discover" ) // resolveTargets turns positional path args, --repos, -C, and workspace diff --git a/internal/cli/targets_test.go b/src/internal/cli/targets_test.go similarity index 100% rename from internal/cli/targets_test.go rename to src/internal/cli/targets_test.go diff --git a/internal/cli/tui.go b/src/internal/cli/tui.go similarity index 82% rename from internal/cli/tui.go rename to src/internal/cli/tui.go index f804ce0..1d91f74 100644 --- a/internal/cli/tui.go +++ b/src/internal/cli/tui.go @@ -1,9 +1,9 @@ package cli import ( - "github.com/simtabi/probaci/internal/platform" - "github.com/simtabi/probaci/internal/stage" - "github.com/simtabi/probaci/internal/tui" + "github.com/simtabi/probaci/src/internal/platform" + "github.com/simtabi/probaci/src/internal/stage" + "github.com/simtabi/probaci/src/internal/tui" "github.com/spf13/cobra" ) diff --git a/internal/config/config.go b/src/internal/config/config.go similarity index 100% rename from internal/config/config.go rename to src/internal/config/config.go diff --git a/internal/config/config_test.go b/src/internal/config/config_test.go similarity index 100% rename from internal/config/config_test.go rename to src/internal/config/config_test.go diff --git a/internal/config/defaults.go b/src/internal/config/defaults.go similarity index 100% rename from internal/config/defaults.go rename to src/internal/config/defaults.go diff --git a/internal/config/load.go b/src/internal/config/load.go similarity index 100% rename from internal/config/load.go rename to src/internal/config/load.go diff --git a/internal/config/load_test.go b/src/internal/config/load_test.go similarity index 100% rename from internal/config/load_test.go rename to src/internal/config/load_test.go diff --git a/internal/config/manage.go b/src/internal/config/manage.go similarity index 100% rename from internal/config/manage.go rename to src/internal/config/manage.go diff --git a/internal/config/migrate.go b/src/internal/config/migrate.go similarity index 100% rename from internal/config/migrate.go rename to src/internal/config/migrate.go diff --git a/internal/config/migrate_test.go b/src/internal/config/migrate_test.go similarity index 100% rename from internal/config/migrate_test.go rename to src/internal/config/migrate_test.go diff --git a/internal/config/schema.go b/src/internal/config/schema.go similarity index 100% rename from internal/config/schema.go rename to src/internal/config/schema.go diff --git a/internal/config/schema.json b/src/internal/config/schema.json similarity index 100% rename from internal/config/schema.json rename to src/internal/config/schema.json diff --git a/internal/detect/detect.go b/src/internal/detect/detect.go similarity index 100% rename from internal/detect/detect.go rename to src/internal/detect/detect.go diff --git a/internal/detect/detect_test.go b/src/internal/detect/detect_test.go similarity index 100% rename from internal/detect/detect_test.go rename to src/internal/detect/detect_test.go diff --git a/internal/discover/root.go b/src/internal/discover/root.go similarity index 100% rename from internal/discover/root.go rename to src/internal/discover/root.go diff --git a/internal/discover/root_test.go b/src/internal/discover/root_test.go similarity index 100% rename from internal/discover/root_test.go rename to src/internal/discover/root_test.go diff --git a/internal/docker/broker.go b/src/internal/docker/broker.go similarity index 99% rename from internal/docker/broker.go rename to src/internal/docker/broker.go index 1349ddb..2b48f5a 100644 --- a/internal/docker/broker.go +++ b/src/internal/docker/broker.go @@ -15,7 +15,7 @@ import ( "strconv" "strings" - "github.com/simtabi/probaci/internal/config" + "github.com/simtabi/probaci/src/internal/config" ) // Broker executes tools in containers via a chosen backend binary. diff --git a/internal/docker/broker_test.go b/src/internal/docker/broker_test.go similarity index 96% rename from internal/docker/broker_test.go rename to src/internal/docker/broker_test.go index 8b34856..1abe6b7 100644 --- a/internal/docker/broker_test.go +++ b/src/internal/docker/broker_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/simtabi/probaci/internal/config" + "github.com/simtabi/probaci/src/internal/config" ) // TestBuildArgsHardening asserts the least-privilege defaults are always set and diff --git a/internal/docker/trust.go b/src/internal/docker/trust.go similarity index 100% rename from internal/docker/trust.go rename to src/internal/docker/trust.go diff --git a/internal/docker/trust_test.go b/src/internal/docker/trust_test.go similarity index 97% rename from internal/docker/trust_test.go rename to src/internal/docker/trust_test.go index 8553eea..0e18fde 100644 --- a/internal/docker/trust_test.go +++ b/src/internal/docker/trust_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - "github.com/simtabi/probaci/internal/config" + "github.com/simtabi/probaci/src/internal/config" ) func TestCheckImageStrictBlocksUnpinned(t *testing.T) { diff --git a/internal/docker/user.go b/src/internal/docker/user.go similarity index 100% rename from internal/docker/user.go rename to src/internal/docker/user.go diff --git a/internal/logging/logging.go b/src/internal/logging/logging.go similarity index 100% rename from internal/logging/logging.go rename to src/internal/logging/logging.go diff --git a/internal/logging/logging_test.go b/src/internal/logging/logging_test.go similarity index 100% rename from internal/logging/logging_test.go rename to src/internal/logging/logging_test.go diff --git a/internal/paths/paths.go b/src/internal/paths/paths.go similarity index 100% rename from internal/paths/paths.go rename to src/internal/paths/paths.go diff --git a/internal/paths/paths_test.go b/src/internal/paths/paths_test.go similarity index 100% rename from internal/paths/paths_test.go rename to src/internal/paths/paths_test.go diff --git a/internal/platform/adapters.go b/src/internal/platform/adapters.go similarity index 99% rename from internal/platform/adapters.go rename to src/internal/platform/adapters.go index ad543b5..7d422d0 100644 --- a/internal/platform/adapters.go +++ b/src/internal/platform/adapters.go @@ -3,7 +3,7 @@ package platform import ( "context" - "github.com/simtabi/probaci/internal/result" + "github.com/simtabi/probaci/src/internal/result" ) // --- GitHub Actions (Tier 1) ---------------------------------------------- diff --git a/internal/platform/helpers.go b/src/internal/platform/helpers.go similarity index 96% rename from internal/platform/helpers.go rename to src/internal/platform/helpers.go index 5d090ad..943755a 100644 --- a/internal/platform/helpers.go +++ b/src/internal/platform/helpers.go @@ -10,8 +10,8 @@ import ( "strings" "time" - "github.com/simtabi/probaci/internal/docker" - "github.com/simtabi/probaci/internal/result" + "github.com/simtabi/probaci/src/internal/docker" + "github.com/simtabi/probaci/src/internal/result" ) // fileExists reports whether repo/rel exists. diff --git a/internal/platform/platform.go b/src/internal/platform/platform.go similarity index 93% rename from internal/platform/platform.go rename to src/internal/platform/platform.go index d816bc3..7bb15f5 100644 --- a/internal/platform/platform.go +++ b/src/internal/platform/platform.go @@ -7,9 +7,9 @@ package platform import ( "context" - "github.com/simtabi/probaci/internal/docker" - "github.com/simtabi/probaci/internal/result" - "github.com/simtabi/probaci/internal/tool" + "github.com/simtabi/probaci/src/internal/docker" + "github.com/simtabi/probaci/src/internal/result" + "github.com/simtabi/probaci/src/internal/tool" ) // Tier expresses how much an adapter can honestly do. diff --git a/src/internal/release/layout_test.go b/src/internal/release/layout_test.go new file mode 100644 index 0000000..2174180 --- /dev/null +++ b/src/internal/release/layout_test.go @@ -0,0 +1,126 @@ +// Package release_test guards how probaci is built and released. It holds +// tests only: the workflows are YAML and build/ is a directory, so there is no +// Go code here to test, just a policy to pin. +package release_test + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// repoRoot walks up from the test's directory to the repository root: the +// directory holding src/go.mod. The module lives in src/, so the root is its +// parent, not the directory holding go.mod. +func repoRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for { + if _, err := os.Stat(filepath.Join(dir, "src", "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatal("src/go.mod not found above the test directory") + } + dir = parent + } +} + +func readFile(t *testing.T, parts ...string) string { + t.Helper() + raw, err := os.ReadFile(filepath.Join(parts...)) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +// buildInputs are the files build/ may hold besides the OS folders. +var buildInputs = map[string]bool{"targets.txt": true, "nfpm.yaml": true, "checksums.txt": true} + +// osFolders are the folders build/ may hold: one per OS, named by GOOS except +// darwin, which is macos. archives/ is written by `make dist`. +var osFolders = map[string]bool{ + "macos": true, "linux": true, "windows": true, "freebsd": true, "openbsd": true, + "netbsd": true, "dragonfly": true, "solaris": true, "illumos": true, "aix": true, + "plan9": true, "android": true, "ios": true, "js": true, "wasip1": true, + "archives": true, +} + +// build/ holds one folder per OS and nothing else: no build/dist/, no darwin/, +// no per-target subfolders and no tool intermediates. `make check-build` runs +// this after every build, and CI runs it after `make cross`. +func TestBuildLayout(t *testing.T) { + dir := filepath.Join(repoRoot(t), "build") + if problems := buildLayoutProblems(t, dir); len(problems) > 0 { + t.Errorf("build/ breaks the layout (one folder per OS, see the Makefile):\n %s", strings.Join(problems, "\n ")) + } +} + +// The layout check has to be able to fail: each of these trees breaks it. +func TestBuildLayoutRejectsStrayOutput(t *testing.T) { + cases := map[string][]string{ + "a dist folder": {"dist/probaci_linux_amd64"}, + "a darwin folder": {"darwin/probaci_darwin_arm64"}, + "a per-target folder": {"linux/probaci_linux_amd64/probaci"}, + "a loose binary": {"probaci"}, + "goreleaser metadata": {"artifacts.json"}, + "a nested macos build": {"macos/universal/probaci"}, + } + for name, files := range cases { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + for _, f := range append([]string{"targets.txt"}, files...) { + p := filepath.Join(dir, filepath.FromSlash(f)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, nil, 0o644); err != nil { + t.Fatal(err) + } + } + if len(buildLayoutProblems(t, dir)) == 0 { + t.Errorf("%v passed the layout check", files) + } + }) + } +} + +func buildLayoutProblems(t *testing.T, dir string) []string { + t.Helper() + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("reading %s: %v", dir, err) + } + if _, err := os.Stat(filepath.Join(dir, "targets.txt")); err != nil { + t.Fatalf("%s has no targets.txt: the check is not looking at build/", dir) + } + var problems []string + for _, e := range entries { + name := e.Name() + switch { + case name == ".DS_Store": + case !e.IsDir() && buildInputs[name]: + case e.IsDir() && osFolders[name]: + inner, err := os.ReadDir(filepath.Join(dir, name)) + if err != nil { + t.Fatal(err) + } + for _, f := range inner { + if f.IsDir() { + problems = append(problems, name+"/"+f.Name()+"/ is a subfolder; an OS folder holds binaries only") + } + } + case e.IsDir(): + problems = append(problems, name+"/ is not an OS folder (darwin is written macos; there is no dist/)") + default: + problems = append(problems, name+" belongs in an OS folder, or nowhere") + } + } + return problems +} diff --git a/src/internal/release/workflow_test.go b/src/internal/release/workflow_test.go new file mode 100644 index 0000000..182b7d2 --- /dev/null +++ b/src/internal/release/workflow_test.go @@ -0,0 +1,140 @@ +package release_test + +import ( + "io/fs" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// automaticTrigger matches a trigger that starts a workflow without a person +// dispatching it, at any indentation under `on:`. +var automaticTrigger = regexp.MustCompile(`(?m)^[ \t]*(push|schedule|pull_request|pull_request_target|release|workflow_run)\s*:`) + +// Go releases are git only until the owner says otherwise, so release.yml must +// never start on its own: no push, schedule, pull_request or release trigger, +// and a dispatch whose first step refuses to publish without confirm=release-go +// on a vX.Y.Z tag. A tag push that started GoReleaser is how an unwanted +// release began elsewhere on 2026-11-02. +func TestReleaseRunsOnlyOnAConfirmedDispatch(t *testing.T) { + wf := readFile(t, repoRoot(t), ".github", "workflows", "release.yml") + + if m := automaticTrigger.FindString(wf); m != "" { + t.Errorf("release.yml has an automatic trigger (%q); it must run on workflow_dispatch only", strings.TrimSpace(m)) + } + for _, want := range []string{"workflow_dispatch:", "confirm:", "required: true", `"${CONFIRM}" != "release-go"`, "refs/tags/v[0-9]*"} { + if !strings.Contains(wf, want) { + t.Errorf("release.yml lacks %q: a release needs an explicit, confirmed dispatch on a tag", want) + } + } + + // The refusal must be the first step, before anything is checked out, + // built or published. + _, steps, ok := strings.Cut(wf, "steps:") + if !ok { + t.Fatal("release.yml has no steps") + } + first := steps + if items := regexp.MustCompile(`(?m)^\s*- `).FindAllStringIndex(steps, 2); len(items) == 2 { + first = steps[:items[1][0]] + } + for _, want := range []string{`"release-go"`, "refs/tags/v[0-9]*", "exit 1"} { + if !strings.Contains(first, want) { + t.Errorf("the first release step must refuse without confirm=release-go on a vX.Y.Z tag; it lacks %q:\n%s", want, first) + } + } +} + +// skipDirs are never ours to police: git's own store, dependencies, +// generated output and local tool state. +var skipDirs = map[string]bool{ + ".git": true, "vendor": true, "node_modules": true, "build": true, "bin": true, + ".cache": true, ".claude": true, ".probaci": true, +} + +// containerFile matches a file that exists only to build or run a container. +var containerFile = regexp.MustCompile(`(?i)^(dockerfile(\..*)?|.*\.dockerfile|\.dockerignore|containerfile|(docker-)?compose(\.[a-z0-9_-]+)?\.ya?ml)$`) + +// containerInWorkflow matches a workflow step or GoReleaser block that builds, +// runs or publishes an image. +var containerInWorkflow = regexp.MustCompile(`(?m)uses:\s*['"]?docker/|\bdocker\s+(build|buildx|run|push|compose|login)\b|\bghcr\.io\b|^\s*dockers?(_v2)?\s*:|^\s*docker_manifests\s*:`) + +// probaci ships as a binary built from source. No container image is built, +// run or published by the repository itself (probaci's own feature of brokering +// tools through the host's container runtime lives in src/internal and is not a +// file this walk matches), and nothing brings one back without this test +// changing. +func TestNoContainerImage(t *testing.T) { + root := repoRoot(t) + visited := 0 + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if path != root && skipDirs[d.Name()] { + return filepath.SkipDir + } + return nil + } + visited++ + if containerFile.MatchString(d.Name()) { + rel, _ := filepath.Rel(root, path) + t.Errorf("%s builds or runs a container; probaci ships a binary built from source", rel) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + // The repository held 116 files outside the skipped directories on + // 2026-11-08; a walk rooted in the wrong place visits a handful. + if visited < 80 { + t.Fatalf("visited %d files, want at least 80: the walk is not looking where it should", visited) + } + + workflows, err := filepath.Glob(filepath.Join(root, ".github", "workflows", "*.y*ml")) + if err != nil { + t.Fatal(err) + } + // ci, codeql, keepalive, release and scorecard on 2026-11-08. + if len(workflows) < 5 { + t.Fatalf("found %d workflow files, want at least 5", len(workflows)) + } + for _, f := range append(workflows, filepath.Join(root, "src", ".goreleaser.yaml")) { + if m := containerInWorkflow.FindString(readFile(t, f)); m != "" { + t.Errorf("%s: %q builds, runs or publishes an image", filepath.Base(f), strings.TrimSpace(m)) + } + } + if regexp.MustCompile(`package-ecosystem:\s*['"]?docker`).MatchString(readFile(t, root, ".github", "dependabot.yml")) { + t.Error("dependabot.yml watches a docker ecosystem") + } +} + +// Releases are on hold, so the docs offer building from source only: no +// `go install` of this module, which would also serve whatever the Go proxy +// cached for a tag rather than the code in the checkout. +func TestDocsOfferNoGoInstall(t *testing.T) { + root := repoRoot(t) + files := []string{filepath.Join(root, "README.md"), filepath.Join(root, "CONTRIBUTING.md")} + var docs []string + for _, pattern := range []string{"*.md", "tools/*.md"} { + matches, err := filepath.Glob(filepath.Join(root, "docs", pattern)) + if err != nil { + t.Fatal(err) + } + docs = append(docs, matches...) + } + // Six pages on 2026-11-08. + if len(docs) < 5 { + t.Fatalf("found %d docs pages, want at least 5", len(docs)) + } + files = append(files, docs...) + goInstall := regexp.MustCompile("go install [^\\s`]*github\\.com/") + for _, f := range files { + if m := goInstall.FindString(readFile(t, f)); m != "" { + t.Errorf("%s offers %q; while releases are on hold the docs build from source", filepath.Base(f), m) + } + } +} diff --git a/internal/report/report.go b/src/internal/report/report.go similarity index 93% rename from internal/report/report.go rename to src/internal/report/report.go index a9d94ea..39aca7a 100644 --- a/internal/report/report.go +++ b/src/internal/report/report.go @@ -8,8 +8,8 @@ import ( "strings" "time" - "github.com/simtabi/probaci/internal/result" - "github.com/simtabi/probaci/internal/ui" + "github.com/simtabi/probaci/src/internal/result" + "github.com/simtabi/probaci/src/internal/ui" ) // JSON renders the aggregate as indented JSON for machine consumption. diff --git a/internal/report/report_test.go b/src/internal/report/report_test.go similarity index 93% rename from internal/report/report_test.go rename to src/internal/report/report_test.go index 03f52fa..d1c294b 100644 --- a/internal/report/report_test.go +++ b/src/internal/report/report_test.go @@ -6,8 +6,8 @@ import ( "testing" "time" - "github.com/simtabi/probaci/internal/result" - "github.com/simtabi/probaci/internal/ui" + "github.com/simtabi/probaci/src/internal/result" + "github.com/simtabi/probaci/src/internal/ui" ) func sampleAgg() result.Aggregate { diff --git a/internal/result/result.go b/src/internal/result/result.go similarity index 100% rename from internal/result/result.go rename to src/internal/result/result.go diff --git a/internal/run/runid.go b/src/internal/run/runid.go similarity index 100% rename from internal/run/runid.go rename to src/internal/run/runid.go diff --git a/internal/secret/redact.go b/src/internal/secret/redact.go similarity index 100% rename from internal/secret/redact.go rename to src/internal/secret/redact.go diff --git a/internal/secret/redact_test.go b/src/internal/secret/redact_test.go similarity index 100% rename from internal/secret/redact_test.go rename to src/internal/secret/redact_test.go diff --git a/internal/stage/engine.go b/src/internal/stage/engine.go similarity index 93% rename from internal/stage/engine.go rename to src/internal/stage/engine.go index 5407b29..7065dd4 100644 --- a/internal/stage/engine.go +++ b/src/internal/stage/engine.go @@ -8,13 +8,13 @@ import ( "sync" "time" - "github.com/simtabi/probaci/internal/config" - "github.com/simtabi/probaci/internal/detect" - "github.com/simtabi/probaci/internal/docker" - "github.com/simtabi/probaci/internal/platform" - "github.com/simtabi/probaci/internal/result" - "github.com/simtabi/probaci/internal/tool" - "github.com/simtabi/probaci/internal/vcs" + "github.com/simtabi/probaci/src/internal/config" + "github.com/simtabi/probaci/src/internal/detect" + "github.com/simtabi/probaci/src/internal/docker" + "github.com/simtabi/probaci/src/internal/platform" + "github.com/simtabi/probaci/src/internal/result" + "github.com/simtabi/probaci/src/internal/tool" + "github.com/simtabi/probaci/src/internal/vcs" ) // Event is emitted as the pipeline progresses. diff --git a/internal/stage/engine_test.go b/src/internal/stage/engine_test.go similarity index 92% rename from internal/stage/engine_test.go rename to src/internal/stage/engine_test.go index cd02172..29881a7 100644 --- a/internal/stage/engine_test.go +++ b/src/internal/stage/engine_test.go @@ -4,9 +4,9 @@ import ( "context" "testing" - "github.com/simtabi/probaci/internal/config" - "github.com/simtabi/probaci/internal/result" - "github.com/simtabi/probaci/internal/tool" + "github.com/simtabi/probaci/src/internal/config" + "github.com/simtabi/probaci/src/internal/result" + "github.com/simtabi/probaci/src/internal/tool" ) // TestPerRepoConfigSelectsStages verifies that a per-repo resolver controls diff --git a/internal/stage/registry_drift_test.go b/src/internal/stage/registry_drift_test.go similarity index 92% rename from internal/stage/registry_drift_test.go rename to src/internal/stage/registry_drift_test.go index 8b6b6cf..0308277 100644 --- a/internal/stage/registry_drift_test.go +++ b/src/internal/stage/registry_drift_test.go @@ -5,8 +5,8 @@ import ( "path/filepath" "testing" - "github.com/simtabi/probaci/internal/detect" - "github.com/simtabi/probaci/internal/tool" + "github.com/simtabi/probaci/src/internal/detect" + "github.com/simtabi/probaci/src/internal/tool" ) // TestDetectToolsResolveInRegistry guards against drift: every lint/audit tool diff --git a/internal/stage/stages.go b/src/internal/stage/stages.go similarity index 96% rename from internal/stage/stages.go rename to src/internal/stage/stages.go index 7e5e3aa..f02487c 100644 --- a/internal/stage/stages.go +++ b/src/internal/stage/stages.go @@ -8,13 +8,13 @@ import ( "path/filepath" "strings" - "github.com/simtabi/probaci/internal/config" - "github.com/simtabi/probaci/internal/detect" - "github.com/simtabi/probaci/internal/docker" - "github.com/simtabi/probaci/internal/platform" - "github.com/simtabi/probaci/internal/result" - "github.com/simtabi/probaci/internal/tool" - "github.com/simtabi/probaci/internal/vcs" + "github.com/simtabi/probaci/src/internal/config" + "github.com/simtabi/probaci/src/internal/detect" + "github.com/simtabi/probaci/src/internal/docker" + "github.com/simtabi/probaci/src/internal/platform" + "github.com/simtabi/probaci/src/internal/result" + "github.com/simtabi/probaci/src/internal/tool" + "github.com/simtabi/probaci/src/internal/vcs" ) // stageCtx carries everything a stage needs for one repository. diff --git a/internal/stage/util.go b/src/internal/stage/util.go similarity index 97% rename from internal/stage/util.go rename to src/internal/stage/util.go index 0d41e8f..003e338 100644 --- a/internal/stage/util.go +++ b/src/internal/stage/util.go @@ -7,7 +7,7 @@ import ( "regexp" "strings" - "github.com/simtabi/probaci/internal/result" + "github.com/simtabi/probaci/src/internal/result" ) // worsen returns the more severe of two statuses, so a folded result reflects diff --git a/internal/stage/util_test.go b/src/internal/stage/util_test.go similarity index 94% rename from internal/stage/util_test.go rename to src/internal/stage/util_test.go index fdb4fcf..02acbb3 100644 --- a/internal/stage/util_test.go +++ b/src/internal/stage/util_test.go @@ -3,7 +3,7 @@ package stage import ( "testing" - "github.com/simtabi/probaci/internal/result" + "github.com/simtabi/probaci/src/internal/result" ) func TestWorsen(t *testing.T) { diff --git a/internal/tool/digests.json b/src/internal/tool/digests.json similarity index 100% rename from internal/tool/digests.json rename to src/internal/tool/digests.json diff --git a/internal/tool/registry.go b/src/internal/tool/registry.go similarity index 97% rename from internal/tool/registry.go rename to src/internal/tool/registry.go index b2af9fa..e85bde2 100644 --- a/internal/tool/registry.go +++ b/src/internal/tool/registry.go @@ -9,13 +9,13 @@ import ( "encoding/json" "fmt" - "github.com/simtabi/probaci/internal/config" + "github.com/simtabi/probaci/src/internal/config" ) // pinnedDigests maps a tool name to its image's sha256 digest, generated by -// scripts/pin-digests.sh. Resolve applies these so built-in tools run pinned +// src/scripts/pin-digests.sh. Resolve applies these so built-in tools run pinned // (tamper-evident) by default; a config override digest still wins. Regenerate -// with: ./scripts/pin-digests.sh > internal/tool/digests.json +// with: ./src/scripts/pin-digests.sh > src/internal/tool/digests.json // //go:embed digests.json var pinnedDigestsJSON []byte diff --git a/internal/tool/registry_test.go b/src/internal/tool/registry_test.go similarity index 95% rename from internal/tool/registry_test.go rename to src/internal/tool/registry_test.go index 8c9c31a..fb3c402 100644 --- a/internal/tool/registry_test.go +++ b/src/internal/tool/registry_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/simtabi/probaci/internal/config" + "github.com/simtabi/probaci/src/internal/config" ) func TestResolveBuiltin(t *testing.T) { diff --git a/internal/tui/tui.go b/src/internal/tui/tui.go similarity index 96% rename from internal/tui/tui.go rename to src/internal/tui/tui.go index 2656817..df1a38e 100644 --- a/internal/tui/tui.go +++ b/src/internal/tui/tui.go @@ -12,9 +12,9 @@ import ( "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/simtabi/probaci/internal/result" - "github.com/simtabi/probaci/internal/stage" - "github.com/simtabi/probaci/internal/ui" + "github.com/simtabi/probaci/src/internal/result" + "github.com/simtabi/probaci/src/internal/stage" + "github.com/simtabi/probaci/src/internal/ui" ) // Run launches the dashboard for the given engine and options. diff --git a/internal/ui/banner.go b/src/internal/ui/banner.go similarity index 100% rename from internal/ui/banner.go rename to src/internal/ui/banner.go diff --git a/internal/ui/banner_test.go b/src/internal/ui/banner_test.go similarity index 100% rename from internal/ui/banner_test.go rename to src/internal/ui/banner_test.go diff --git a/internal/ui/box.go b/src/internal/ui/box.go similarity index 100% rename from internal/ui/box.go rename to src/internal/ui/box.go diff --git a/internal/ui/ui.go b/src/internal/ui/ui.go similarity index 98% rename from internal/ui/ui.go rename to src/internal/ui/ui.go index b200563..bd87d7d 100644 --- a/internal/ui/ui.go +++ b/src/internal/ui/ui.go @@ -8,7 +8,7 @@ import ( "strings" "github.com/charmbracelet/lipgloss" - "github.com/simtabi/probaci/internal/result" + "github.com/simtabi/probaci/src/internal/result" ) // Theme holds resolved capability flags and styles. diff --git a/internal/vcs/providers.go b/src/internal/vcs/providers.go similarity index 100% rename from internal/vcs/providers.go rename to src/internal/vcs/providers.go diff --git a/internal/vcs/vcs.go b/src/internal/vcs/vcs.go similarity index 100% rename from internal/vcs/vcs.go rename to src/internal/vcs/vcs.go diff --git a/internal/vcs/vcs_test.go b/src/internal/vcs/vcs_test.go similarity index 100% rename from internal/vcs/vcs_test.go rename to src/internal/vcs/vcs_test.go diff --git a/internal/version/version.go b/src/internal/version/version.go similarity index 100% rename from internal/version/version.go rename to src/internal/version/version.go diff --git a/internal/version/version_test.go b/src/internal/version/version_test.go similarity index 100% rename from internal/version/version_test.go rename to src/internal/version/version_test.go diff --git a/pkg/probaci/probaci.go b/src/pkg/probaci/probaci.go similarity index 93% rename from pkg/probaci/probaci.go rename to src/pkg/probaci/probaci.go index 7775bfb..b593c1e 100644 --- a/pkg/probaci/probaci.go +++ b/src/pkg/probaci/probaci.go @@ -13,14 +13,14 @@ import ( "path/filepath" "time" - "github.com/simtabi/probaci/internal/config" - "github.com/simtabi/probaci/internal/docker" - "github.com/simtabi/probaci/internal/logging" - "github.com/simtabi/probaci/internal/paths" - "github.com/simtabi/probaci/internal/run" - "github.com/simtabi/probaci/internal/secret" - "github.com/simtabi/probaci/internal/stage" - "github.com/simtabi/probaci/internal/tool" + "github.com/simtabi/probaci/src/internal/config" + "github.com/simtabi/probaci/src/internal/docker" + "github.com/simtabi/probaci/src/internal/logging" + "github.com/simtabi/probaci/src/internal/paths" + "github.com/simtabi/probaci/src/internal/run" + "github.com/simtabi/probaci/src/internal/secret" + "github.com/simtabi/probaci/src/internal/stage" + "github.com/simtabi/probaci/src/internal/tool" ) // App is a fully-wired probaci instance. diff --git a/src/scripts/build-all.sh b/src/scripts/build-all.sh new file mode 100755 index 0000000..8777136 --- /dev/null +++ b/src/scripts/build-all.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +# +# Cross-compile every target in build/targets.txt into build/, one folder per OS. +# It builds on this machine and publishes nothing; GoReleaser +# (src/.goreleaser.yaml) is the release path, and releases are dispatched by +# hand only (docs/release.md). +# +# build/ holds one folder per OS and nothing else: +# +# build/targets.txt tracked input, "GOOS GOARCH [GOARM]" +# build/checksums.txt SHA-256 over every binary, as {os}/{file}, +# so `cd build && shasum -a 256 -c checksums.txt` +# build/macos/probaci_macos_arm64 ... and _amd64, and _universal on a macOS host +# build/linux/probaci_linux_amd64 ... _386, _arm64, _armv6, _armv7 +# build/windows/probaci_windows_amd64.exe ... _386, _arm64 +# build/archives/ only with ARCHIVES=1: .tar.gz / .zip per +# binary (plus LICENSE/README/CHANGELOG), and +# their own bare-name checksums.txt +# +# The OS folder and the name use `macos`, never `darwin`; arm is armvN; Windows +# binaries end in .exe. Static (CGO_ENABLED=0), reproducible (-trimpath), +# stripped (-s -w), with version metadata embedded. +# +# Every generated file under build/ is removed first, so nothing stale from a +# removed target survives. targets.txt (and nfpm.yaml, if one is added) is input +# and is kept; when targets.txt is missing it is written from the default matrix +# below. +# +# VERSION=v1.2.3 ./src/scripts/build-all.sh # binaries and archives +# ARCHIVES=0 ./src/scripts/build-all.sh # binaries only (`make cross`) +# TARGETS=path/to/list ./src/scripts/build-all.sh # use an external target list +# +# Portable to bash 3.2 (the macOS system bash): no mapfile / wait -n / assoc arrays. +set -euo pipefail + +# The script lives in src/scripts/; build/ and the files it bundles are at the +# repository root, and the module it compiles is src/. +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +BINARY="probaci" +PKG="github.com/simtabi/probaci/src/internal/version" +BUILD_DIR="build" +ARCHIVE_DIR="${BUILD_DIR}/archives" +TARGETS_FILE="${BUILD_DIR}/targets.txt" +WITH_ARCHIVES="${ARCHIVES:-1}" + +# The default target matrix, written to build/targets.txt only when that file is +# missing. +DEFAULT_TARGETS='# Release targets for src/scripts/build-all.sh -- "GOOS GOARCH [GOARM]". +# GoReleaser (src/.goreleaser.yaml) is the release path; this drives `make cross` +# and `make dist`. Output goes to build// with names {binary}_{os}_{arch}, +# darwin written as macos, arm as armvN, and .exe on windows; a macos_universal +# (Intel + Apple Silicon) binary is produced on macOS. +linux amd64 +linux 386 +linux arm64 +linux arm 6 +linux arm 7 +darwin amd64 +darwin arm64 +windows amd64 +windows 386 +windows arm64' + +# --- preflight --------------------------------------------------------------- +command -v go >/dev/null 2>&1 || { echo "error: 'go' is not on PATH" >&2; exit 1; } + +mkdir -p "$BUILD_DIR" +if [ -n "${TARGETS:-}" ]; then + [ -f "$TARGETS" ] || { echo "error: no target list at ${TARGETS}" >&2; exit 1; } + MATRIX_FILE="$TARGETS" +else + if [ ! -f "$TARGETS_FILE" ]; then + printf '%s\n' "$DEFAULT_TARGETS" > "$TARGETS_FILE" + echo "==> wrote the default matrix to ${TARGETS_FILE}" + fi + MATRIX_FILE="$TARGETS_FILE" +fi + +# Portable SHA-256: Linux ships sha256sum, macOS/BSD ship shasum. +sha256() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$@" + elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$@" + else echo "error: no sha256sum or shasum found" >&2; return 1; fi +} + +VERSION="${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo dev)}" +COMMIT="${COMMIT:-$(git rev-parse --short HEAD 2>/dev/null || echo none)}" +DATE="${DATE:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" +LDFLAGS="-s -w -X ${PKG}.Version=${VERSION} -X ${PKG}.Commit=${COMMIT} -X ${PKG}.Date=${DATE}" + +# Extra files bundled into each archive (whichever exist). +EXTRAS="" +for f in LICENSE README.md CHANGELOG.md; do + [ -f "$f" ] && EXTRAS="${EXTRAS} ${f}" +done + +# --- clear generated output, keep the inputs --------------------------------- +echo "==> ${BINARY} ${VERSION} (${COMMIT}) into ${BUILD_DIR}/" +for entry in "$BUILD_DIR"/* "$BUILD_DIR"/.[!.]*; do + [ -e "$entry" ] || continue + case "$(basename "$entry")" in + targets.txt|nfpm.yaml) ;; + *) rm -rf "$entry" ;; + esac +done +[ "$WITH_ARCHIVES" = "1" ] && mkdir -p "$ARCHIVE_DIR" + +# make_archive +make_archive() { + [ "$WITH_ARCHIVES" = "1" ] || return 0 + folder="$1"; bin="$2"; goos="$3" + base="${bin%.exe}" # probaci_macos_arm64 + stage="$(mktemp -d "${TMPDIR:-/tmp}/build-all.XXXXXX")" + cp "${BUILD_DIR}/${folder}/${bin}" "${stage}/${bin}" + # shellcheck disable=SC2086 + [ -n "$EXTRAS" ] && cp $EXTRAS "${stage}/" + if [ "$goos" = "windows" ]; then + if command -v zip >/dev/null 2>&1; then + ( cd "$stage" && zip -qr "${base}.zip" . ) + mv "${stage}/${base}.zip" "${ARCHIVE_DIR}/" + else + echo " ! 'zip' not found; skipping ${base}.zip" >&2 + fi + else + tar -czf "${ARCHIVE_DIR}/${base}.tar.gz" -C "$stage" . + fi + rm -rf "$stage" +} + +# --- build every target ------------------------------------------------------ +count=0 +have_macos_amd64=0 +have_macos_arm64=0 + +# `|| [ -n "$goos" ]` processes a final line with no trailing newline; the \r in +# IFS tolerates CRLF line endings. +while IFS=$' \t\r' read -r goos goarch goarm || [ -n "${goos:-}" ]; do + case "$goos" in ''|\#*) continue ;; esac # skip blanks and comments + + os_token="$goos"; [ "$goos" = "darwin" ] && os_token="macos" + arch_token="$goarch"; [ "$goarch" = "arm" ] && arch_token="armv${goarm}" + ext=""; [ "$goos" = "windows" ] && ext=".exe" + + name="${BINARY}_${os_token}_${arch_token}${ext}" + mkdir -p "${BUILD_DIR}/${os_token}" + printf ' -> %-40s' "${os_token}/${name}" + + GOOS="$goos" GOARCH="$goarch" GOARM="${goarm:-}" CGO_ENABLED=0 \ + go -C src build -trimpath -ldflags "$LDFLAGS" \ + -o "${ROOT}/${BUILD_DIR}/${os_token}/${name}" ./cmd/probaci + echo "ok" + + make_archive "$os_token" "$name" "$goos" + count=$((count + 1)) + [ "$name" = "${BINARY}_macos_amd64" ] && have_macos_amd64=1 + [ "$name" = "${BINARY}_macos_arm64" ] && have_macos_arm64=1 +done < "$MATRIX_FILE" + +[ "$count" -gt 0 ] || { echo "error: no targets built (empty matrix?)" >&2; exit 1; } + +# --- macOS universal (Intel + Apple Silicon in one binary) ------------------- +if [ "$have_macos_amd64" = 1 ] && [ "$have_macos_arm64" = 1 ]; then + if command -v lipo >/dev/null 2>&1; then + uni="${BINARY}_macos_universal" + printf ' -> %-40s' "macos/${uni}" + lipo -create -output "${BUILD_DIR}/macos/${uni}" \ + "${BUILD_DIR}/macos/${BINARY}_macos_amd64" "${BUILD_DIR}/macos/${BINARY}_macos_arm64" + echo "ok" + make_archive macos "$uni" "darwin" + else + echo " ! 'lipo' not available (non-macOS host); skipping macos_universal" >&2 + fi +fi + +# --- checksums --------------------------------------------------------------- +# build/checksums.txt names {os}/{file}, so it verifies from inside build/ as-is. +( cd "$BUILD_DIR" && find . -mindepth 2 -maxdepth 2 -type f -name "${BINARY}_*" -not -path './archives/*' \ + | sed 's|^\./||' | LC_ALL=C sort | while IFS= read -r f; do sha256 "$f"; done > checksums.txt ) +echo "==> wrote ${BUILD_DIR}/checksums.txt" +# The archives' checksums keep bare names, the form release assets use. +if [ "$WITH_ARCHIVES" = "1" ] && ls "${ARCHIVE_DIR}/${BINARY}_"* >/dev/null 2>&1; then + ( cd "$ARCHIVE_DIR" && sha256 "${BINARY}"_* > checksums.txt ) + echo "==> wrote ${ARCHIVE_DIR}/checksums.txt" +fi + +echo "==> done: ${count} binaries in ${BUILD_DIR}//$( [ "$WITH_ARCHIVES" = "1" ] && echo ", archives in ${ARCHIVE_DIR}" )" diff --git a/scripts/extract-changelog.sh b/src/scripts/extract-changelog.sh similarity index 93% rename from scripts/extract-changelog.sh rename to src/scripts/extract-changelog.sh index b5c7c48..bcedd5d 100755 --- a/scripts/extract-changelog.sh +++ b/src/scripts/extract-changelog.sh @@ -2,7 +2,7 @@ # # Print the CHANGELOG.md section for a given tag, used as the GitHub release body. # -# ./scripts/extract-changelog.sh v0.1.0 +# ./src/scripts/extract-changelog.sh v0.1.0 # set -euo pipefail diff --git a/scripts/install.ps1 b/src/scripts/install.ps1 similarity index 97% rename from scripts/install.ps1 rename to src/scripts/install.ps1 index a5b96b5..beac604 100644 --- a/scripts/install.ps1 +++ b/src/scripts/install.ps1 @@ -3,7 +3,7 @@ probaci installer for Windows. .EXAMPLE - irm https://raw.githubusercontent.com/simtabi/probaci/main/scripts/install.ps1 | iex + irm https://raw.githubusercontent.com/simtabi/probaci/main/src/scripts/install.ps1 | iex .EXAMPLE # Pin a version / custom dir (env vars are prefixed with the uppercased binary name) diff --git a/scripts/install.sh b/src/scripts/install.sh similarity index 95% rename from scripts/install.sh rename to src/scripts/install.sh index b0a55bf..a91bb5d 100755 --- a/scripts/install.sh +++ b/src/scripts/install.sh @@ -2,10 +2,10 @@ # # probaci installer for macOS and Linux. # -# curl -fsSL https://raw.githubusercontent.com/simtabi/probaci/main/scripts/install.sh | bash -# curl -fsSL https://raw.githubusercontent.com/simtabi/probaci/main/scripts/install.sh | bash -s v0.1.0 +# curl -fsSL https://raw.githubusercontent.com/simtabi/probaci/main/src/scripts/install.sh | bash +# curl -fsSL https://raw.githubusercontent.com/simtabi/probaci/main/src/scripts/install.sh | bash -s v0.1.0 # -# Windows users: use scripts/install.ps1 (irm ... | iex) or download the bare +# Windows users: use src/scripts/install.ps1 (irm ... | iex) or download the bare # probaci_windows_.exe from the GitHub releases page. # set -euo pipefail diff --git a/scripts/pin-digests.sh b/src/scripts/pin-digests.sh similarity index 92% rename from scripts/pin-digests.sh rename to src/scripts/pin-digests.sh index 8cae1f9..e41bc70 100755 --- a/scripts/pin-digests.sh +++ b/src/scripts/pin-digests.sh @@ -5,7 +5,7 @@ # Regenerate the shipped pins (run in a network-connected env with a container # runtime + python3, and a built probaci on PATH or via PROBACI=...): # -# ./scripts/pin-digests.sh > internal/tool/digests.json +# ./src/scripts/pin-digests.sh > src/internal/tool/digests.json # # Images that fail to pull are reported on stderr and skipped (not pinned). set -eu @@ -46,7 +46,7 @@ for t in tools: if failed: sys.stderr.write("\nNOT pinned (resolve these images): %s\n" % ", ".join(sorted(failed))) -# Flat name -> digest map, embedded by internal/tool as digests.json. +# Flat name -> digest map, embedded by src/internal/tool as digests.json. flat = {name: meta["digest"] for name, meta in sorted(pinned.items())} print(json.dumps(flat, indent=2)) '