diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 7a136e0..984a618 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -8,11 +8,11 @@ accepted user intent, policy choices, required outcome, and responsibility boundary. This plan translates that target into bounded implementation stages suitable for one `gpt-5.6-terra` coding prompt apiece. -All stages are pending and must be implemented in numeric order. Each stage -must leave its owned behavior correct, tested at an appropriate stable -boundary, and ready for the next stage. This plan supersedes the completed -pipeline-configuration implementation plan that previously occupied this -path; that feature's implemented behavior is now owned by current canonical +All five stages were completed in numeric order. This document is retained as +implementation history; current release behavior is owned by the checked-in +scripts and canonical release documentation. This plan superseded the completed +pipeline-configuration implementation plan that previously occupied this path; +that feature's implemented behavior is now owned by current canonical documentation and code. ## Settled Implementation Decisions diff --git a/internal/releasecheck/assets_test.go b/internal/releasecheck/assets_test.go index 05a148e..36e4eec 100644 --- a/internal/releasecheck/assets_test.go +++ b/internal/releasecheck/assets_test.go @@ -11,6 +11,45 @@ import ( "testing" ) +func TestReleaseVersionValidationIsExact(t *testing.T) { + repoRoot := releaseCheckRepoRoot(t) + library := filepath.Join(repoRoot, "scripts", "release-lib.sh") + workingDirectory := t.TempDir() + if err := os.WriteFile(filepath.Join(workingDirectory, "2"), nil, 0o644); err != nil { + t.Fatal(err) + } + + for _, test := range []struct { + version string + valid bool + }{ + {version: "v0.0.0", valid: true}, + {version: "v1.2.3", valid: true}, + {version: "v10.200.3000", valid: true}, + {version: "1.2.3"}, + {version: "v01.2.3"}, + {version: "v1.02.3"}, + {version: "v1.2.03"}, + {version: "v1.2.3."}, + {version: "v1.2.3-rc.1"}, + {version: "v1.2.3+build"}, + {version: "v1.*.3"}, + {version: "v1.2.3\nv4.5.6"}, + } { + t.Run(test.version, func(t *testing.T) { + command := exec.Command("sh", "-c", `. "$1"; narratio_release_validate_version "$2"`, "release-version-test", library, test.version) + command.Dir = workingDirectory + err := command.Run() + if test.valid && err != nil { + t.Fatalf("valid version %q rejected: %v", test.version, err) + } + if !test.valid && err == nil { + t.Fatalf("invalid version %q accepted", test.version) + } + }) + } +} + func TestAssetBuilderRejectsUnsafeDestinations(t *testing.T) { repoRoot := releaseCheckRepoRoot(t) builder := filepath.Join(repoRoot, "scripts", "build-release-assets.sh") @@ -62,10 +101,15 @@ func TestAssetBuilderBuildsNamedAssetsAndChecksEmbeddedVersion(t *testing.T) { binDir := t.TempDir() writeFakeGo(t, filepath.Join(binDir, "go")) outputDir := filepath.Join(t.TempDir(), "assets") + goLog := filepath.Join(t.TempDir(), "go.log") command := exec.Command("sh", builder, "v1.2.3", outputDir) command.Dir = t.TempDir() - command.Env = append(os.Environ(), "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + command.Env = append(os.Environ(), + "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"), + "GOWORK=/caller/controlled/go.work", + "NARRATIO_RELEASE_ASSET_GO_LOG="+goLog, + ) output, err := command.CombinedOutput() if err != nil { t.Fatalf("asset builder error = %v\n%s", err, output) @@ -99,6 +143,15 @@ func TestAssetBuilderBuildsNamedAssetsAndChecksEmbeddedVersion(t *testing.T) { if got := versionOutput.String(); got != "narratio v1.2.3\n" { t.Fatalf("embedded version output = %q", got) } + goCommands, err := os.ReadFile(goLog) + if err != nil { + t.Fatal(err) + } + for _, line := range strings.Split(strings.TrimSpace(string(goCommands)), "\n") { + if !strings.HasPrefix(line, "off ") { + t.Fatalf("asset builder Go command did not receive GOWORK=off: %q", line) + } + } } func TestCandidateCheckerRejectsMalformedVersionAndReleaseNotes(t *testing.T) { @@ -233,6 +286,10 @@ func writeFakeGo(t *testing.T, path string) { const fake = `#!/bin/sh set -eu +if [ -n "${NARRATIO_RELEASE_ASSET_GO_LOG:-}" ]; then + printf '%s %s\n' "${GOWORK:-}" "$*" >> "$NARRATIO_RELEASE_ASSET_GO_LOG" +fi + if [ "$1" = env ]; then case $2 in GOOS) printf '%s\n' linux ;; diff --git a/scripts/build-release-assets.sh b/scripts/build-release-assets.sh index d55476f..b3019de 100755 --- a/scripts/build-release-assets.sh +++ b/scripts/build-release-assets.sh @@ -7,6 +7,11 @@ script_dir=$(CDPATH= cd "$(dirname "$0")" && pwd -P) tool_name=build-release-assets +# Release builds always use the tagged module in this repository. Own this +# setting here so direct callers and CI cannot supply an ambient Go workspace. +GOWORK=off +export GOWORK + if [ "$#" -ne 2 ]; then narratio_release_fail "$tool_name" 'usage: scripts/build-release-assets.sh VERSION OUTPUT_DIR' fi diff --git a/scripts/release-lib.sh b/scripts/release-lib.sh index 0501c80..eca9561 100755 --- a/scripts/release-lib.sh +++ b/scripts/release-lib.sh @@ -11,32 +11,12 @@ narratio_release_fail() { } narratio_release_validate_version() { - version=$1 - case $version in - v*.*.*) ;; - *) return 1 ;; + case $1 in + *' +'*) return 1 ;; esac - components=${version#v} - if [ "$components" = "$version" ]; then - return 1 - fi - - previous_ifs=$IFS - IFS=. - set -- $components - IFS=$previous_ifs - if [ "$#" -ne 3 ]; then - return 1 - fi - for component in "$@"; do - case $component in - 0 | [1-9]*) ;; - *) return 1 ;; - esac - case $component in - *[!0-9]*) return 1 ;; - esac - done + printf '%s\n' "$1" | + grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' } narratio_release_repo_root() (