diff --git a/docs/migration-v2.md b/docs/migration-v2.md index 46c6eac..720954f 100644 --- a/docs/migration-v2.md +++ b/docs/migration-v2.md @@ -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. diff --git a/scripts/release.py b/scripts/release.py index 432f33e..a1232f2 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -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: diff --git a/scripts/test_module_consumer.py b/scripts/test_module_consumer.py new file mode 100644 index 0000000..21c0942 --- /dev/null +++ b/scripts/test_module_consumer.py @@ -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) diff --git a/scripts/test_release.py b/scripts/test_release.py index c2e87e0..38b197a 100644 --- a/scripts/test_release.py +++ b/scripts/test_release.py @@ -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 @@ -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)) diff --git a/supervisor_binary.go b/supervisor_binary.go index cc7a35c..9c71f83 100644 --- a/supervisor_binary.go +++ b/supervisor_binary.go @@ -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") } @@ -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) diff --git a/supervisor_binary_test.go b/supervisor_binary_test.go index b6267b7..0d0b682 100644 --- a/supervisor_binary_test.go +++ b/supervisor_binary_test.go @@ -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) + } + } +}