Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/migration-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ already cancelled test context does not skip shutdown. `Stop` retains the
`ErrServerNotStarted` behaviour; `Close` can safely be called repeatedly.

The supervisor is an independent executable. Released module consumers acquire
its matching release automatically. When using a source checkout or `replace`,
its matching release automatically, including from `go test` and `go test -trimpath`.
Test binaries use the version recorded in the module-cache source path when Go
omits dependency build metadata. When using a source checkout, local `replace`,
or vendored tests without that version information,
build `go build -o /absolute/path/embedded-postgres ./cmd/embedded-postgres` and set
`EP_SUPERVISOR` or `Config.Supervisor`. Offline users must prefetch PostgreSQL and
provide an already installed supervisor. No system Go compiler is used at runtime.
Expand Down
8 changes: 5 additions & 3 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,13 @@ def build(tag):
def publish(tag):
if not TAG.fullmatch(tag):
raise ValueError("invalid v2 release tag")
release = json.loads(command("gh", "api", f"repos/{REPO}/releases/tags/{tag}"))
if not release["draft"]:
# The REST endpoint for a tag only resolves published releases. The CLI
# also finds drafts, including those whose Git tag has not been created.
release = json.loads(command("gh", "release", "view", tag, "--repo", REPO, "--json", "isDraft,targetCommitish"))
if not release["isDraft"]:
print("Release is already published; artifacts are immutable")
return
if release["target_commitish"] != os.environ["GITHUB_SHA"]:
if release["targetCommitish"] != os.environ["GITHUB_SHA"]:
raise SystemExit("draft belongs to a different commit")
assets = sorted(str(path) for path in (ROOT / "dist").iterdir())
if len(assets) != 12:
Expand Down
53 changes: 53 additions & 0 deletions scripts/test_module_consumer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Exercise version detection through the real Go module loader, without network."""
import json
import os
import pathlib
import shutil
import subprocess
import tempfile
import unittest
import zipfile


@unittest.skipUnless(shutil.which("go"), "requires Go")
class ModuleConsumerTest(unittest.TestCase):
def test_tagged_dependency_version_in_test_binaries(self):
root = pathlib.Path(__file__).resolve().parent.parent
module = "github.com/fergusstrange/embedded-postgres/v2"
version = "v2.0.0-fixture.1"
with tempfile.TemporaryDirectory() as directory:
stage = pathlib.Path(directory)
proxy = stage / "proxy" / module / "@v"
proxy.mkdir(parents=True)
(proxy / f"{version}.mod").write_bytes((root / "go.mod").read_bytes())
(proxy / f"{version}.info").write_text(json.dumps({"Version": version, "Time": "2026-01-01T00:00:00Z"}))
files = subprocess.check_output(["git", "ls-files", "-z"], cwd=root).decode().split("\0")
with zipfile.ZipFile(proxy / f"{version}.zip", "w") as archive:
for name in files:
if name and (name.endswith(".go") or name in ("go.mod", "internal/binaries/manifest.json")):
archive.write(root / name, f"{module}@{version}/{name}")
# Expose the private resolver only inside this temporary module
# fixture, so an external consumer calls the production code.
archive.writestr(f"{module}@{version}/release_fixture.go",
"package embeddedpostgres\nfunc FixtureSupervisorVersion() string { return supervisorVersion() }\n")
consumer = stage / "consumer"
consumer.mkdir()
(consumer / "go.mod").write_text(f"module example.com/consumer\n\ngo 1.26.0\n\nrequire {module} {version}\n")
(consumer / "main.go").write_text(
f'package main\nimport ("fmt"; postgres "{module}")\n'
'func main() { fmt.Print(postgres.FixtureSupervisorVersion()) }\n')
(consumer / "main_test.go").write_text(
f'package main\nimport ("testing"; postgres "{module}")\n'
'func TestSupervisorVersion(t *testing.T) { '
f'if got := postgres.FixtureSupervisorVersion(); got != "{version}" {{ t.Fatalf("got %q", got) }} }}\n')
env = dict(os.environ, GOPROXY=(stage / "proxy").as_uri(), GOSUMDB="off",
GOPRIVATE="", GONOPROXY="", GONOSUMDB="", GOWORK="off",
GOTOOLCHAIN="local", GOMODCACHE=str(stage / "modules"), GOFLAGS="-modcacherw")
for args in [("run", "-mod=mod", "."), ("test", "-mod=mod", "-count=1", "."),
("test", "-mod=mod", "-count=1", "-trimpath", ".")]:
with self.subTest(command=args):
result = subprocess.run(["go", *args], cwd=consumer, env=env, text=True,
capture_output=True, timeout=180)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
if args[0] == "run":
self.assertEqual(result.stdout, version)
84 changes: 84 additions & 0 deletions scripts/test_release.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import json
import os
import pathlib
import tempfile
import unittest
from unittest.mock import patch

import release
from release import next_version


Expand All @@ -17,3 +24,80 @@ def test_refuses_invalid_config(self):
for config in [{"base": "1.0.0", "prerelease": ""}, {"base": "2.0.0", "prerelease": "../bad"}]:
with self.assertRaises(ValueError):
next_version(config, [])


class ReleasePublicationTest(unittest.TestCase):
tag = "v2.0.0-alpha.1"
sha = "a" * 40

def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = pathlib.Path(temporary.name)
self.dist = self.root / "dist"
self.dist.mkdir()
self.output = self.root / "output"
self.enterContext(patch.object(release, "ROOT", self.root))
self.enterContext(patch.dict(os.environ, GITHUB_SHA=self.sha,
GITHUB_REF="refs/heads/master", GITHUB_EVENT_NAME="push",
GITHUB_OUTPUT=str(self.output)))
self.command = self.enterContext(patch.object(release, "command"))

def draft(self, **changes):
return dict(isDraft=True, targetCommitish=self.sha, **changes)

def create_assets(self, count=12):
for index in range(count):
(self.dist / f"asset-{index}").write_text("built artifact")

def test_publishes_draft_without_an_existing_tag(self):
self.create_assets()

def github(*args, **kwargs):
# A draft without a tag is visible through release view, while
# the published-release REST lookup fails with HTTP 404.
if args[:3] == ("gh", "release", "view"):
return json.dumps(self.draft())
if args[:3] in (("gh", "release", "upload"), ("gh", "release", "edit")):
return ""
raise AssertionError(f"unsupported draft lookup: {args}")

self.command.side_effect = github
release.publish(self.tag)
upload = self.command.call_args_list[1].args
self.assertEqual(upload[:7], ("gh", "release", "upload", self.tag, "--repo", release.REPO, "--clobber"))
self.assertEqual(set(upload[7:]), {str(path) for path in self.dist.iterdir()})
self.command.assert_called_with("gh", "release", "edit", self.tag, "--repo", release.REPO,
"--draft=false", "--prerelease=true", "--latest=false")

def test_published_release_is_immutable_even_without_local_assets(self):
self.dist.rmdir()
self.command.return_value = json.dumps({"isDraft": False, "targetCommitish": self.sha})
release.publish(self.tag)
self.assertEqual(self.command.call_count, 1)

def test_draft_for_another_commit_cannot_be_published(self):
self.create_assets()
self.command.return_value = json.dumps({"isDraft": True, "targetCommitish": "b" * 40})
with self.assertRaisesRegex(SystemExit, "different commit"):
release.publish(self.tag)
self.assertEqual(self.command.call_count, 1)

def test_incomplete_build_cannot_be_published(self):
self.create_assets(11)
self.command.return_value = json.dumps(self.draft())
with self.assertRaisesRegex(SystemExit, "incomplete release assets"):
release.publish(self.tag)
self.assertEqual(self.command.call_count, 1)

def test_prepare_reuses_draft_and_skips_published_release_on_retry(self):
for draft in (True, False):
with self.subTest(draft=draft):
self.output.write_text("")
self.command.reset_mock()
self.command.side_effect = [self.sha, json.dumps([[{
"tag_name": self.tag, "target_commitish": self.sha, "draft": draft,
}]]), "", ""]
release.prepare()
self.assertEqual(self.output.read_text(), f"tag={self.tag}\npublish={str(draft).lower()}\n")
self.assertFalse(any(call.args[:2] == ("gh", "release") for call in self.command.call_args_list))
29 changes: 25 additions & 4 deletions supervisor_binary.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,7 @@ func resolveSupervisor(ctx context.Context, c Config) (string, error) {
}
return p, nil
}
version := ""
if info, ok := debug.ReadBuildInfo(); ok {
version = moduleVersion(info)
}
version := supervisorVersion()
if !publishedVersion(version) {
return "", errors.New("source build requires Config.Supervisor(path) or EP_SUPERVISOR; build ./cmd/embedded-postgres first")
}
Expand All @@ -71,6 +68,30 @@ func moduleVersion(info *debug.BuildInfo) string {
}
return ""
}

func supervisorVersion() string {
if info, ok := debug.ReadBuildInfo(); ok && len(info.Deps) != 0 {
return moduleVersion(info)
}
// Go test binaries omit dependency versions. Module-cache source paths
// retain them, including with -trimpath (module/path@version/file.go).
_, source, _, ok := runtime.Caller(0)
if !ok {
return ""
}
return sourceModuleVersion(source)
}

func sourceModuleVersion(source string) string {
source = strings.ReplaceAll(source, `\`, "/")
_, suffix, found := strings.Cut("/"+source, "/github.com/fergusstrange/embedded-postgres/v2@")
version, file, separated := strings.Cut(suffix, "/")
if !found || !separated || file != "supervisor_binary.go" || !publishedVersion(version) {
return ""
}
return version
}

func cacheSupervisor(ctx context.Context, cache, version, name, base string, client *http.Client) (string, error) {
path := filepath.Join(cache, version+"-"+name)
lock, err := filelock.Acquire(ctx, path+".lock", true)
Expand Down
27 changes: 27 additions & 0 deletions supervisor_binary_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,30 @@ func TestPublishedSupervisorVersions(t *testing.T) {
}
}
}

func TestSupervisorSourceModuleVersion(t *testing.T) {
for _, source := range []string{
"/go/pkg/mod/github.com/fergusstrange/embedded-postgres/v2@v2.0.0-alpha.1/supervisor_binary.go",
"github.com/fergusstrange/embedded-postgres/v2@v2.0.0-alpha.1/supervisor_binary.go",
`C:\Users\test\go\pkg\mod\github.com\fergusstrange\embedded-postgres\v2@v2.0.0-alpha.1\supervisor_binary.go`,
} {
if got := sourceModuleVersion(source); got != "v2.0.0-alpha.1" {
t.Errorf("%s: got %q", source, got)
}
}
for _, source := range []string{
"/checkout/embedded-postgres/supervisor_binary.go",
"github.com/fergusstrange/embedded-postgres/v2/supervisor_binary.go",
"/app/vendor/github.com/fergusstrange/embedded-postgres/v2/supervisor_binary.go",
"github.com/fergusstrange/embedded-postgres/v2@v2.0.0-20261005024047-69c76a8ace40/supervisor_binary.go",
"github.com/fergusstrange/embedded-postgres/v2@v2.0.0-dev/supervisor_binary.go",
"github.com/fergusstrange/embedded-postgres/v2@v2.0.0/supervisor_binary.go/other.go",
"github.com/fergusstrange/embedded-postgres/v2@v2.0.0/other.go",
"notgithub.com/fergusstrange/embedded-postgres/v2@v2.0.0/supervisor_binary.go",
"github.com/fergusstrange/embedded-postgres/v2@v2.0.0",
} {
if got := sourceModuleVersion(source); got != "" {
t.Errorf("unversioned or unrelated source %s selected release %q", source, got)
}
}
}
Loading