Refresh documentation and policies and simplify releases
This commit is contained in:
17
scripts/build-release.sh
Executable file
17
scripts/build-release.sh
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# != 2 || ! $1 =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
|
||||
echo 'Usage: scripts/build-release.sh vMAJOR.MINOR.PATCH OUTPUT_DIRECTORY' >&2
|
||||
exit 2
|
||||
fi
|
||||
version=$1
|
||||
output=$2
|
||||
mkdir -p "$output"
|
||||
output=$(cd "$output" && pwd)
|
||||
cd "$(dirname "$0")/.."
|
||||
for arch in amd64 arm64; do
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=$arch GOWORK=off \
|
||||
go build -mod=readonly -trimpath -ldflags '-s -w' \
|
||||
-o "$output/audita-$version-linux-$arch" ./cmd/audita
|
||||
done
|
||||
48
scripts/release.sh
Executable file
48
scripts/release.sh
Executable file
@@ -0,0 +1,48 @@
|
||||
#!/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."
|
||||
142
scripts/release_test.go
Normal file
142
scripts/release_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package scripts
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"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)
|
||||
}
|
||||
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))
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
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")
|
||||
}
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user