From 5521e2138e45297d86cffbf3477febcaeee9e27c Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Sat, 7 Nov 2026 23:55:29 -0500 Subject: [PATCH 1/8] Move the Go module into src/, verbatim The simtabi Go layout keeps everything the toolchain reads under src/ and everything the repository presents at the top. This commit only moves files with git mv: go.mod, go.sum, the root package, cmd/, internal/, pkg/, scripts/ and the GoReleaser and golangci configs. Nothing inside them changes here, so the move reviews as renames. The module path, the embedded docs, the tooling and the workflows are pointed at src/ in the following commits; until then the root package's //go:embed of README.md and docs/ cannot resolve. --- .golangci.yml => src/.golangci.yml | 0 .goreleaser.yaml => src/.goreleaser.yaml | 0 assets.go => src/assets.go | 0 assets_test.go => src/assets_test.go | 0 {cmd => src/cmd}/probaci/main.go | 0 go.mod => src/go.mod | 0 go.sum => src/go.sum | 0 {internal => src/internal}/cli/config.go | 0 {internal => src/internal}/cli/doctor.go | 0 {internal => src/internal}/cli/errors.go | 0 {internal => src/internal}/cli/info.go | 0 {internal => src/internal}/cli/init.go | 0 {internal => src/internal}/cli/install.go | 0 {internal => src/internal}/cli/root.go | 0 {internal => src/internal}/cli/run.go | 0 {internal => src/internal}/cli/stage.go | 0 {internal => src/internal}/cli/targets.go | 0 {internal => src/internal}/cli/targets_test.go | 0 {internal => src/internal}/cli/tui.go | 0 {internal => src/internal}/config/config.go | 0 {internal => src/internal}/config/config_test.go | 0 {internal => src/internal}/config/defaults.go | 0 {internal => src/internal}/config/load.go | 0 {internal => src/internal}/config/load_test.go | 0 {internal => src/internal}/config/manage.go | 0 {internal => src/internal}/config/migrate.go | 0 {internal => src/internal}/config/migrate_test.go | 0 {internal => src/internal}/config/schema.go | 0 {internal => src/internal}/config/schema.json | 0 {internal => src/internal}/detect/detect.go | 0 {internal => src/internal}/detect/detect_test.go | 0 {internal => src/internal}/discover/root.go | 0 {internal => src/internal}/discover/root_test.go | 0 {internal => src/internal}/docker/broker.go | 0 {internal => src/internal}/docker/broker_test.go | 0 {internal => src/internal}/docker/trust.go | 0 {internal => src/internal}/docker/trust_test.go | 0 {internal => src/internal}/docker/user.go | 0 {internal => src/internal}/logging/logging.go | 0 {internal => src/internal}/logging/logging_test.go | 0 {internal => src/internal}/paths/paths.go | 0 {internal => src/internal}/paths/paths_test.go | 0 {internal => src/internal}/platform/adapters.go | 0 {internal => src/internal}/platform/helpers.go | 0 {internal => src/internal}/platform/platform.go | 0 {internal => src/internal}/report/report.go | 0 {internal => src/internal}/report/report_test.go | 0 {internal => src/internal}/result/result.go | 0 {internal => src/internal}/run/runid.go | 0 {internal => src/internal}/secret/redact.go | 0 {internal => src/internal}/secret/redact_test.go | 0 {internal => src/internal}/stage/engine.go | 0 {internal => src/internal}/stage/engine_test.go | 0 {internal => src/internal}/stage/registry_drift_test.go | 0 {internal => src/internal}/stage/stages.go | 0 {internal => src/internal}/stage/util.go | 0 {internal => src/internal}/stage/util_test.go | 0 {internal => src/internal}/tool/digests.json | 0 {internal => src/internal}/tool/registry.go | 0 {internal => src/internal}/tool/registry_test.go | 0 {internal => src/internal}/tui/tui.go | 0 {internal => src/internal}/ui/banner.go | 0 {internal => src/internal}/ui/banner_test.go | 0 {internal => src/internal}/ui/box.go | 0 {internal => src/internal}/ui/ui.go | 0 {internal => src/internal}/vcs/providers.go | 0 {internal => src/internal}/vcs/vcs.go | 0 {internal => src/internal}/vcs/vcs_test.go | 0 {internal => src/internal}/version/version.go | 0 {internal => src/internal}/version/version_test.go | 0 {pkg => src/pkg}/probaci/probaci.go | 0 release_workflow_test.go => src/release_workflow_test.go | 0 {scripts => src/scripts}/build-all.sh | 0 {scripts => src/scripts}/extract-changelog.sh | 0 {scripts => src/scripts}/install.ps1 | 0 {scripts => src/scripts}/install.sh | 0 {scripts => src/scripts}/pin-digests.sh | 0 77 files changed, 0 insertions(+), 0 deletions(-) rename .golangci.yml => src/.golangci.yml (100%) rename .goreleaser.yaml => src/.goreleaser.yaml (100%) rename assets.go => src/assets.go (100%) rename assets_test.go => src/assets_test.go (100%) rename {cmd => src/cmd}/probaci/main.go (100%) rename go.mod => src/go.mod (100%) rename go.sum => src/go.sum (100%) rename {internal => src/internal}/cli/config.go (100%) rename {internal => src/internal}/cli/doctor.go (100%) rename {internal => src/internal}/cli/errors.go (100%) rename {internal => src/internal}/cli/info.go (100%) rename {internal => src/internal}/cli/init.go (100%) rename {internal => src/internal}/cli/install.go (100%) rename {internal => src/internal}/cli/root.go (100%) rename {internal => src/internal}/cli/run.go (100%) rename {internal => src/internal}/cli/stage.go (100%) rename {internal => src/internal}/cli/targets.go (100%) rename {internal => src/internal}/cli/targets_test.go (100%) rename {internal => src/internal}/cli/tui.go (100%) rename {internal => src/internal}/config/config.go (100%) rename {internal => src/internal}/config/config_test.go (100%) rename {internal => src/internal}/config/defaults.go (100%) rename {internal => src/internal}/config/load.go (100%) rename {internal => src/internal}/config/load_test.go (100%) rename {internal => src/internal}/config/manage.go (100%) rename {internal => src/internal}/config/migrate.go (100%) rename {internal => src/internal}/config/migrate_test.go (100%) rename {internal => src/internal}/config/schema.go (100%) rename {internal => src/internal}/config/schema.json (100%) rename {internal => src/internal}/detect/detect.go (100%) rename {internal => src/internal}/detect/detect_test.go (100%) rename {internal => src/internal}/discover/root.go (100%) rename {internal => src/internal}/discover/root_test.go (100%) rename {internal => src/internal}/docker/broker.go (100%) rename {internal => src/internal}/docker/broker_test.go (100%) rename {internal => src/internal}/docker/trust.go (100%) rename {internal => src/internal}/docker/trust_test.go (100%) rename {internal => src/internal}/docker/user.go (100%) rename {internal => src/internal}/logging/logging.go (100%) rename {internal => src/internal}/logging/logging_test.go (100%) rename {internal => src/internal}/paths/paths.go (100%) rename {internal => src/internal}/paths/paths_test.go (100%) rename {internal => src/internal}/platform/adapters.go (100%) rename {internal => src/internal}/platform/helpers.go (100%) rename {internal => src/internal}/platform/platform.go (100%) rename {internal => src/internal}/report/report.go (100%) rename {internal => src/internal}/report/report_test.go (100%) rename {internal => src/internal}/result/result.go (100%) rename {internal => src/internal}/run/runid.go (100%) rename {internal => src/internal}/secret/redact.go (100%) rename {internal => src/internal}/secret/redact_test.go (100%) rename {internal => src/internal}/stage/engine.go (100%) rename {internal => src/internal}/stage/engine_test.go (100%) rename {internal => src/internal}/stage/registry_drift_test.go (100%) rename {internal => src/internal}/stage/stages.go (100%) rename {internal => src/internal}/stage/util.go (100%) rename {internal => src/internal}/stage/util_test.go (100%) rename {internal => src/internal}/tool/digests.json (100%) rename {internal => src/internal}/tool/registry.go (100%) rename {internal => src/internal}/tool/registry_test.go (100%) rename {internal => src/internal}/tui/tui.go (100%) rename {internal => src/internal}/ui/banner.go (100%) rename {internal => src/internal}/ui/banner_test.go (100%) rename {internal => src/internal}/ui/box.go (100%) rename {internal => src/internal}/ui/ui.go (100%) rename {internal => src/internal}/vcs/providers.go (100%) rename {internal => src/internal}/vcs/vcs.go (100%) rename {internal => src/internal}/vcs/vcs_test.go (100%) rename {internal => src/internal}/version/version.go (100%) rename {internal => src/internal}/version/version_test.go (100%) rename {pkg => src/pkg}/probaci/probaci.go (100%) rename release_workflow_test.go => src/release_workflow_test.go (100%) rename {scripts => src/scripts}/build-all.sh (100%) rename {scripts => src/scripts}/extract-changelog.sh (100%) rename {scripts => src/scripts}/install.ps1 (100%) rename {scripts => src/scripts}/install.sh (100%) rename {scripts => src/scripts}/pin-digests.sh (100%) 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 100% rename from .goreleaser.yaml rename to src/.goreleaser.yaml diff --git a/assets.go b/src/assets.go similarity index 100% rename from assets.go rename to src/assets.go diff --git a/assets_test.go b/src/assets_test.go similarity index 100% rename from assets_test.go rename to src/assets_test.go diff --git a/cmd/probaci/main.go b/src/cmd/probaci/main.go similarity index 100% rename from cmd/probaci/main.go rename to src/cmd/probaci/main.go diff --git a/go.mod b/src/go.mod similarity index 100% rename from go.mod rename to src/go.mod 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 100% rename from internal/cli/config.go rename to src/internal/cli/config.go diff --git a/internal/cli/doctor.go b/src/internal/cli/doctor.go similarity index 100% rename from internal/cli/doctor.go rename to src/internal/cli/doctor.go 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 100% rename from internal/cli/info.go rename to src/internal/cli/info.go diff --git a/internal/cli/init.go b/src/internal/cli/init.go similarity index 100% rename from internal/cli/init.go rename to src/internal/cli/init.go 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 100% rename from internal/cli/root.go rename to src/internal/cli/root.go diff --git a/internal/cli/run.go b/src/internal/cli/run.go similarity index 100% rename from internal/cli/run.go rename to src/internal/cli/run.go diff --git a/internal/cli/stage.go b/src/internal/cli/stage.go similarity index 100% rename from internal/cli/stage.go rename to src/internal/cli/stage.go diff --git a/internal/cli/targets.go b/src/internal/cli/targets.go similarity index 100% rename from internal/cli/targets.go rename to src/internal/cli/targets.go 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 100% rename from internal/cli/tui.go rename to src/internal/cli/tui.go 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 100% rename from internal/docker/broker.go rename to src/internal/docker/broker.go diff --git a/internal/docker/broker_test.go b/src/internal/docker/broker_test.go similarity index 100% rename from internal/docker/broker_test.go rename to src/internal/docker/broker_test.go 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 100% rename from internal/docker/trust_test.go rename to src/internal/docker/trust_test.go 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 100% rename from internal/platform/adapters.go rename to src/internal/platform/adapters.go diff --git a/internal/platform/helpers.go b/src/internal/platform/helpers.go similarity index 100% rename from internal/platform/helpers.go rename to src/internal/platform/helpers.go diff --git a/internal/platform/platform.go b/src/internal/platform/platform.go similarity index 100% rename from internal/platform/platform.go rename to src/internal/platform/platform.go diff --git a/internal/report/report.go b/src/internal/report/report.go similarity index 100% rename from internal/report/report.go rename to src/internal/report/report.go diff --git a/internal/report/report_test.go b/src/internal/report/report_test.go similarity index 100% rename from internal/report/report_test.go rename to src/internal/report/report_test.go 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 100% rename from internal/stage/engine.go rename to src/internal/stage/engine.go diff --git a/internal/stage/engine_test.go b/src/internal/stage/engine_test.go similarity index 100% rename from internal/stage/engine_test.go rename to src/internal/stage/engine_test.go diff --git a/internal/stage/registry_drift_test.go b/src/internal/stage/registry_drift_test.go similarity index 100% rename from internal/stage/registry_drift_test.go rename to src/internal/stage/registry_drift_test.go diff --git a/internal/stage/stages.go b/src/internal/stage/stages.go similarity index 100% rename from internal/stage/stages.go rename to src/internal/stage/stages.go diff --git a/internal/stage/util.go b/src/internal/stage/util.go similarity index 100% rename from internal/stage/util.go rename to src/internal/stage/util.go diff --git a/internal/stage/util_test.go b/src/internal/stage/util_test.go similarity index 100% rename from internal/stage/util_test.go rename to src/internal/stage/util_test.go 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 100% rename from internal/tool/registry.go rename to src/internal/tool/registry.go diff --git a/internal/tool/registry_test.go b/src/internal/tool/registry_test.go similarity index 100% rename from internal/tool/registry_test.go rename to src/internal/tool/registry_test.go diff --git a/internal/tui/tui.go b/src/internal/tui/tui.go similarity index 100% rename from internal/tui/tui.go rename to src/internal/tui/tui.go 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 100% rename from internal/ui/ui.go rename to src/internal/ui/ui.go 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 100% rename from pkg/probaci/probaci.go rename to src/pkg/probaci/probaci.go diff --git a/release_workflow_test.go b/src/release_workflow_test.go similarity index 100% rename from release_workflow_test.go rename to src/release_workflow_test.go diff --git a/scripts/build-all.sh b/src/scripts/build-all.sh similarity index 100% rename from scripts/build-all.sh rename to src/scripts/build-all.sh diff --git a/scripts/extract-changelog.sh b/src/scripts/extract-changelog.sh similarity index 100% rename from scripts/extract-changelog.sh rename to src/scripts/extract-changelog.sh diff --git a/scripts/install.ps1 b/src/scripts/install.ps1 similarity index 100% rename from scripts/install.ps1 rename to src/scripts/install.ps1 diff --git a/scripts/install.sh b/src/scripts/install.sh similarity index 100% rename from scripts/install.sh rename to src/scripts/install.sh diff --git a/scripts/pin-digests.sh b/src/scripts/pin-digests.sh similarity index 100% rename from scripts/pin-digests.sh rename to src/scripts/pin-digests.sh From 0c93b65e2c8f8fe4331e069cca1049dd45e69da8 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Sat, 7 Nov 2026 23:57:59 -0500 Subject: [PATCH 2/8] Point the module, tooling and workflows at src/ With the module in src/, its path becomes github.com/simtabi/probaci/src: GitHub's go-import tag has no subdir field, so Go treats the rest of the path as a directory. Imports, the -X ldflags and GoReleaser follow it. Everything that runs go now names the module: the Makefile and Taskfile use go -C src, CI and CodeQL point setup-go's cache at src/go.sum and golangci-lint at src/, CodeQL builds in manual mode because autobuild does not look in src/, and Dependabot's gomod entry reads /src. A bare `go test ./...` at the root would find no packages and pass. The root package embeds README.md and docs/ for `probaci docs`, and //go:embed cannot reach above src/, while the docs must stay at the root for GitHub and the docs site. They are mirrored into src/embedded/ by `make sync-docs`, and TestEmbeddedDocsMatchTheRepository fails when the copy drifts in either direction. release.yml mirrors src/vX.Y.Z onto the release commit, since that is the only tag Go resolves a subdirectory module through, writes the CHANGELOG notes to $RUNNER_TEMP so GoReleaser does not see a dirty tree, and sets GOPRIVATE for the org's modules. It is still dispatch-only. setup-go stays on go-version "1.25" rather than go-version-file: go.mod says go 1.25.0, which setup-go installs exactly, and the govulncheck and gosec pins rely on the newest 1.25 patch. --- .github/dependabot.yml | 2 +- .github/workflows/ci.yml | 22 +++- .github/workflows/codeql.yml | 9 +- .github/workflows/release.yml | 30 ++++- Makefile | 36 ++++-- Taskfile.yml | 29 ++--- src/.goreleaser.yaml | 13 +- src/assets.go | 16 ++- src/assets_test.go | 61 ++++++++- src/cmd/probaci/main.go | 2 +- src/embedded/README.md | 144 ++++++++++++++++++++++ src/embedded/docs/architecture.md | 60 +++++++++ src/embedded/docs/configuration.md | 116 +++++++++++++++++ src/embedded/docs/installation.md | 107 ++++++++++++++++ src/embedded/docs/release.md | 100 +++++++++++++++ src/embedded/docs/security.md | 61 +++++++++ src/embedded/docs/tools/probaci.md | 61 +++++++++ src/go.mod | 2 +- src/internal/cli/config.go | 4 +- src/internal/cli/doctor.go | 6 +- src/internal/cli/info.go | 14 +-- src/internal/cli/init.go | 4 +- src/internal/cli/root.go | 6 +- src/internal/cli/run.go | 12 +- src/internal/cli/stage.go | 6 +- src/internal/cli/targets.go | 4 +- src/internal/cli/tui.go | 6 +- src/internal/docker/broker.go | 2 +- src/internal/docker/broker_test.go | 2 +- src/internal/docker/trust_test.go | 2 +- src/internal/platform/adapters.go | 2 +- src/internal/platform/helpers.go | 4 +- src/internal/platform/platform.go | 6 +- src/internal/report/report.go | 4 +- src/internal/report/report_test.go | 4 +- src/internal/stage/engine.go | 14 +-- src/internal/stage/engine_test.go | 6 +- src/internal/stage/registry_drift_test.go | 4 +- src/internal/stage/stages.go | 14 +-- src/internal/stage/util.go | 2 +- src/internal/stage/util_test.go | 2 +- src/internal/tool/registry.go | 6 +- src/internal/tool/registry_test.go | 2 +- src/internal/tui/tui.go | 6 +- src/internal/ui/ui.go | 2 +- src/pkg/probaci/probaci.go | 16 +-- src/release_workflow_test.go | 2 +- src/scripts/build-all.sh | 14 ++- src/scripts/extract-changelog.sh | 2 +- src/scripts/install.ps1 | 2 +- src/scripts/install.sh | 6 +- src/scripts/pin-digests.sh | 4 +- 52 files changed, 922 insertions(+), 141 deletions(-) create mode 100644 src/embedded/README.md create mode 100644 src/embedded/docs/architecture.md create mode 100644 src/embedded/docs/configuration.md create mode 100644 src/embedded/docs/installation.md create mode 100644 src/embedded/docs/release.md create mode 100644 src/embedded/docs/security.md create mode 100644 src/embedded/docs/tools/probaci.md diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b9f9832..9fe7eef 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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5ecf96..e09cb87 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,7 +103,7 @@ 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 @@ -118,4 +125,7 @@ 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 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..949a108 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -62,6 +68,7 @@ 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 @@ -74,15 +81,34 @@ jobs: 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. diff --git a/Makefile b/Makefile index 0d23b44..78faa82 100644 --- a/Makefile +++ b/Makefile @@ -6,34 +6,36 @@ TASK := $(shell command -v task 2>/dev/null) BIN := bin/probaci PKG := ./cmd/probaci +# The module lives in src/: every go command names it with -C src, and a bare +# `go test ./...` at the root would find no packages and pass. VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) -LDFLAGS := -s -w -X github.com/simtabi/probaci/internal/version.Version=$(VERSION) +LDFLAGS := -s -w -X github.com/simtabi/probaci/src/internal/version.Version=$(VERSION) -.PHONY: build install test cover vet lint fmt tidy bundle release-check snapshot clean help +.PHONY: build install test cover vet lint fmt tidy bundle release-check snapshot clean help sync-docs ifeq ($(TASK),) # --- fallback: invoke the Go toolchain directly --- build: - go build -ldflags "$(LDFLAGS)" -o $(BIN) $(PKG) + go -C src build -ldflags "$(LDFLAGS)" -o ../$(BIN) $(PKG) install: - go install $(PKG) + go -C src install $(PKG) test: - go test -race ./... + go -C src test -race ./... cover: - go test -coverprofile=coverage.txt ./... + go -C src test -coverprofile=../coverage.txt ./... vet: - go vet ./... + go -C src vet ./... fmt: - gofmt -w . + gofmt -w src tidy: - go mod tidy + go -C src mod tidy lint: - golangci-lint run + cd src && golangci-lint run ./... release-check: - goreleaser check + goreleaser check --config src/.goreleaser.yaml bundle: - goreleaser release --snapshot --clean --skip=publish,sign,docker,sbom + goreleaser release --config src/.goreleaser.yaml --snapshot --clean --skip=publish,sign,docker,sbom snapshot: bundle clean: rm -rf bin dist coverage.txt @@ -43,6 +45,16 @@ build install test cover vet lint fmt tidy bundle release-check snapshot clean: @$(TASK) $@ endif +# 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: + 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/ + 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" diff --git a/Taskfile.yml b/Taskfile.yml index a50ecdc..7b8328e 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -6,11 +6,12 @@ version: "3" vars: BIN: bin/probaci PKG: ./cmd/probaci - MODULE: github.com/simtabi/probaci + # The module lives in src/; every go command below names it with -C src. + MODULE: github.com/simtabi/probaci/src # 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}} + LDFLAGS: -s -w -X github.com/simtabi/probaci/src/internal/version.Version={{.VERSION}} tasks: default: @@ -19,53 +20,53 @@ tasks: build: desc: Build the probaci binary into ./bin (version-stamped) cmds: - - go build -ldflags "{{.LDFLAGS}}" -o {{.BIN}} {{.PKG}} + - go -C src build -ldflags "{{.LDFLAGS}}" -o ../{{.BIN}} {{.PKG}} install: desc: Install probaci into GOBIN/PATH via the Go toolchain cmds: - - go install {{.PKG}} + - go -C src install {{.PKG}} run: desc: Run probaci from source (pass args after --) cmds: - - go run {{.PKG}} {{.CLI_ARGS}} + - go -C src run {{.PKG}} {{.CLI_ARGS}} test: desc: Run the test suite with the race detector cmds: - - go test -race ./... + - go -C src test -race ./... cover: desc: Run tests with coverage cmds: - - go test -coverprofile=coverage.txt ./... - - go tool cover -func=coverage.txt | tail -1 + - go -C src test -coverprofile=../coverage.txt ./... + - go -C src tool cover -func=../coverage.txt | tail -1 vet: desc: Run go vet cmds: - - go vet ./... + - go -C src vet ./... lint: desc: Run golangci-lint (must be installed) cmds: - - golangci-lint run + - cd src && golangci-lint run ./... fmt: desc: Format the codebase cmds: - - gofmt -w . + - gofmt -w src tidy: desc: Tidy go.mod/go.sum cmds: - - go mod tidy + - go -C src mod tidy release-check: desc: Validate the GoReleaser configuration cmds: - - goreleaser check + - goreleaser check --config src/.goreleaser.yaml bundle: desc: Build local release deliverables (archives + checksums) into ./dist @@ -73,7 +74,7 @@ tasks: # 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 + - goreleaser release --config src/.goreleaser.yaml --snapshot --clean --skip=publish,sign,docker,sbom snapshot: desc: Alias for `bundle` (local multi-arch deliverables, no publish) diff --git a/src/.goreleaser.yaml b/src/.goreleaser.yaml index 578bb2d..195470c 100644 --- a/src/.goreleaser.yaml +++ b/src/.goreleaser.yaml @@ -1,3 +1,7 @@ +# 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 @@ -10,19 +14,20 @@ dist: build/dist 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"] diff --git a/src/assets.go b/src/assets.go index d1e0402..dd07ba1 100644 --- a/src/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 index debfa66..53f7dc9 100644 --- a/src/assets_test.go +++ b/src/assets_test.go @@ -1,6 +1,13 @@ package probaci -import "testing" +import ( + "bytes" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" +) func TestDocLookup(t *testing.T) { for _, topic := range DocTopics() { @@ -19,3 +26,55 @@ func TestDocLookup(t *testing.T) { 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/src/cmd/probaci/main.go b/src/cmd/probaci/main.go index d48c69f..adff06b 100644 --- a/src/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..89782a4 --- /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 `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) +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..3c42b14 --- /dev/null +++ b/src/embedded/docs/architecture.md @@ -0,0 +1,60 @@ +# 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 + +| 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..80d282f --- /dev/null +++ b/src/embedded/docs/installation.md @@ -0,0 +1,107 @@ +# 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 `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) +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. + +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`. + +### 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/bin/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, and `scripts/install.sh` / `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. +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 + +One task definition, three entrypoints: + +```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 +``` + +Produce local release deliverables (archives + checksums in `dist/`) with +`task bundle`. 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..35be87f --- /dev/null +++ b/src/embedded/docs/release.md @@ -0,0 +1,100 @@ +# 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 downloadable archives locally — useful for testing the binaries users +will get: + +```sh +task bundle # or: make bundle +``` + +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`). + +## Install scripts + +`install.sh` / `install.ps1` (repo root) 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 +``` + +## Pinning tool-image digests + +The built-in tool images ship pinned by digest (`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 +``` + +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, container image, deb/rpm, and Homebrew/Scoop manifests. + +## 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 +`internal/version`; `probaci version` prints it. + +--- + +[← 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/src/go.mod b/src/go.mod index 7cce2df..3ab7797 100644 --- a/src/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/src/internal/cli/config.go b/src/internal/cli/config.go index 97fb7e4..05e5fe2 100644 --- a/src/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/src/internal/cli/doctor.go b/src/internal/cli/doctor.go index 05504b3..529dbf7 100644 --- a/src/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/src/internal/cli/info.go b/src/internal/cli/info.go index 4ade806..bd61763 100644 --- a/src/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/src/internal/cli/init.go b/src/internal/cli/init.go index d97ff14..8b5ff84 100644 --- a/src/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/src/internal/cli/root.go b/src/internal/cli/root.go index 266696e..671ecb2 100644 --- a/src/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/src/internal/cli/run.go b/src/internal/cli/run.go index caaa7f2..4acb523 100644 --- a/src/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/src/internal/cli/stage.go b/src/internal/cli/stage.go index 9be5049..5033d2c 100644 --- a/src/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/src/internal/cli/targets.go b/src/internal/cli/targets.go index 5d936a8..4e5f16d 100644 --- a/src/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/src/internal/cli/tui.go b/src/internal/cli/tui.go index f804ce0..1d91f74 100644 --- a/src/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/src/internal/docker/broker.go b/src/internal/docker/broker.go index 1349ddb..2b48f5a 100644 --- a/src/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/src/internal/docker/broker_test.go b/src/internal/docker/broker_test.go index 8b34856..1abe6b7 100644 --- a/src/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/src/internal/docker/trust_test.go b/src/internal/docker/trust_test.go index 8553eea..0e18fde 100644 --- a/src/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/src/internal/platform/adapters.go b/src/internal/platform/adapters.go index ad543b5..7d422d0 100644 --- a/src/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/src/internal/platform/helpers.go b/src/internal/platform/helpers.go index 5d090ad..943755a 100644 --- a/src/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/src/internal/platform/platform.go b/src/internal/platform/platform.go index d816bc3..7bb15f5 100644 --- a/src/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/report/report.go b/src/internal/report/report.go index a9d94ea..39aca7a 100644 --- a/src/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/src/internal/report/report_test.go b/src/internal/report/report_test.go index 03f52fa..d1c294b 100644 --- a/src/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/src/internal/stage/engine.go b/src/internal/stage/engine.go index 5407b29..7065dd4 100644 --- a/src/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/src/internal/stage/engine_test.go b/src/internal/stage/engine_test.go index cd02172..29881a7 100644 --- a/src/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/src/internal/stage/registry_drift_test.go b/src/internal/stage/registry_drift_test.go index 8b6b6cf..0308277 100644 --- a/src/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/src/internal/stage/stages.go b/src/internal/stage/stages.go index 7e5e3aa..f02487c 100644 --- a/src/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/src/internal/stage/util.go b/src/internal/stage/util.go index 0d41e8f..003e338 100644 --- a/src/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/src/internal/stage/util_test.go b/src/internal/stage/util_test.go index fdb4fcf..02acbb3 100644 --- a/src/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/src/internal/tool/registry.go b/src/internal/tool/registry.go index b2af9fa..e85bde2 100644 --- a/src/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/src/internal/tool/registry_test.go b/src/internal/tool/registry_test.go index 8c9c31a..fb3c402 100644 --- a/src/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/src/internal/tui/tui.go b/src/internal/tui/tui.go index 2656817..df1a38e 100644 --- a/src/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/src/internal/ui/ui.go b/src/internal/ui/ui.go index b200563..bd87d7d 100644 --- a/src/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/src/pkg/probaci/probaci.go b/src/pkg/probaci/probaci.go index 7775bfb..b593c1e 100644 --- a/src/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/release_workflow_test.go b/src/release_workflow_test.go index 0dbcd0d..bfada4c 100644 --- a/src/release_workflow_test.go +++ b/src/release_workflow_test.go @@ -13,7 +13,7 @@ import ( // 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")) + wf, err := os.ReadFile(filepath.Join("..", ".github", "workflows", "release.yml")) if err != nil { t.Fatal(err) } diff --git a/src/scripts/build-all.sh b/src/scripts/build-all.sh index dce80d1..0763b5f 100755 --- a/src/scripts/build-all.sh +++ b/src/scripts/build-all.sh @@ -18,17 +18,19 @@ # (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 +# VERSION=v1.2.3 ./src/scripts/build-all.sh # normal +# ARCHIVES=0 ./src/scripts/build-all.sh # binaries only, skip archives +# 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 -cd "$(dirname "$0")/.." +# The script lives in src/scripts/; build/ and the docs it bundles are at the +# repository root, and the module it compiles is src/. +cd "$(dirname "$0")/../.." BINARY="probaci" -PKG="github.com/simtabi/probaci/internal/version" +PKG="github.com/simtabi/probaci/src/internal/version" BUILD_DIR="build" OUT="${BUILD_DIR}/dist" ARCHIVE_DIR="${OUT}/archives" @@ -134,7 +136,7 @@ while IFS=$' \t\r' read -r goos goarch goarm || [ -n "${goos:-}" ]; do printf ' -> %-32s' "$name" GOOS="$goos" GOARCH="$goarch" GOARM="${goarm:-}" CGO_ENABLED=0 \ - go build -trimpath -ldflags "$LDFLAGS" -o "${OUT}/${name}" ./cmd/probaci + go -C src build -trimpath -ldflags "$LDFLAGS" -o "${PWD}/${OUT}/${name}" ./cmd/probaci echo "ok" make_archive "$name" "$goos" diff --git a/src/scripts/extract-changelog.sh b/src/scripts/extract-changelog.sh index b5c7c48..bcedd5d 100755 --- a/src/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/src/scripts/install.ps1 b/src/scripts/install.ps1 index a5b96b5..beac604 100644 --- a/src/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/src/scripts/install.sh b/src/scripts/install.sh index b0a55bf..a91bb5d 100755 --- a/src/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/src/scripts/pin-digests.sh b/src/scripts/pin-digests.sh index 8cae1f9..e41bc70 100755 --- a/src/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)) ' From 885d04f91dbf131baac6d800d6f18c3ce4d89283 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Sun, 8 Nov 2026 00:00:41 -0500 Subject: [PATCH 3/8] Build into build// and move GoReleaser's dist out of build/ The simtabi Go layout gives build/ one folder per OS and nothing else: build/{macos,linux,windows}/probaci_{os}_{arch}[.exe], one build/checksums.txt over {os}/{file} paths so `cd build && shasum -a 256 -c checksums.txt` verifies as-is, and build/archives/ only from `make dist`. build/dist/ is gone: GoReleaser now writes its scratch dist to .cache/goreleaser (gitignored), and the release attestation reads the checksums from there. The Makefile is now the one definition of every task, with build, cross, dist and check-build added; `make build` writes build//probaci and `make cross`/`make dist` go through src/scripts/build-all.sh, never GoReleaser. The Taskfile keeps every task name and forwards to make. The build script no longer deletes build/targets.txt; it writes the default matrix only when the file is missing. TestBuildLayout fails on anything else in build/, and its negative test plants each wrong shape (dist/, darwin/, a per-target folder, a loose binary, GoReleaser metadata, a nested macos build) to prove it can fail. make build, cross and dist run it, and a new CI snapshot job runs `make cross`, verifies the checksums and uploads the binaries. --- .github/workflows/ci.yml | 28 ++++++ .github/workflows/release.yml | 2 +- .gitignore | 9 +- Makefile | 135 ++++++++++++++++++--------- Taskfile.yml | 85 ++++++++--------- build/targets.txt | 10 +- src/.goreleaser.yaml | 10 +- src/internal/release/layout_test.go | 126 +++++++++++++++++++++++++ src/scripts/build-all.sh | 140 ++++++++++++++++------------ 9 files changed, 379 insertions(+), 166 deletions(-) create mode 100644 src/internal/release/layout_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e09cb87..c8afd3d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,3 +129,31 @@ jobs: # 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/release.yml b/.github/workflows/release.yml index 949a108..ced8778 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -123,4 +123,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/Makefile b/Makefile index 78faa82..272af22 100644 --- a/Makefile +++ b/Makefile @@ -1,60 +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 -# The module lives in src/: every go command names it with -C src, and a bare -# `go test ./...` at the root would find no packages and pass. - -VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) -LDFLAGS := -s -w -X github.com/simtabi/probaci/src/internal/version.Version=$(VERSION) - -.PHONY: build install test cover vet lint fmt tidy bundle release-check snapshot clean help sync-docs - -ifeq ($(TASK),) -# --- fallback: invoke the Go toolchain directly --- -build: - go -C src build -ldflags "$(LDFLAGS)" -o ../$(BIN) $(PKG) -install: - go -C src install $(PKG) -test: - go -C src test -race ./... -cover: +# 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 ./... -vet: + go -C src tool cover -func=../coverage.txt | tail -1 + +vet: ## go vet go -C src vet ./... -fmt: + +fmt: ## gofmt gofmt -w src -tidy: - go -C src mod tidy -lint: + +lint: ## golangci-lint (must be installed) cd src && golangci-lint run ./... -release-check: - goreleaser check --config src/.goreleaser.yaml -bundle: - goreleaser release --config src/.goreleaser.yaml --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 + +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: +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/ -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" +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,docker,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/Taskfile.yml b/Taskfile.yml index 7b8328e..d52f579 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -1,87 +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 - # The module lives in src/; every go command below names it with -C src. - MODULE: github.com/simtabi/probaci/src - # 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/src/internal/version.Version={{.VERSION}} - tasks: default: cmds: [task: build] build: - desc: Build the probaci binary into ./bin (version-stamped) - cmds: - - go -C src 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 -C src install {{.PKG}} + desc: Install probaci into GOBIN from this checkout + cmds: [make install] run: desc: Run probaci from source (pass args after --) cmds: - - go -C src run {{.PKG}} {{.CLI_ARGS}} + - go -C src run ./cmd/probaci {{.CLI_ARGS}} test: desc: Run the test suite with the race detector - cmds: - - go -C src test -race ./... + cmds: [make test] cover: desc: Run tests with coverage - cmds: - - go -C src test -coverprofile=../coverage.txt ./... - - go -C src tool cover -func=../coverage.txt | tail -1 + cmds: [make cover] vet: desc: Run go vet - cmds: - - go -C src vet ./... + cmds: [make vet] lint: desc: Run golangci-lint (must be installed) - cmds: - - cd src && golangci-lint run ./... + cmds: [make lint] fmt: desc: Format the codebase - cmds: - - gofmt -w src + cmds: [make fmt] tidy: - desc: Tidy go.mod/go.sum - cmds: - - go -C src 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 --config src/.goreleaser.yaml + 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 --config src/.goreleaser.yaml --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/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/src/.goreleaser.yaml b/src/.goreleaser.yaml index 195470c..73efcfd 100644 --- a/src/.goreleaser.yaml +++ b/src/.goreleaser.yaml @@ -8,9 +8,11 @@ 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: @@ -88,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 }}" 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/scripts/build-all.sh b/src/scripts/build-all.sh index 0763b5f..8777136 100755 --- a/src/scripts/build-all.sh +++ b/src/scripts/build-all.sh @@ -1,49 +1,57 @@ #!/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/: +# 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). # -# 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 +# build/ holds one folder per OS and nothing else: # -# Naming: darwin -> macos, arm -> armvN, .exe on windows. Static -# (CGO_ENABLED=0), reproducible (-trimpath), stripped (-s -w), with version -# metadata embedded. +# 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 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. +# 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. # -# VERSION=v1.2.3 ./src/scripts/build-all.sh # normal -# ARCHIVES=0 ./src/scripts/build-all.sh # binaries only, skip archives -# TARGETS=path/to/list ./src/scripts/build-all.sh # use an external target list +# 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 docs it bundles are at the +# The script lives in src/scripts/; build/ and the files it bundles are at the # repository root, and the module it compiles is src/. -cd "$(dirname "$0")/../.." +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" BINARY="probaci" PKG="github.com/simtabi/probaci/src/internal/version" BUILD_DIR="build" -OUT="${BUILD_DIR}/dist" -ARCHIVE_DIR="${OUT}/archives" +ARCHIVE_DIR="${BUILD_DIR}/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. +# 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 @@ -58,15 +66,16 @@ 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")" +mkdir -p "$BUILD_DIR" +if [ -n "${TARGETS:-}" ]; then + [ -f "$TARGETS" ] || { echo "error: no target list at ${TARGETS}" >&2; exit 1; } + MATRIX_FILE="$TARGETS" else - MATRIX="$DEFAULT_TARGETS" + 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. @@ -87,22 +96,24 @@ 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" +# --- 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" -printf '%s\n' "$MATRIX" > "$TARGETS_FILE" # regenerate the target list -echo "==> regenerated ${TARGETS_FILE}" -# make_archive +# make_archive make_archive() { [ "$WITH_ARCHIVES" = "1" ] || return 0 - bin="$1"; goos="$2" + folder="$1"; bin="$2"; goos="$3" base="${bin%.exe}" # probaci_macos_arm64 - stage="$(mktemp -d)" - cp "${OUT}/${bin}" "${stage}/${bin}" + 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 @@ -110,7 +121,7 @@ make_archive() { ( cd "$stage" && zip -qr "${base}.zip" . ) mv "${stage}/${base}.zip" "${ARCHIVE_DIR}/" else - echo " ! 'zip' not found — skipping ${base}.zip" >&2 + echo " ! 'zip' not found; skipping ${base}.zip" >&2 fi else tar -czf "${ARCHIVE_DIR}/${base}.tar.gz" -C "$stage" . @@ -133,17 +144,19 @@ while IFS=$' \t\r' read -r goos goarch goarm || [ -n "${goos:-}" ]; do ext=""; [ "$goos" = "windows" ] && ext=".exe" name="${BINARY}_${os_token}_${arch_token}${ext}" - printf ' -> %-32s' "$name" + 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 "${PWD}/${OUT}/${name}" ./cmd/probaci + go -C src build -trimpath -ldflags "$LDFLAGS" \ + -o "${ROOT}/${BUILD_DIR}/${os_token}/${name}" ./cmd/probaci echo "ok" - make_archive "$name" "$goos" + 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 < "$TARGETS_FILE" +done < "$MATRIX_FILE" [ "$count" -gt 0 ] || { echo "error: no targets built (empty matrix?)" >&2; exit 1; } @@ -151,22 +164,25 @@ done < "$TARGETS_FILE" 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" + 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 "$uni" "darwin" + make_archive macos "$uni" "darwin" else - echo " ! 'lipo' not available (non-macOS host) — skipping macos_universal" >&2 + 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" +# --- 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 ) + ( 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}" )" +echo "==> done: ${count} binaries in ${BUILD_DIR}//$( [ "$WITH_ARCHIVES" = "1" ] && echo ", archives in ${ARCHIVE_DIR}" )" From bb8ebd1d904eef7bb022029249c64e189e84a966 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Sun, 8 Nov 2026 00:01:28 -0500 Subject: [PATCH 4/8] Remove probaci's container image The owner decided probaci ships as a binary built from source and no longer builds a container image. Removed: the Dockerfile, the GoReleaser dockers_v2 block (ghcr.io/simtabi/probaci, linux/amd64 and arm64), release.yml's QEMU, buildx and ghcr.io login steps with the packages: write permission they needed, Dependabot's docker ecosystem, the --skip=docker flag of `make bundle`, and the docs lines offering the image. No image was ever published: the release workflow has never run, the repository has no releases, and the packages API answers 404 for simtabi/probaci, so there is nothing on ghcr.io to freeze or delete. The CI gitleaks job ran gitleaks from a container. The scan stays: it now installs gitleaks v8.30.1 with go install and runs the same command, so the repository runs no container anywhere. probaci's own feature of brokering tools through the host's container runtime is untouched. --- .github/dependabot.yml | 11 ----------- .github/workflows/ci.yml | 12 +++++++++--- .github/workflows/release.yml | 12 +----------- Dockerfile | 7 ------- Makefile | 2 +- docs/installation.md | 2 +- docs/release.md | 3 ++- src/.goreleaser.yaml | 18 ------------------ 8 files changed, 14 insertions(+), 53 deletions(-) delete mode 100644 Dockerfile diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9fe7eef..28154d9 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -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 c8afd3d..071a16c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,11 +109,17 @@ jobs: 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/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ced8778..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. @@ -47,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: @@ -72,15 +71,6 @@ jobs: - 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, 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 272af22..7913d93 100644 --- a/Makefile +++ b/Makefile @@ -93,7 +93,7 @@ release-check: ## Validate the GoReleaser configuration (a check only; builds no # 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,docker,sbom + goreleaser release --config src/.goreleaser.yaml --snapshot --clean --skip=publish,sign,sbom snapshot: bundle ## Alias for bundle diff --git a/docs/installation.md b/docs/installation.md index 80d282f..5508458 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -55,7 +55,7 @@ so a build from a branch is still identifiable. No binary release has been published. The release tooling stays in the tree: the GoReleaser config, and `scripts/install.sh` / `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). diff --git a/docs/release.md b/docs/release.md index 35be87f..d8353cb 100644 --- a/docs/release.md +++ b/docs/release.md @@ -70,7 +70,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 diff --git a/src/.goreleaser.yaml b/src/.goreleaser.yaml index 73efcfd..9f5f44e 100644 --- a/src/.goreleaser.yaml +++ b/src/.goreleaser.yaml @@ -121,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 From 70789c2d66c3edd087a85887a973c6718a835e80 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Sun, 8 Nov 2026 00:01:49 -0500 Subject: [PATCH 5/8] Sync the embedded docs copy with the image removal The previous commit edited docs/installation.md and docs/release.md without refreshing src/embedded/, and TestEmbeddedDocsMatchTheRepository caught the drift. This is `make sync-docs`, nothing else. --- src/embedded/docs/installation.md | 2 +- src/embedded/docs/release.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/embedded/docs/installation.md b/src/embedded/docs/installation.md index 80d282f..5508458 100644 --- a/src/embedded/docs/installation.md +++ b/src/embedded/docs/installation.md @@ -55,7 +55,7 @@ so a build from a branch is still identifiable. No binary release has been published. The release tooling stays in the tree: the GoReleaser config, and `scripts/install.sh` / `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). diff --git a/src/embedded/docs/release.md b/src/embedded/docs/release.md index 35be87f..d8353cb 100644 --- a/src/embedded/docs/release.md +++ b/src/embedded/docs/release.md @@ -70,7 +70,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 From b5e4dc9483778055976d15c59075f628a56a25c2 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Sun, 8 Nov 2026 00:01:56 -0500 Subject: [PATCH 6/8] Move the release guard beside the other build guards TestReleaseRunsOnlyOnAConfirmedDispatch sat in the root package because the module used to start at the repository root. The build-layout guard now lives in src/internal/release, so the release guard joins it there and finds release.yml through the same repoRoot helper. Only the package clause and the path lookup change; the assertions are untouched. --- .../release/workflow_test.go} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename src/{release_workflow_test.go => internal/release/workflow_test.go} (89%) diff --git a/src/release_workflow_test.go b/src/internal/release/workflow_test.go similarity index 89% rename from src/release_workflow_test.go rename to src/internal/release/workflow_test.go index bfada4c..a657cd7 100644 --- a/src/release_workflow_test.go +++ b/src/internal/release/workflow_test.go @@ -1,4 +1,4 @@ -package probaci +package release_test import ( "os" @@ -13,7 +13,7 @@ import ( // 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")) + wf, err := os.ReadFile(filepath.Join(repoRoot(t), ".github", "workflows", "release.yml")) if err != nil { t.Fatal(err) } From 6494bc882c74b8547a1693301ee302ffecb31d66 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Sun, 8 Nov 2026 00:04:00 -0500 Subject: [PATCH 7/8] Guard the release trigger, the image removal and the docs TestReleaseRunsOnlyOnAConfirmedDispatch keeps its assertions and gains two: pull_request_target and workflow_run count as automatic triggers, and the refusal (confirm=release-go on a v[0-9]* tag, then exit 1) has to be the first step, before anything is checked out or built. TestNoContainerImage walks the repository for a Dockerfile, Containerfile, .dockerignore or compose file, and reads every workflow and src/.goreleaser.yaml for docker/ actions, docker build/run/push/login, ghcr.io and dockers/dockers_v2/docker_manifests blocks, and dependabot.yml for a docker ecosystem. It asserts it visited at least 80 files (116 today) and at least 5 workflows, so a walk rooted in the wrong place cannot pass. TestDocsOfferNoGoInstall fails when README, CONTRIBUTING or a docs page offers `go install github.com/...` while releases are on hold, with a floor of 5 docs pages. Each was proven red by planting its defect (a tag push trigger, a step before the refusal, a Dockerfile, docker run in ci.yml, a dockers_v2 block, a docker Dependabot ecosystem, a go install line) and green after reverting. --- src/internal/release/workflow_test.go | 132 ++++++++++++++++++++++++-- 1 file changed, 122 insertions(+), 10 deletions(-) diff --git a/src/internal/release/workflow_test.go b/src/internal/release/workflow_test.go index a657cd7..182b7d2 100644 --- a/src/internal/release/workflow_test.go +++ b/src/internal/release/workflow_test.go @@ -1,28 +1,140 @@ package release_test import ( - "os" + "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 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. +// 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, err := os.ReadFile(filepath.Join(repoRoot(t), ".github", "workflows", "release.yml")) + 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) } - 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") + // 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) } - 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) + + 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) } } } From fec3e21136b37f9f9c9c77d1fad7d49d029d5521 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Sun, 8 Nov 2026 00:05:05 -0500 Subject: [PATCH 8/8] Document the src/ module and the build// layout README, the installation and release guides, the architecture page, CONTRIBUTING and the pull request template now name src/ for the module, build//probaci for the host build, make cross and make dist for the OS folders and archives, .cache/goreleaser for GoReleaser's scratch output, and src/scripts/ for the install and pin scripts. The release guide explains the src/vX.Y.Z tag, and CONTRIBUTING explains `make sync-docs`. They still say what they said before about releases: binary releases are on hold, probaci is built from source, and no go install path is offered. CHANGELOG [Unreleased] records the move, the new guards and the removed container image. src/embedded/ is resynced. --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- CHANGELOG.md | 25 ++++++++++++++++++ CONTRIBUTING.md | 34 +++++++++++++++++------- README.md | 6 ++--- docs/architecture.md | 5 ++++ docs/installation.md | 39 +++++++++++++++------------ docs/release.md | 44 +++++++++++++++++++++---------- src/embedded/README.md | 6 ++--- src/embedded/docs/architecture.md | 5 ++++ src/embedded/docs/installation.md | 39 +++++++++++++++------------ src/embedded/docs/release.md | 44 +++++++++++++++++++++---------- 11 files changed, 170 insertions(+), 79 deletions(-) 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/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/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/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 5508458..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,7 +55,8 @@ 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 and deb/rpm packages. probaci no longer builds a container image. None of those channels is offered while binary releases are on hold; see @@ -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 d8353cb..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 @@ -94,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/src/embedded/README.md b/src/embedded/README.md index 89782a4..9b4f2fa 100644 --- a/src/embedded/README.md +++ b/src/embedded/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/src/embedded/docs/architecture.md b/src/embedded/docs/architecture.md index 3c42b14..f8dca03 100644 --- a/src/embedded/docs/architecture.md +++ b/src/embedded/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/src/embedded/docs/installation.md b/src/embedded/docs/installation.md index 5508458..550ae99 100644 --- a/src/embedded/docs/installation.md +++ b/src/embedded/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,7 +55,8 @@ 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 and deb/rpm packages. probaci no longer builds a container image. None of those channels is offered while binary releases are on hold; see @@ -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/src/embedded/docs/release.md b/src/embedded/docs/release.md index d8353cb..0d45eb4 100644 --- a/src/embedded/docs/release.md +++ b/src/embedded/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 @@ -94,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. ---