Simplify source releases with one guarded publication command
This commit is contained in:
@@ -2,85 +2,23 @@
|
||||
|
||||
set -eu
|
||||
|
||||
fail() {
|
||||
printf '%s\n' "check-release-source: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [ "$#" -ne 1 ]; then
|
||||
fail "usage: $0 vMAJOR.MINOR.PATCH"
|
||||
fi
|
||||
|
||||
release_version=$1
|
||||
if ! printf '%s\n' "$release_version" | grep -E -x 'v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)' >/dev/null; then
|
||||
fail "version must be a stable semantic version tag"
|
||||
fi
|
||||
fail() { printf '%s\n' "check-release-source: $*" >&2; exit 1; }
|
||||
|
||||
[ "$#" -eq 1 ] || fail "usage: $0 vMAJOR.MINOR.PATCH"
|
||||
version=$1
|
||||
printf '%s\n' "$version" | grep -E -x 'v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)' >/dev/null || fail "invalid stable release version"
|
||||
script_dir=$(CDPATH= cd "$(dirname "$0")" && pwd)
|
||||
repo_root=$(CDPATH= cd "$script_dir/.." && pwd)
|
||||
cd "$repo_root"
|
||||
cd "$script_dir/.."
|
||||
note="docs/releases/$version.md"
|
||||
[ -s "$note" ] || fail "missing release note: $note"
|
||||
[ "$(head -n 1 "$note")" = "# Notarius $version" ] || fail "release note title does not match version"
|
||||
|
||||
temporary_dir=$(mktemp -d "${TMPDIR:-/tmp}/notarius-release-source.XXXXXX") || fail "create temporary directory"
|
||||
trap 'rm -rf "$temporary_dir"' 0 HUP INT TERM
|
||||
export GOWORK=off
|
||||
go test -count=1 ./...
|
||||
|
||||
if ! grep -E '^module[[:space:]]+gitea\.maximumdirect\.net/eric/notarius([[:space:]]|$)' go.mod >/dev/null; then
|
||||
fail "go.mod does not declare the expected module path"
|
||||
fi
|
||||
|
||||
if git ls-files --error-unmatch go.work >/dev/null 2>&1 || git ls-files --error-unmatch go.work.sum >/dev/null 2>&1; then
|
||||
fail "tracked Go workspace files are not allowed"
|
||||
fi
|
||||
if [ -d vendor ]; then
|
||||
fail "vendor directory is not allowed"
|
||||
fi
|
||||
if grep -E '^[[:space:]]*replace([[:space:]]|$)' go.mod >/dev/null; then
|
||||
fail "go.mod replace directives are not allowed"
|
||||
fi
|
||||
|
||||
GOWORK=off go test -count=1 ./...
|
||||
GOWORK=off go test -race -count=1 ./...
|
||||
GOWORK=off go vet ./...
|
||||
GOWORK=off go build ./...
|
||||
GOWORK=off go mod tidy -diff
|
||||
|
||||
unformatted=$(git ls-files '*.go' | while IFS= read -r go_file; do
|
||||
fmt_output=$(gofmt -l "$go_file")
|
||||
if [ -n "$fmt_output" ]; then
|
||||
printf '%s\n' "$fmt_output"
|
||||
fi
|
||||
done)
|
||||
if [ -n "$unformatted" ]; then
|
||||
printf '%s\n' "$unformatted" >&2
|
||||
fail "tracked Go files are not gofmt formatted"
|
||||
fi
|
||||
|
||||
git diff --check
|
||||
git diff --cached --check
|
||||
|
||||
GOWORK=off go run ./cmd/notarius config validate --config examples/dnd-minimal.config.yml --pipeline dnd-session
|
||||
GOWORK=off OPENROUTER_API_KEY=validation-placeholder go run ./cmd/notarius config validate --config examples/dnd-complete.config.yml --pipeline dnd-session
|
||||
|
||||
host_os=$(GOWORK=off go env GOOS)
|
||||
host_arch=$(GOWORK=off go env GOARCH)
|
||||
version_symbol='gitea.maximumdirect.net/eric/notarius/internal/buildinfo.Override'
|
||||
|
||||
build_target() {
|
||||
target_os=$1
|
||||
target_arch=$2
|
||||
target_path="$temporary_dir/notarius-$target_os-$target_arch"
|
||||
CGO_ENABLED=0 GOOS="$target_os" GOARCH="$target_arch" GOWORK=off go build -trimpath \
|
||||
-ldflags "-X $version_symbol=$release_version" \
|
||||
-o "$target_path" ./cmd/notarius
|
||||
if [ "$host_os" = "$target_os" ] && [ "$host_arch" = "$target_arch" ]; then
|
||||
"$target_path" --version > "$temporary_dir/version-output"
|
||||
printf 'notarius %s\n' "$release_version" > "$temporary_dir/expected-version-output"
|
||||
if ! cmp -s "$temporary_dir/expected-version-output" "$temporary_dir/version-output"; then
|
||||
fail "version output is incorrect for $target_os/$target_arch"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
build_target linux amd64
|
||||
build_target linux arm64
|
||||
build_target darwin amd64
|
||||
build_target darwin arm64
|
||||
# Exercise the actual command and its linker-supplied diagnostic version.
|
||||
verification_dir=$(mktemp -d "${TMPDIR:-/tmp}/notarius-release-check.XXXXXX")
|
||||
trap 'rm -rf "$verification_dir"' 0 HUP INT TERM
|
||||
go build -trimpath -ldflags "-X gitea.maximumdirect.net/eric/notarius/internal/buildinfo.Override=$version" \
|
||||
-o "$verification_dir/notarius" ./cmd/notarius
|
||||
[ "$("$verification_dir/notarius" --version)" = "notarius $version" ] || fail "incorrect diagnostic version"
|
||||
|
||||
56
scripts/release.sh
Executable file
56
scripts/release.sh
Executable file
@@ -0,0 +1,56 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
fail() { printf '%s\n' "release: $*" >&2; exit 1; }
|
||||
|
||||
[ "$#" -eq 1 ] || fail "usage: $0 vMAJOR.MINOR.PATCH"
|
||||
version=$1
|
||||
printf '%s\n' "$version" | grep -E -x 'v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)' >/dev/null || fail "invalid stable release version"
|
||||
script_dir=$(CDPATH= cd "$(dirname "$0")" && pwd)
|
||||
cd "$script_dir/.."
|
||||
export GOWORK=off
|
||||
candidate=$(git rev-parse 'HEAD^{commit}')
|
||||
|
||||
check_candidate() {
|
||||
[ "$(git branch --show-current)" = main ] || fail "release branch must be main"
|
||||
[ -z "$(git status --porcelain)" ] || fail "worktree and index must be clean"
|
||||
[ "$(git rev-parse 'HEAD^{commit}')" = "$candidate" ] || fail "candidate changed during validation"
|
||||
git cat-file -e "$candidate:docs/releases/$version.md" || fail "release note must be committed"
|
||||
}
|
||||
|
||||
check_publication() {
|
||||
# Query failure is not proof that the tag is unused.
|
||||
remote_tag=$(git ls-remote --tags origin "refs/tags/$version" "refs/tags/$version^{}") || fail "cannot determine remote tag availability"
|
||||
[ -z "$remote_tag" ] || fail "remote tag already exists: $version"
|
||||
if git show-ref --verify --quiet "refs/tags/$version"; then
|
||||
fail "local tag already exists: $version"
|
||||
fi
|
||||
git fetch --no-tags origin refs/heads/main || fail "cannot fetch authoritative main"
|
||||
remote_main=$(git rev-parse 'FETCH_HEAD^{commit}')
|
||||
git merge-base --is-ancestor "$remote_main" "$candidate" || fail "candidate does not contain authoritative main"
|
||||
}
|
||||
|
||||
check_candidate
|
||||
check_publication
|
||||
"$script_dir/check-release-source.sh" "$version"
|
||||
check_candidate
|
||||
check_publication
|
||||
|
||||
if ! git -c push.followTags=false push origin "$candidate:refs/heads/main"; then
|
||||
# Do not publish a tag after a failed branch push, even if the branch arrived.
|
||||
git ls-remote origin refs/heads/main >&2 || fail "branch publication status unknown; inspect origin before retrying"
|
||||
fail "branch push failed; inspect the reported remote state before retrying"
|
||||
fi
|
||||
|
||||
git -c tag.gpgSign=false tag "$version" "$candidate"
|
||||
[ "$(git cat-file -t "$version")" = commit ] || fail "tag is not lightweight"
|
||||
[ "$(git rev-parse "$version^{commit}")" = "$candidate" ] || fail "tag target changed"
|
||||
if ! git -c push.followTags=false push origin "refs/tags/$version:refs/tags/$version"; then
|
||||
# A connection can fail after the server accepted the tag. Preserve it locally
|
||||
# until authoritative state proves whether publication succeeded.
|
||||
remote_tag=$(git ls-remote --tags origin "refs/tags/$version" "refs/tags/$version^{}") || fail "tag publication status unknown; preserve local tag and inspect origin"
|
||||
expected=$(printf '%s\t%s' "$candidate" "refs/tags/$version")
|
||||
[ "$remote_tag" = "$expected" ] || fail "tag push failed; preserve local tag and inspect origin before retrying"
|
||||
fi
|
||||
printf 'Released %s at %s\n' "$version" "$candidate"
|
||||
243
scripts/release_test.go
Normal file
243
scripts/release_test.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package scripts
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Keep publication tests offline: real Git repositories exercise ref safety,
|
||||
// while a fake Go command represents the expensive candidate validation boundary.
|
||||
type releaseFixture struct {
|
||||
t *testing.T
|
||||
root string
|
||||
remote string
|
||||
bin string
|
||||
log string
|
||||
initial string
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, content string, mode os.FileMode) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), mode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func command(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
cmd.Dir = dir
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("%v: %v\n%s", args, err, output)
|
||||
}
|
||||
return strings.TrimSpace(string(output))
|
||||
}
|
||||
|
||||
func newReleaseFixture(t *testing.T) *releaseFixture {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
f := &releaseFixture{t: t, root: filepath.Join(dir, "work"), remote: filepath.Join(dir, "origin.git"), bin: filepath.Join(dir, "bin"), log: filepath.Join(dir, "validation.log")}
|
||||
command(t, dir, "git", "init", "--bare", f.remote)
|
||||
command(t, dir, "git", "init", "-b", "main", f.root)
|
||||
f.git("config", "user.name", "Release Test")
|
||||
f.git("config", "user.email", "release@example.invalid")
|
||||
f.git("config", "commit.gpgSign", "false")
|
||||
f.git("config", "tag.gpgSign", "false")
|
||||
for _, name := range []string{"release.sh", "check-release-source.sh"} {
|
||||
content, err := os.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, filepath.Join(f.root, "scripts", name), string(content), 0755)
|
||||
}
|
||||
writeFile(t, filepath.Join(f.root, ".gitignore"), "vendor/\n", 0644)
|
||||
f.git("add", ".")
|
||||
f.git("commit", "-m", "Initial source")
|
||||
f.git("remote", "add", "origin", f.remote)
|
||||
f.git("push", "origin", "main")
|
||||
f.initial = f.git("rev-parse", "HEAD")
|
||||
writeFile(t, filepath.Join(f.root, "docs/releases/v0.8.0.md"), "# Notarius v0.8.0\n\nA concise note without fixed sections.\n", 0644)
|
||||
f.git("add", ".")
|
||||
f.git("commit", "-m", "Prepare release")
|
||||
writeFile(t, filepath.Join(f.bin, "go"), `#!/bin/sh
|
||||
set -eu
|
||||
[ "$GOWORK" = off ]
|
||||
printf '%s\n' "$1" >> "$RELEASE_TEST_LOG"
|
||||
case "$1" in
|
||||
test)
|
||||
[ "${RELEASE_TEST_FAIL:-}" != test ] || exit 1
|
||||
if [ "${RELEASE_TEST_CHANGE:-}" = yes ]; then
|
||||
printf 'changed\n' >> docs/releases/v0.8.0.md
|
||||
fi
|
||||
;;
|
||||
build)
|
||||
while [ "$1" != -o ]; do shift; done
|
||||
shift
|
||||
printf '#!/bin/sh\nprintf "notarius v0.8.0\\n"\n' > "$1"
|
||||
chmod +x "$1"
|
||||
;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
`, 0755)
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *releaseFixture) git(args ...string) string {
|
||||
f.t.Helper()
|
||||
return command(f.t, f.root, append([]string{"git"}, args...)...)
|
||||
}
|
||||
|
||||
func (f *releaseFixture) run(version string, env ...string) (string, error) {
|
||||
f.t.Helper()
|
||||
cmd := exec.Command("sh", "scripts/release.sh", version)
|
||||
cmd.Dir = f.root
|
||||
cmd.Env = append(os.Environ(), "PATH="+f.bin+string(os.PathListSeparator)+os.Getenv("PATH"), "RELEASE_TEST_LOG="+f.log)
|
||||
cmd.Env = append(cmd.Env, env...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
return string(output), err
|
||||
}
|
||||
|
||||
func TestReleasePublishesExactCandidateOnce(t *testing.T) {
|
||||
f := newReleaseFixture(t)
|
||||
// Neither ignored local files nor unrelated local tags are release inputs.
|
||||
writeFile(t, filepath.Join(f.root, "vendor/local.txt"), "ignored", 0644)
|
||||
f.git("tag", "unrelated")
|
||||
f.git("tag", "-a", "unrelated-annotated", "-m", "Do not publish")
|
||||
f.git("config", "push.followTags", "true")
|
||||
candidate := f.git("rev-parse", "HEAD")
|
||||
output, err := f.run("v0.8.0")
|
||||
if err != nil {
|
||||
t.Fatalf("release: %v\n%s", err, output)
|
||||
}
|
||||
for _, ref := range []string{"refs/heads/main", "refs/tags/v0.8.0"} {
|
||||
if got := command(t, f.remote, "git", "rev-parse", ref); got != candidate {
|
||||
t.Fatalf("%s = %s, want %s", ref, got, candidate)
|
||||
}
|
||||
}
|
||||
if got := command(t, f.remote, "git", "cat-file", "-t", "v0.8.0"); got != "commit" {
|
||||
t.Fatalf("tag type = %s", got)
|
||||
}
|
||||
if got := f.git("ls-remote", "--tags", "origin", "refs/tags/unrelated*"); got != "" {
|
||||
t.Fatal("published unrelated tag")
|
||||
}
|
||||
log, err := os.ReadFile(f.log)
|
||||
if err != nil || string(log) != "test\nbuild\n" {
|
||||
t.Fatalf("validation was not performed once: %q, %v", log, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseGuardsPreventPublication(t *testing.T) {
|
||||
for _, name := range []string{"invalid_version", "missing_note", "wrong_title", "dirty", "staged", "wrong_branch", "local_tag", "remote_tag", "remote_ahead", "remote_unavailable", "validation_failure", "candidate_changed"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
f := newReleaseFixture(t)
|
||||
version := "v0.8.0"
|
||||
var env []string
|
||||
switch name {
|
||||
case "invalid_version":
|
||||
version = "v00.8.0"
|
||||
case "missing_note":
|
||||
version = "v0.8.1"
|
||||
case "wrong_title":
|
||||
writeFile(t, filepath.Join(f.root, "docs/releases/v0.8.0.md"), "# Wrong version\n", 0644)
|
||||
f.git("add", ".")
|
||||
f.git("commit", "-m", "Wrong title")
|
||||
case "dirty", "staged":
|
||||
writeFile(t, filepath.Join(f.root, "dirty.txt"), "dirty", 0644)
|
||||
if name == "staged" {
|
||||
f.git("add", ".")
|
||||
}
|
||||
case "wrong_branch":
|
||||
f.git("checkout", "-b", "other")
|
||||
case "local_tag":
|
||||
f.git("tag", version)
|
||||
case "remote_tag":
|
||||
f.git("push", "origin", f.initial+":refs/tags/"+version)
|
||||
case "remote_ahead":
|
||||
// Publish an independent descendant of the old remote tip.
|
||||
f.git("checkout", "-b", "other", f.initial)
|
||||
f.git("commit", "--allow-empty", "-m", "Other work")
|
||||
f.git("push", "origin", "HEAD:main")
|
||||
f.initial = f.git("rev-parse", "HEAD")
|
||||
f.git("checkout", "main")
|
||||
case "remote_unavailable":
|
||||
f.git("remote", "set-url", "origin", filepath.Join(t.TempDir(), "missing.git"))
|
||||
case "validation_failure":
|
||||
env = []string{"RELEASE_TEST_FAIL=test"}
|
||||
case "candidate_changed":
|
||||
env = []string{"RELEASE_TEST_CHANGE=yes"}
|
||||
}
|
||||
if output, err := f.run(version, env...); err == nil {
|
||||
t.Fatalf("guard allowed publication: %s", output)
|
||||
}
|
||||
if got := command(t, f.remote, "git", "rev-parse", "main"); got != f.initial {
|
||||
t.Fatal("guard advanced remote branch")
|
||||
}
|
||||
if name != "remote_tag" {
|
||||
if got := command(t, f.remote, "git", "tag", "--list"); got != "" {
|
||||
t.Fatalf("guard published tag: %s", got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleasePushFailureRecovery(t *testing.T) {
|
||||
for _, mode := range []string{"branch_rejected", "tag_rejected", "tag_accepted_disconnect", "tag_unknown"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
f := newReleaseFixture(t)
|
||||
if mode == "branch_rejected" || mode == "tag_rejected" {
|
||||
pattern := "refs/heads/main"
|
||||
if mode == "tag_rejected" {
|
||||
pattern = "refs/tags/*"
|
||||
}
|
||||
writeFile(t, filepath.Join(f.remote, "hooks/update"), "#!/bin/sh\ncase \"$1\" in "+pattern+") exit 1;; esac\n", 0755)
|
||||
} else {
|
||||
realGit, err := exec.LookPath("git")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Simulate a lost acknowledgement, optionally followed by an outage.
|
||||
writeFile(t, filepath.Join(f.bin, "git"), `#!/bin/sh
|
||||
set -eu
|
||||
if [ "$1" = -c ] && [ "$3" = push ] && [ "$5" = refs/tags/v0.8.0:refs/tags/v0.8.0 ]; then
|
||||
"$RELEASE_TEST_GIT" "$@"
|
||||
touch "$RELEASE_TEST_MARKER"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$1" = ls-remote ] && [ -e "$RELEASE_TEST_MARKER" ] && [ "$RELEASE_TEST_MODE" = tag_unknown ]; then
|
||||
exit 1
|
||||
fi
|
||||
exec "$RELEASE_TEST_GIT" "$@"
|
||||
`, 0755)
|
||||
output, err := f.run("v0.8.0", "RELEASE_TEST_GIT="+realGit, "RELEASE_TEST_MARKER="+filepath.Join(f.bin, "marker"), "RELEASE_TEST_MODE="+mode)
|
||||
if (err == nil) != (mode == "tag_accepted_disconnect") {
|
||||
t.Fatalf("unexpected outcome: %v\n%s", err, output)
|
||||
}
|
||||
if got := command(t, f.remote, "git", "rev-parse", "v0.8.0"); got != f.git("rev-parse", "HEAD") {
|
||||
t.Fatal("wrong remote tag")
|
||||
}
|
||||
return
|
||||
}
|
||||
if output, err := f.run("v0.8.0"); err == nil {
|
||||
t.Fatalf("rejected push reported success: %s", output)
|
||||
}
|
||||
if got := command(t, f.remote, "git", "tag", "--list"); got != "" {
|
||||
t.Fatal("rejected push published tag")
|
||||
}
|
||||
if mode == "tag_rejected" {
|
||||
if got := f.git("rev-parse", "v0.8.0"); got != f.git("rev-parse", "HEAD") {
|
||||
t.Fatal("unpublished local tag not preserved")
|
||||
}
|
||||
} else if got := f.git("tag", "--list"); got != "" {
|
||||
t.Fatal("created tag after failed branch push")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user