diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index fdb3071..bc993fc 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,7 +1,7 @@ # Implementation Plan: ReleaseKit Adoption **Last updated:** 2026-10-07 -**Status:** Stage 1 complete; Stages 2–5 pending. +**Status:** Stages 1–2 complete; Stages 3–5 pending. **Audience:** Luna coding agent. ## Scope and Execution @@ -98,7 +98,7 @@ Checks passed: `go test ./internal/cli ./cmd/audita`, `go build -o ./bin/audita ./cmd/audita`, `./bin/audita version`, and `git diff --check`. -## Stage 2 — Introduce the Product Profile and Thin Local Wrapper +## Stage 2 — Introduce the Product Profile and Thin Local Wrapper [x] **Primary files:** new `releasekit.json`, `scripts/release.sh`, and `scripts/release_test.go`. @@ -154,6 +154,12 @@ This prints the tool's version, not Audita's. Check ignored build output and `git diff --check`, then commit. No publication or delivery invocation against an external service is needed. +**Completed 2026-10-07:** Added the Audita profile and replaced the local +publication workflow with a wrapper that builds and execs ReleaseKit. Replaced +the publication test matrix with focused wrapper and profile checks. Checks +passed: `go test ./scripts`, `bash scripts/release.sh version`, ignored-output +verification for `bin/releasekit`, and `git diff --check`. + ## Stage 3 — Migrate Woodpecker Delivery and Retire the Builder **Primary files:** `.woodpecker/release.yml` and deletion of diff --git a/releasekit.json b/releasekit.json new file mode 100644 index 0000000..3c6569f --- /dev/null +++ b/releasekit.json @@ -0,0 +1,15 @@ +{ + "product": "Audita", + "remote": "origin", + "build": { + "main_package": "./cmd/audita", + "binary_name": "audita", + "targets": ["linux/amd64", "linux/arm64"] + }, + "delivery": { + "base_url": "https://gitea.maximumdirect.net", + "owner": "eric", + "repository": "audita", + "token_env": "RELEASEKIT_GITEA_TOKEN" + } +} diff --git a/scripts/release.sh b/scripts/release.sh index 05894b5..0e48983 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -1,48 +1,9 @@ #!/usr/bin/env bash set -euo pipefail -fail() { echo "Release stopped: $*" >&2; exit 1; } -if [[ $# != 2 || ( $1 != --check && $1 != --publish ) || ! $2 =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then - echo 'Usage: scripts/release.sh --check|--publish vMAJOR.MINOR.PATCH' >&2 - exit 2 -fi -mode=$1 -version=$2 -cd "$(dirname "$0")/.." -[[ $(git symbolic-ref --quiet --short HEAD) == main ]] || fail 'switch to main' -[[ -z $(git status --porcelain) ]] || fail 'commit or remove outstanding changes first' -candidate=$(git rev-parse HEAD) -note="docs/releases/$version.md" -[[ -s $note ]] || fail "missing release note: $note" -IFS= read -r title < "$note" || true -[[ $title == "# Audita $version" ]] || fail "note must begin with # Audita $version" -git show-ref --verify --quiet "refs/tags/$version" && fail 'version already exists locally' - -# Check mode is local and never contacts a remote or creates a tag. -if [[ $mode == --publish ]]; then - existing=$(git ls-remote --tags origin "refs/tags/$version") - [[ -z $existing ]] || fail 'version already exists on origin' -fi -build_dir=$(mktemp -d) -trap 'rm -rf "$build_dir"' EXIT -GOWORK=off go test -mod=readonly ./... -bash scripts/build-release.sh "$version" "$build_dir" -[[ $(git rev-parse HEAD) == "$candidate" ]] || fail 'HEAD changed during validation' -[[ -z $(git status --porcelain) ]] || fail 'worktree changed during validation' -[[ $(git symbolic-ref --quiet --short HEAD) == main ]] || fail 'branch changed during validation' -if [[ $mode == --check ]]; then - echo "Validated $version at $candidate; nothing published." - exit 0 -fi -# Fetch the authoritative branch before checking that this is a forward update. -git fetch --no-tags origin refs/heads/main -git merge-base --is-ancestor FETCH_HEAD "$candidate" || fail 'candidate does not include origin/main' -existing=$(git ls-remote --tags origin "refs/tags/$version") -[[ -z $existing ]] || fail 'version already exists on origin' -git tag "$version" "$candidate" -[[ $(git rev-parse "refs/tags/$version") == "$candidate" ]] || fail 'tag target changed' -# Atomic publication prevents a rejected tag/branch update from publishing only half. -if ! git push --atomic origin "$candidate:refs/heads/main" "refs/tags/$version:refs/tags/$version"; then - fail "push failed or its outcome is uncertain; inspect origin/main and refs/tags/$version before retrying (local tag retained)" -fi -echo "Released $version at $candidate. Binary publication runs asynchronously; CI is not awaited." +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +cd "$script_dir/.." +mkdir -p bin +GOWORK=off GOTOOLCHAIN=local go build -mod=readonly \ + -o ./bin/releasekit gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit +exec ./bin/releasekit "$@" diff --git a/scripts/release_test.go b/scripts/release_test.go index 9584005..bfa2fa9 100644 --- a/scripts/release_test.go +++ b/scripts/release_test.go @@ -1,142 +1,274 @@ package scripts import ( + "encoding/json" "os" "os/exec" "path/filepath" + "reflect" "strings" "testing" ) -// Real local Git repositories exercise publication; Go is stubbed at the slow -// build/test boundary so these tests remain offline and cannot recurse. -func TestReleaseWorkflow(t *testing.T) { - for _, scenario := range []string{"check", "publish", "dirty", "tests-fail", "build-fail", "existing-remote-tag", "remote-ahead", "rejected-push", "source-changed", "missing-note", "invalid-version", "existing-local-tag", "wrong-branch", "head-changed"} { - t.Run(scenario, func(t *testing.T) { - root := t.TempDir() - repo, remote := filepath.Join(root, "repo"), filepath.Join(root, "remote.git") - if err := os.MkdirAll(repo, 0755); err != nil { - t.Fatal(err) +const goStub = `#!/usr/bin/env bash +set -euo pipefail +printf '%s\0' "$PWD" "GOWORK=$GOWORK" "GOTOOLCHAIN=$GOTOOLCHAIN" "$@" > "$RELEASEKIT_BUILD_LOG" +if [[ ${RELEASEKIT_BOOTSTRAP_STATUS:-0} != 0 ]]; then + exit "$RELEASEKIT_BOOTSTRAP_STATUS" +fi +output= +while (($#)); do + if [[ $1 == -o ]]; then + shift + output=${1:?missing output path} + fi + shift +done +[[ -n $output ]] +cat > "$output" <<'RELEASEKIT_TOOL' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\0' "$PWD" "$@" > "$RELEASEKIT_ARGS_LOG" +exit "${RELEASEKIT_TOOL_STATUS:-0}" +RELEASEKIT_TOOL +chmod +x "$output" +` + +func TestReleaseWrapperForwardsNativeArgumentsAndStatus(t *testing.T) { + for _, toolStatus := range []string{"0", "3"} { + t.Run("status "+toolStatus, func(t *testing.T) { + layout := newReleaseTestLayout(t) + args := []string{"publish", "v4.2.0", "argument with spaces"} + cmd := exec.Command("bash", append([]string{layout.wrapper}, args...)...) + cmd.Dir = layout.caller + cmd.Env = layout.environment(map[string]string{ + "RELEASEKIT_ARGS_LOG": layout.argsLog, + "RELEASEKIT_BUILD_LOG": layout.buildLog, + "RELEASEKIT_TOOL_STATUS": toolStatus, + }) + output, err := cmd.CombinedOutput() + if got := commandStatus(err); got != toolStatusInt(toolStatus) { + t.Fatalf("exit=%d, want %s; output=%q, error=%v", got, toolStatus, output, err) } - env := append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL="+os.DevNull, "GIT_AUTHOR_NAME=Release Test", "GIT_AUTHOR_EMAIL=test@example.invalid", "GIT_COMMITTER_NAME=Release Test", "GIT_COMMITTER_EMAIL=test@example.invalid", "GIT_TERMINAL_PROMPT=0") - run := func(dir string, args ...string) string { - t.Helper() - c := exec.Command("git", args...) - c.Dir = dir - c.Env = env - b, e := c.CombinedOutput() - if e != nil { - t.Fatalf("git %v: %v\n%s", args, e, b) - } - return strings.TrimSpace(string(b)) + if len(output) != 0 { + t.Fatalf("unexpected output: %q", output) } - write := func(path, data string, mode os.FileMode) { - t.Helper() - if e := os.MkdirAll(filepath.Dir(path), 0755); e != nil { - t.Fatal(e) - } - if e := os.WriteFile(path, []byte(data), mode); e != nil { - t.Fatal(e) - } + + buildInvocation := readNULArguments(t, layout.buildLog) + if len(buildInvocation) < 4 || buildInvocation[0] != layout.repo { + t.Fatalf("build invocation=%q, want repository %q", buildInvocation, layout.repo) } - run(root, "init", "--bare", "--initial-branch=main", remote) - run(repo, "init", "--initial-branch=main") - for _, name := range []string{"release.sh", "build-release.sh"} { - b, e := os.ReadFile(name) - if e != nil { - t.Fatal(e) - } - write(filepath.Join(repo, "scripts", name), string(b), 0755) + if buildInvocation[1] != "GOWORK=off" || buildInvocation[2] != "GOTOOLCHAIN=local" { + t.Fatalf("build environment=%q", buildInvocation[1:3]) } - write(filepath.Join(repo, "docs/releases/v1.2.3.md"), "# Audita v1.2.3\n\nA test release.\n", 0644) - run(repo, "add", ".") - run(repo, "commit", "-m", "Initial fixture") - run(repo, "remote", "add", "origin", remote) - run(repo, "push", "origin", "main") - write(filepath.Join(repo, "change.txt"), "candidate\n", 0644) - run(repo, "add", ".") - run(repo, "commit", "-m", "Candidate fixture") - candidate := run(repo, "rev-parse", "HEAD") - log := filepath.Join(root, "go.log") - stub := "#!/bin/sh\necho \"$* GOOS=$GOOS GOARCH=$GOARCH CGO_ENABLED=$CGO_ENABLED GOWORK=$GOWORK\" >> \"$GO_LOG\"\n" - switch scenario { - case "existing-local-tag": - run(repo, "tag", "v1.2.3") - case "wrong-branch": - run(repo, "checkout", "-b", "feature") - case "head-changed": - stub += "if [ \"$1\" = test ]; then echo changed > change.txt; git add change.txt; git commit -m Changed; fi\n" - case "dirty": - write(filepath.Join(repo, "untracked"), "dirty", 0644) - case "tests-fail": - stub += "[ \"$1\" != test ] || exit 1\n" - case "build-fail": - stub += "[ \"$GOARCH\" != arm64 ] || exit 1\n" - case "existing-remote-tag": - run(remote, "tag", "v1.2.3", "main") - case "remote-ahead": - other := filepath.Join(root, "other") - run(root, "clone", remote, other) - write(filepath.Join(other, "other.txt"), "new remote commit", 0644) - run(other, "add", ".") - run(other, "commit", "-m", "Remote fixture") - run(other, "push", "origin", "main") - case "rejected-push": - write(filepath.Join(remote, "hooks/pre-receive"), "#!/bin/sh\nexit 1\n", 0755) - case "source-changed": - stub += "[ \"$1\" != test ] || echo changed > change.txt\n" - case "missing-note": - run(repo, "rm", "docs/releases/v1.2.3.md") - run(repo, "commit", "-m", "Remove fixture note") + buildArgs := buildInvocation[3:] + if buildArgs[0] != "build" || !containsArgument(buildArgs, "-mod=readonly") || !containsArgument(buildArgs, "gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit") { + t.Fatalf("unexpected build arguments: %q", buildArgs) } - stub += "exit 0\n" - write(filepath.Join(root, "bin/go"), stub, 0755) - mode, version := "--publish", "v1.2.3" - if scenario == "check" { - mode = "--check" - run(repo, "remote", "remove", "origin") + if !hasAdjacentArguments(buildArgs, "-o", "./bin/releasekit") { + t.Fatalf("build output was not selected: %q", buildArgs) } - if scenario == "invalid-version" { - version = "v01.2.3" - } - c := exec.Command("bash", "scripts/release.sh", mode, version) - c.Dir = repo - c.Env = append(env, "PATH="+filepath.Join(root, "bin")+":"+os.Getenv("PATH"), "GO_LOG="+log) - b, e := c.CombinedOutput() - success := scenario == "check" || scenario == "publish" - if (e == nil) != success { - t.Fatalf("success=%v: %v\n%s", success, e, b) - } - tags := run(remote, "tag", "--list") - if scenario == "publish" { - if run(remote, "rev-parse", "refs/tags/v1.2.3") != candidate || run(remote, "rev-parse", "main") != candidate { - t.Fatal("wrong source published") - } - if !strings.Contains(string(b), "CI is not awaited") { - t.Fatal("missing asynchronous completion message") - } - } else if scenario != "existing-remote-tag" && tags != "" { - t.Fatalf("unexpected remote tags: %s", tags) - } - if scenario == "rejected-push" && run(remote, "rev-parse", "main") == candidate { - t.Fatal("rejected push published candidate") - } - if scenario == "check" && run(repo, "tag", "--list") != "" { - t.Fatal("check created a tag") - } - if success { - calls, e := os.ReadFile(log) - if e != nil { - t.Fatal(e) - } - lines := strings.Split(strings.TrimSpace(string(calls)), "\n") - if len(lines) != 3 { - t.Fatalf("expected one test and two builds: %s", calls) - } - if !strings.HasPrefix(lines[0], "test -mod=readonly ./...") || !strings.Contains(lines[1], "GOOS=linux GOARCH=amd64 CGO_ENABLED=0 GOWORK=off") || !strings.Contains(lines[2], "GOOS=linux GOARCH=arm64 CGO_ENABLED=0 GOWORK=off") { - t.Fatalf("unexpected validation: %s", calls) - } + + toolInvocation := readNULArguments(t, layout.argsLog) + wantToolInvocation := append([]string{layout.repo}, args...) + if !reflect.DeepEqual(toolInvocation, wantToolInvocation) { + t.Fatalf("tool invocation=%q, want %q", toolInvocation, wantToolInvocation) } }) } } + +func TestReleaseWrapperStopsWhenBootstrapFails(t *testing.T) { + layout := newReleaseTestLayout(t) + cmd := exec.Command("bash", layout.wrapper, "publish", "v4.2.0") + cmd.Dir = layout.caller + cmd.Env = layout.environment(map[string]string{ + "RELEASEKIT_ARGS_LOG": layout.argsLog, + "RELEASEKIT_BUILD_LOG": layout.buildLog, + "RELEASEKIT_BOOTSTRAP_STATUS": "42", + }) + output, err := cmd.CombinedOutput() + if got := commandStatus(err); got != 42 { + t.Fatalf("exit=%d, want 42; output=%q, error=%v", got, output, err) + } + if _, err := os.Stat(layout.argsLog); !os.IsNotExist(err) { + t.Fatalf("ReleaseKit executed after failed bootstrap; stat error=%v", err) + } +} + +func TestReleaseKitProfileDeclaresProductAndDeliverySettings(t *testing.T) { + data, err := os.ReadFile("../releasekit.json") + if err != nil { + t.Fatal(err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatal(err) + } + if _, exists := raw["checks"]; exists { + t.Fatal("profile must not define additional checks") + } + var deliveryFields map[string]json.RawMessage + if err := json.Unmarshal(raw["delivery"], &deliveryFields); err != nil { + t.Fatal(err) + } + if _, exists := deliveryFields["token"]; exists { + t.Fatal("profile must not contain a credential value") + } + var profile struct { + Product string `json:"product"` + Remote string `json:"remote"` + Build struct { + MainPackage string `json:"main_package"` + BinaryName string `json:"binary_name"` + Targets []string `json:"targets"` + } `json:"build"` + Delivery struct { + BaseURL string `json:"base_url"` + Owner string `json:"owner"` + Repository string `json:"repository"` + TokenEnv string `json:"token_env"` + } `json:"delivery"` + } + if err := json.Unmarshal(data, &profile); err != nil { + t.Fatal(err) + } + if profile.Product != "Audita" || profile.Remote != "origin" { + t.Fatalf("product=%q remote=%q", profile.Product, profile.Remote) + } + if profile.Build.MainPackage != "./cmd/audita" || profile.Build.BinaryName != "audita" { + t.Fatalf("build=%+v", profile.Build) + } + if !sameMembers(profile.Build.Targets, []string{"linux/amd64", "linux/arm64"}) { + t.Fatalf("build targets=%q", profile.Build.Targets) + } + if profile.Delivery.BaseURL != "https://gitea.maximumdirect.net" || profile.Delivery.Owner != "eric" || profile.Delivery.Repository != "audita" || profile.Delivery.TokenEnv != "RELEASEKIT_GITEA_TOKEN" { + t.Fatalf("delivery=%+v", profile.Delivery) + } +} + +type releaseTestLayout struct { + repo string + caller string + wrapper string + argsLog string + buildLog string + path string +} + +func newReleaseTestLayout(t *testing.T) releaseTestLayout { + t.Helper() + root := t.TempDir() + layout := releaseTestLayout{ + repo: filepath.Join(root, "repository"), + caller: filepath.Join(root, "caller"), + argsLog: filepath.Join(root, "tool-arguments"), + buildLog: filepath.Join(root, "build-arguments"), + path: filepath.Join(root, "bin"), + } + if err := os.MkdirAll(filepath.Join(layout.repo, "scripts"), 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(layout.caller, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(layout.path, 0755); err != nil { + t.Fatal(err) + } + wrapper, err := os.ReadFile("release.sh") + if err != nil { + t.Fatal(err) + } + layout.wrapper = filepath.Join(layout.repo, "scripts", "release.sh") + writeReleaseTestFile(t, layout.wrapper, wrapper, 0755) + writeReleaseTestFile(t, filepath.Join(layout.path, "go"), []byte(goStub), 0755) + return layout +} + +func (layout releaseTestLayout) environment(values map[string]string) []string { + filtered := make([]string, 0, len(os.Environ())+len(values)+1) + for _, value := range os.Environ() { + key, _, _ := strings.Cut(value, "=") + if key == "PATH" || hasKey(values, key) { + continue + } + filtered = append(filtered, value) + } + filtered = append(filtered, "PATH="+layout.path+string(os.PathListSeparator)+os.Getenv("PATH")) + for key, value := range values { + filtered = append(filtered, key+"="+value) + } + return filtered +} + +func writeReleaseTestFile(t *testing.T, path string, data []byte, mode os.FileMode) { + t.Helper() + if err := os.WriteFile(path, data, mode); err != nil { + t.Fatal(err) + } +} + +func readNULArguments(t *testing.T, path string) []string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + parts := strings.Split(string(data), "\x00") + if len(parts) > 0 && parts[len(parts)-1] == "" { + parts = parts[:len(parts)-1] + } + return parts +} + +func commandStatus(err error) int { + if err == nil { + return 0 + } + if exitErr, ok := err.(*exec.ExitError); ok { + return exitErr.ExitCode() + } + return -1 +} + +func toolStatusInt(value string) int { + if value == "3" { + return 3 + } + return 0 +} + +func containsArgument(args []string, want string) bool { + for _, arg := range args { + if arg == want { + return true + } + } + return false +} + +func hasAdjacentArguments(args []string, first, second string) bool { + for i := 0; i+1 < len(args); i++ { + if args[i] == first && args[i+1] == second { + return true + } + } + return false +} + +func sameMembers(got, want []string) bool { + if len(got) != len(want) { + return false + } + for _, value := range got { + if !containsArgument(want, value) { + return false + } + } + return true +} + +func hasKey(values map[string]string, key string) bool { + _, exists := values[key] + return exists +}