diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index cd9de0e..a95f06d 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -199,7 +199,7 @@ documentation. ## Stage 2 — Central Release-Candidate Checker -**Status: Pending** +**Status: Completed** ### Goal diff --git a/internal/releasecheck/assets_test.go b/internal/releasecheck/assets_test.go index 8be989c..05a148e 100644 --- a/internal/releasecheck/assets_test.go +++ b/internal/releasecheck/assets_test.go @@ -101,6 +101,124 @@ func TestAssetBuilderBuildsNamedAssetsAndChecksEmbeddedVersion(t *testing.T) { } } +func TestCandidateCheckerRejectsMalformedVersionAndReleaseNotes(t *testing.T) { + fixture := newCandidateFixture(t) + if output, err := fixture.run("v1.2"); err == nil || !strings.Contains(output, "invalid stable version") { + t.Fatalf("malformed version error = %v\n%s", err, output) + } + + for _, note := range []string{ + "# Narratio v1.2.3\n\n## Summary\n\n## Compatibility\n\n## Upgrade\n", + "# Narratio v1.2.4\n\n## Summary\n\n## Compatibility\n\n## Upgrade\n\n## Changes\n", + } { + fixture := newCandidateFixture(t) + if err := os.WriteFile(fixture.notePath, []byte(note), 0o644); err != nil { + t.Fatal(err) + } + if output, err := fixture.run("v1.2.3"); err == nil || !strings.Contains(output, "release note") { + t.Fatalf("invalid note error = %v\n%s", err, output) + } + } +} + +func TestCandidateCheckerRejectsRepositoryHygieneViolations(t *testing.T) { + for _, test := range []struct { + name string + prepare func(*testing.T, *candidateFixture) + want string + }{ + { + name: "wrong module", want: "go.mod must declare module", + prepare: func(t *testing.T, fixture *candidateFixture) { + if err := os.WriteFile(filepath.Join(fixture.root, "go.mod"), []byte("module example.invalid/release\n\ngo 1.25.0\n"), 0o644); err != nil { + t.Fatal(err) + } + }, + }, + { + name: "tracked workspace", want: "tracked workspace file is not allowed", + prepare: func(t *testing.T, fixture *candidateFixture) { + path := filepath.Join(fixture.root, "go.work") + if err := os.WriteFile(path, []byte("go 1.25.0\n"), 0o644); err != nil { + t.Fatal(err) + } + fixture.git("add", "go.work") + }, + }, + { + name: "vendor directory", want: "vendor directory is not allowed", + prepare: func(t *testing.T, fixture *candidateFixture) { + if err := os.Mkdir(filepath.Join(fixture.root, "vendor"), 0o755); err != nil { + t.Fatal(err) + } + }, + }, + { + name: "replace directive", want: "replace directives are not allowed", + prepare: func(t *testing.T, fixture *candidateFixture) { + data, err := os.ReadFile(filepath.Join(fixture.root, "go.mod")) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(fixture.root, "go.mod"), append(data, []byte("replace example.invalid/module => ./module\n")...), 0o644); err != nil { + t.Fatal(err) + } + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + fixture := newCandidateFixture(t) + test.prepare(t, fixture) + if output, err := fixture.run("v1.2.3"); err == nil || !strings.Contains(output, test.want) { + t.Fatalf("hygiene error = %v\n%s", err, output) + } + }) + } +} + +func TestCandidateCheckerPropagatesOwnedValidationFailure(t *testing.T) { + fixture := newCandidateFixture(t) + fixture.extraEnv = append(fixture.extraEnv, "NARRATIO_RELEASE_FAIL_GO=test -race -count=1 ./...") + if output, err := fixture.run("v1.2.3"); err == nil || !strings.Contains(output, "failed: go test -race -count=1 ./...") { + t.Fatalf("validation failure = %v\n%s", err, output) + } +} + +func TestCandidateCheckerUsesGoWorkOffForOwnedCommands(t *testing.T) { + fixture := newCandidateFixture(t) + if output, err := fixture.run("v1.2.3"); err != nil { + t.Fatalf("candidate checker error = %v\n%s", err, output) + } + data, err := os.ReadFile(fixture.logPath) + if err != nil { + t.Fatal(err) + } + lines := strings.FieldsFunc(strings.TrimSpace(string(data)), func(r rune) bool { return r == '\n' }) + if len(lines) < 9 { + t.Fatalf("owned command log = %q", data) + } + for _, line := range lines { + if !strings.HasPrefix(line, "off ") { + t.Fatalf("owned command did not receive GOWORK=off: %q", line) + } + } + for _, want := range []string{ + "off mod tidy -diff", + "off test -count=1 ./...", + "off test -race -count=1 ./...", + "off vet ./...", + "off build ./...", + "off test -count=1 ./internal/doccheck", + "off test -count=1 ./internal/config -run ^TestExamplesLoadAndValidate$", + "off gofmt -l", + "off build-release-assets v1.2.3", + } { + if !containsLogLine(lines, want) { + t.Fatalf("owned command log = %q, missing %q", data, want) + } + } +} + func releaseCheckRepoRoot(t *testing.T) string { t.Helper() _, file, _, ok := runtime.Caller(0) @@ -165,3 +283,101 @@ func slicesEqual(left, right []string) bool { } return true } + +type candidateFixture struct { + root string + notePath string + binDir string + logPath string + extraEnv []string +} + +func newCandidateFixture(t *testing.T) *candidateFixture { + t.Helper() + root := t.TempDir() + for _, dir := range []string{"scripts", "docs/releases"} { + if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil { + t.Fatal(err) + } + } + repoRoot := releaseCheckRepoRoot(t) + copyFixtureFile(t, filepath.Join(repoRoot, "scripts", "release-lib.sh"), filepath.Join(root, "scripts", "release-lib.sh"), 0o755) + copyFixtureFile(t, filepath.Join(repoRoot, "scripts", "check-release-candidate.sh"), filepath.Join(root, "scripts", "check-release-candidate.sh"), 0o755) + writeFixtureFile(t, filepath.Join(root, "scripts", "build-release-assets.sh"), `#!/bin/sh +set -eu +printf '%s build-release-assets %s\n' "${GOWORK:-}" "$1" >> "$NARRATIO_RELEASE_CHECK_LOG" +`, 0o755) + writeFixtureFile(t, filepath.Join(root, "go.mod"), "module gitea.maximumdirect.net/eric/narratio\n\ngo 1.25.0\n", 0o644) + writeFixtureFile(t, filepath.Join(root, "main.go"), "package narratio\n", 0o644) + notePath := filepath.Join(root, "docs", "releases", "v1.2.3.md") + writeFixtureFile(t, notePath, "# Narratio v1.2.3\n\n## Summary\n\n## Compatibility\n\n## Upgrade\n\n## Changes\n", 0o644) + + fixture := &candidateFixture{root: root, notePath: notePath, binDir: t.TempDir(), logPath: filepath.Join(t.TempDir(), "commands.log")} + writeFixtureFile(t, filepath.Join(fixture.binDir, "go"), fixtureGoShim, 0o755) + writeFixtureFile(t, filepath.Join(fixture.binDir, "gofmt"), fixtureGofmtShim, 0o755) + fixture.git("init", "-q") + fixture.git("config", "user.email", "releasecheck@example.test") + fixture.git("config", "user.name", "Release Check") + fixture.git("add", ".") + fixture.git("commit", "-qm", "fixture") + return fixture +} + +func (fixture *candidateFixture) run(version string) (string, error) { + command := exec.Command("sh", filepath.Join(fixture.root, "scripts", "check-release-candidate.sh"), version) + command.Dir = fixture.binDir + command.Env = append(os.Environ(), + "PATH="+fixture.binDir+string(os.PathListSeparator)+os.Getenv("PATH"), + "NARRATIO_RELEASE_CHECK_LOG="+fixture.logPath, + ) + command.Env = append(command.Env, fixture.extraEnv...) + output, err := command.CombinedOutput() + return string(output), err +} + +func (fixture *candidateFixture) git(args ...string) { + command := exec.Command("git", args...) + command.Dir = fixture.root + output, err := command.CombinedOutput() + if err != nil { + panic("fixture git command failed: " + string(output)) + } +} + +func copyFixtureFile(t *testing.T, source, destination string, mode os.FileMode) { + t.Helper() + data, err := os.ReadFile(source) + if err != nil { + t.Fatal(err) + } + writeFixtureFile(t, destination, string(data), mode) +} + +func writeFixtureFile(t *testing.T, path, contents string, mode os.FileMode) { + t.Helper() + if err := os.WriteFile(path, []byte(contents), mode); err != nil { + t.Fatal(err) + } +} + +func containsLogLine(lines []string, want string) bool { + for _, line := range lines { + if strings.HasPrefix(line, want) { + return true + } + } + return false +} + +const fixtureGoShim = `#!/bin/sh +set -eu +printf '%s %s\n' "${GOWORK:-}" "$*" >> "$NARRATIO_RELEASE_CHECK_LOG" +if [ "${NARRATIO_RELEASE_FAIL_GO:-}" = "$*" ]; then + exit 1 +fi +` + +const fixtureGofmtShim = `#!/bin/sh +set -eu +printf '%s gofmt %s\n' "${GOWORK:-}" "$*" >> "$NARRATIO_RELEASE_CHECK_LOG" +` diff --git a/scripts/check-release-candidate.sh b/scripts/check-release-candidate.sh new file mode 100755 index 0000000..42eae90 --- /dev/null +++ b/scripts/check-release-candidate.sh @@ -0,0 +1,95 @@ +#!/bin/sh +set -eu + +script_dir=$(CDPATH= cd "$(dirname "$0")" && pwd -P) +# shellcheck source=release-lib.sh +. "$script_dir/release-lib.sh" + +tool_name=check-release-candidate + +if [ "$#" -ne 1 ]; then + narratio_release_fail "$tool_name" 'usage: scripts/check-release-candidate.sh VERSION' +fi + +release_version=$1 +if ! narratio_release_validate_version "$release_version"; then + narratio_release_fail "$tool_name" "invalid stable version: $release_version" +fi + +repo_root=$(narratio_release_repo_root "$0") || narratio_release_fail "$tool_name" 'cannot locate repository root' +if ! cd "$repo_root"; then + narratio_release_fail "$tool_name" "cannot enter repository root: $repo_root" +fi + +release_note="docs/releases/$release_version.md" +if [ ! -s "$release_note" ]; then + narratio_release_fail "$tool_name" "release note must be nonempty: $release_note" +fi +note_heading=$(sed -n '1p' "$release_note") +if [ "$note_heading" != "# Narratio $release_version" ]; then + narratio_release_fail "$tool_name" "release note must begin with # Narratio $release_version" +fi +for required_heading in '## Summary' '## Compatibility' '## Upgrade' '## Changes'; do + if ! grep -Fqx "$required_heading" "$release_note"; then + narratio_release_fail "$tool_name" "release note is missing required heading: $required_heading" + fi +done + +if ! grep -Eq '^module[[:space:]]+gitea\.maximumdirect\.net/eric/narratio[[:space:]]*$' go.mod; then + narratio_release_fail "$tool_name" 'go.mod must declare module gitea.maximumdirect.net/eric/narratio' +fi +for workspace_file in go.work go.work.sum; do + if git ls-files --error-unmatch "$workspace_file" >/dev/null 2>&1; then + narratio_release_fail "$tool_name" "tracked workspace file is not allowed: $workspace_file" + fi +done +if [ -d vendor ]; then + narratio_release_fail "$tool_name" 'vendor directory is not allowed' +fi +if grep -Eq '^[[:space:]]*replace[[:space:]]' go.mod; then + narratio_release_fail "$tool_name" 'go.mod replace directives are not allowed' +fi + +narratio_release_go() { + if ! GOWORK=off go "$@"; then + narratio_release_fail "$tool_name" "failed: go $*" + fi +} + +narratio_release_go mod tidy -diff +unformatted_files=$(git ls-files -z -- '*.go' | xargs -0 env GOWORK=off gofmt -l) || narratio_release_fail "$tool_name" 'failed: gofmt tracked Go files' +if [ -n "$unformatted_files" ]; then + narratio_release_fail "$tool_name" "tracked Go files are not gofmt clean: $unformatted_files" +fi +if ! git diff --check; then + narratio_release_fail "$tool_name" 'working-tree whitespace check failed' +fi +if ! git diff --cached --check; then + narratio_release_fail "$tool_name" 'cached whitespace check failed' +fi + +narratio_release_go test -count=1 ./... +narratio_release_go test -race -count=1 ./... +narratio_release_go vet ./... +narratio_release_go build ./... +narratio_release_go test -count=1 ./internal/doccheck +narratio_release_go test -count=1 ./internal/config -run '^TestExamplesLoadAndValidate$' + +temporary_assets_dir=$(mktemp -d "${TMPDIR:-/tmp}/narratio-release-candidate.XXXXXX") || narratio_release_fail "$tool_name" 'cannot create temporary asset directory' +case $temporary_assets_dir in + /*) ;; + *) narratio_release_fail "$tool_name" 'temporary asset directory must be absolute' ;; +esac +if [ ! -d "$temporary_assets_dir" ] || [ -L "$temporary_assets_dir" ]; then + narratio_release_fail "$tool_name" "temporary asset directory is unsafe: $temporary_assets_dir" +fi +cleanup_temporary_assets() { + if [ -n "${temporary_assets_dir:-}" ] && [ -d "$temporary_assets_dir" ] && [ ! -L "$temporary_assets_dir" ]; then + rm -rf "$temporary_assets_dir" + fi +} +trap cleanup_temporary_assets 0 HUP INT TERM + +if ! GOWORK=off "$script_dir/build-release-assets.sh" "$release_version" "$temporary_assets_dir"; then + narratio_release_fail "$tool_name" 'failed: build-release-assets.sh' +fi