Harden release validation

This commit is contained in:
2026-08-30 20:36:23 +00:00
parent f8fa0a2623
commit 98139f7e8b
4 changed files with 73 additions and 31 deletions

View File

@@ -8,11 +8,11 @@ accepted user intent, policy choices, required outcome, and responsibility
boundary. This plan translates that target into bounded implementation stages boundary. This plan translates that target into bounded implementation stages
suitable for one `gpt-5.6-terra` coding prompt apiece. suitable for one `gpt-5.6-terra` coding prompt apiece.
All stages are pending and must be implemented in numeric order. Each stage All five stages were completed in numeric order. This document is retained as
must leave its owned behavior correct, tested at an appropriate stable implementation history; current release behavior is owned by the checked-in
boundary, and ready for the next stage. This plan supersedes the completed scripts and canonical release documentation. This plan superseded the completed
pipeline-configuration implementation plan that previously occupied this pipeline-configuration implementation plan that previously occupied this path;
path; that feature's implemented behavior is now owned by current canonical that feature's implemented behavior is now owned by current canonical
documentation and code. documentation and code.
## Settled Implementation Decisions ## Settled Implementation Decisions

View File

@@ -11,6 +11,45 @@ import (
"testing" "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) { func TestAssetBuilderRejectsUnsafeDestinations(t *testing.T) {
repoRoot := releaseCheckRepoRoot(t) repoRoot := releaseCheckRepoRoot(t)
builder := filepath.Join(repoRoot, "scripts", "build-release-assets.sh") builder := filepath.Join(repoRoot, "scripts", "build-release-assets.sh")
@@ -62,10 +101,15 @@ func TestAssetBuilderBuildsNamedAssetsAndChecksEmbeddedVersion(t *testing.T) {
binDir := t.TempDir() binDir := t.TempDir()
writeFakeGo(t, filepath.Join(binDir, "go")) writeFakeGo(t, filepath.Join(binDir, "go"))
outputDir := filepath.Join(t.TempDir(), "assets") outputDir := filepath.Join(t.TempDir(), "assets")
goLog := filepath.Join(t.TempDir(), "go.log")
command := exec.Command("sh", builder, "v1.2.3", outputDir) command := exec.Command("sh", builder, "v1.2.3", outputDir)
command.Dir = t.TempDir() 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() output, err := command.CombinedOutput()
if err != nil { if err != nil {
t.Fatalf("asset builder error = %v\n%s", err, output) 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" { if got := versionOutput.String(); got != "narratio v1.2.3\n" {
t.Fatalf("embedded version output = %q", got) 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) { func TestCandidateCheckerRejectsMalformedVersionAndReleaseNotes(t *testing.T) {
@@ -233,6 +286,10 @@ func writeFakeGo(t *testing.T, path string) {
const fake = `#!/bin/sh const fake = `#!/bin/sh
set -eu 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 if [ "$1" = env ]; then
case $2 in case $2 in
GOOS) printf '%s\n' linux ;; GOOS) printf '%s\n' linux ;;

View File

@@ -7,6 +7,11 @@ script_dir=$(CDPATH= cd "$(dirname "$0")" && pwd -P)
tool_name=build-release-assets 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 if [ "$#" -ne 2 ]; then
narratio_release_fail "$tool_name" 'usage: scripts/build-release-assets.sh VERSION OUTPUT_DIR' narratio_release_fail "$tool_name" 'usage: scripts/build-release-assets.sh VERSION OUTPUT_DIR'
fi fi

View File

@@ -11,32 +11,12 @@ narratio_release_fail() {
} }
narratio_release_validate_version() { narratio_release_validate_version() {
version=$1 case $1 in
case $version in *'
v*.*.*) ;; '*) return 1 ;;
*) return 1 ;;
esac esac
components=${version#v} printf '%s\n' "$1" |
if [ "$components" = "$version" ]; then grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
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
} }
narratio_release_repo_root() ( narratio_release_repo_root() (