turbo-editors/turbo-pythonpublic Fork 0
v1.0.2
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-python.git
git clone ssh://git@rickub.com/turbo-editors/turbo-python.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

release_test.go · 594 lines · 21.4 KBGo Blame HistoryRaw
📦 Turbo Python 6fc62ea k33g 8h ago1package main
2
3import (
4 "os"
5 "os/exec"
6 "path/filepath"
7 "runtime"
8 "strings"
9 "testing"
10)
11
12// readReleaseScript returns the release builder, so its rules can be asserted
13// without running it: running it cross-compiles five binaries, which is not a
14// unit test. (Running the tagging script, on the other hand, is done below,
15// against a throwaway clone.)
16func readReleaseScript(t *testing.T) string {
17 t.Helper()
18
19 script, err := os.ReadFile("02-build-releases.sh")
20 if err != nil {
21 t.Fatalf("cannot read the release script: %v", err)
22 }
23 return string(script)
24}
25
26func TestTheReleaseScriptStampsTheBinariesItShips(t *testing.T) {
27 // Without -ldflags on the cross-compile, every downloaded binary reports
28 // "devel" while the release page names a version. The host binary would
29 // still be right, so nothing but this notices.
30 script := readReleaseScript(t)
31
32 build := commandContaining(t, script, "GOARCH=")
33 if !strings.Contains(build, "-ldflags") {
34 t.Errorf("the cross-compile does not stamp a version:\n%s", build)
35 }
36}
37
38func TestTheReleaseScriptTakesTheStampFromTheMakefile(t *testing.T) {
39 // Repeating the -X paths in the script is how the host binary and the
40 // downloads would come to disagree about which package holds the version.
41 script := readReleaseScript(t)
42
43 if !strings.Contains(script, "make --no-print-directory ldflags") {
44 t.Error("the script does not read the linker flags from the Makefile")
45 }
46 if strings.Contains(script, "version.stamp=") {
47 t.Error("the script spells out the -X path, which the Makefile already owns")
48 }
49}
50
51func TestTheReleaseScriptStampsTheTagItIsReleasing(t *testing.T) {
52 // The release *is* ${TAG}, so that is what the binaries say. Letting the
53 // Makefile's default stand would stamp `git describe`, which answers a
54 // different question — where HEAD is — and disagrees the moment anyone
55 // commits after tagging.
56 script := readReleaseScript(t)
57
58 flags := commandContaining(t, script, "ldflags")
59 if !strings.Contains(flags, `VERSION="${TAG}"`) {
60 t.Errorf("the stamp does not come from TAG:\n%s", flags)
61 }
62 if build := commandContaining(t, script, "make build"); !strings.Contains(build, `VERSION="${TAG}"`) {
63 t.Errorf("the host build carries a different version from the assets:\n%s", build)
64 }
65}
66
67func TestTheReleaseScriptDoesNotParseTheVersionOutOfProse(t *testing.T) {
68 // `-version` is written for a person and has changed shape once already;
69 // awk '{print $NF}' on it read a timestamp and failed a release.
70 script := readReleaseScript(t)
71
72 if strings.Contains(script, "$NF") {
73 t.Error("the script reads a field out of the -version line, which is prose")
74 }
75}
76
77func TestTheMakefileHandsOutTheFlagsThatStampABuild(t *testing.T) {
78 // The contract the release script depends on: `make ldflags` prints flags
79 // that actually put *the Makefile's own version* into a binary.
80 //
81 // It is checked against `make version` rather than against "not devel",
82 // because a checkout with no tags — a fresh clone, or a repository that has
83 // never had a release — correctly reports devel, and a test that called
84 // that a failure would be testing the tags rather than the flags.
85 version, err := exec.Command("make", "--no-print-directory", "version").Output()
86 if err != nil {
87 t.Fatalf("make version: %v", err)
88 }
89 // internal/version drops the leading v of a tag, so the comparison has to
90 // as well: `make version` says v0.2.1 and the binary says 0.2.1.
91 number := strings.TrimPrefix(strings.Fields(strings.TrimSpace(string(version)))[0], "v")
92
93 flags, err := exec.Command("make", "--no-print-directory", "ldflags").Output()
94 if err != nil {
95 t.Fatalf("make ldflags: %v", err)
96 }
97
98 binary := filepath.Join(t.TempDir(), "turbo-python")
99 build := exec.Command("go", "build", "-trimpath", "-ldflags", strings.TrimSpace(string(flags)), "-o", binary, ".")
100 build.Env = append(os.Environ(), "CGO_ENABLED=0", "GOOS="+runtime.GOOS, "GOARCH="+runtime.GOARCH)
101 if out, err := build.CombinedOutput(); err != nil {
102 t.Fatalf("building with those flags failed: %v\n%s", err, out)
103 }
104
105 reported, err := exec.Command(binary, "-version").Output()
106 if err != nil {
107 t.Fatalf("the stamped binary does not run: %v", err)
108 }
109 if !strings.Contains(string(reported), number) {
110 t.Errorf("-version printed %q, want it to carry the Makefile's version %q", reported, number)
111 }
112 if strings.Contains(string(reported), "unknown") {
113 t.Errorf("-version printed %q, so nothing reached the linker at all", reported)
114 }
115}
116
117func TestMakeLdflagsTakesTheVersionItIsGiven(t *testing.T) {
118 // The release script overrides VERSION with the tag it is releasing, and
119 // everything downstream rests on that override reaching the linker.
120 flags, err := exec.Command("make", "--no-print-directory", "ldflags", "VERSION=v9.9.9").Output()
121 if err != nil {
122 t.Fatalf("make ldflags: %v", err)
123 }
124
125 binary := filepath.Join(t.TempDir(), "turbo-python")
126 build := exec.Command("go", "build", "-trimpath", "-ldflags", strings.TrimSpace(string(flags)), "-o", binary, ".")
127 build.Env = append(os.Environ(), "CGO_ENABLED=0", "GOOS="+runtime.GOOS, "GOARCH="+runtime.GOARCH)
128 if out, err := build.CombinedOutput(); err != nil {
129 t.Fatalf("building with those flags failed: %v\n%s", err, out)
130 }
131
132 reported, err := exec.Command(binary, "-version").Output()
133 if err != nil {
134 t.Fatalf("the stamped binary does not run: %v", err)
135 }
136 if !strings.Contains(string(reported), "9.9.9") {
137 t.Errorf("-version printed %q, so VERSION=v9.9.9 never reached the linker", reported)
138 }
139}
140
141// commandContaining returns the first shell command of a script holding a
142// fragment, with backslash continuations joined: a command's flags are often
143// on the line after the one that names it, and a test about the command should
144// not depend on where it happens to wrap.
145func commandContaining(t *testing.T, script, fragment string) string {
146 t.Helper()
147
148 joined := strings.ReplaceAll(script, "\\\n", " ")
149 for _, line := range strings.Split(joined, "\n") {
150 if strings.Contains(line, fragment) {
151 return strings.TrimSpace(line)
152 }
153 }
154 t.Fatalf("no command in the script contains %q", fragment)
155 return ""
156}
157
158// readTagScript returns the tagging script, whose failure modes are what the
159// release builder is left to notice when they are not caught here.
160func readTagScript(t *testing.T) string {
161 t.Helper()
162
163 script, err := os.ReadFile("01-release.tag.sh")
164 if err != nil {
165 t.Fatalf("cannot read the tagging script: %v", err)
166 }
167 return string(script)
168}
169
170func TestTheTagScriptStopsOnTheFirstFailure(t *testing.T) {
171 // Without this, `git tag` refusing a tag that already existed was skipped
172 // in silence and the `git push` after it pushed the OLD tag, cutting a
173 // release from a commit nobody meant.
174 if !strings.Contains(readTagScript(t), "set -euo pipefail") {
175 t.Error("the tagging script does not stop on a failing step")
176 }
177}
178
179func TestTheTagScriptRefusesATagThatAlreadyExists(t *testing.T) {
180 script := readTagScript(t)
181
182 for _, want := range []string{
183 "git rev-parse -q --verify", // taken locally
184 "git ls-remote --tags origin", // taken on the remote, after a local delete
185 } {
186 if !strings.Contains(script, want) {
187 t.Errorf("the tagging script never checks %q", want)
188 }
189 }
190}
191
192func TestTheTagScriptSurvivesHavingNothingToCommit(t *testing.T) {
193 // Under `set -e` a plain `git commit` with a clean tree ends the release,
194 // which is wrong: the work being already committed is the normal case on a
195 // second run.
196 script := readTagScript(t)
197
198 if !strings.Contains(script, "git diff --cached --quiet") {
199 t.Error("the tagging script commits without checking there is anything to commit")
200 }
201}
202
203func TestTheTagScriptTagsOnlyAfterThePushSucceeded(t *testing.T) {
204 // A tag left behind pointing at a commit the remote has never seen is the
205 // state that needs a force push to escape.
206 script := readTagScript(t)
207
208 push := strings.Index(script, `git push origin "$(git rev-parse`)
209 tag := strings.Index(script, `git tag -a "${TAG}"`)
210 if push < 0 || tag < 0 {
211 t.Fatal("the tagging script no longer pushes and tags")
212 }
213 if tag < push {
214 t.Error("the script tags before pushing, so a rejected push leaves a stray tag")
215 }
216}
217
218// skipInsideARelease stops a test that runs the tagging script from running
219// while the tagging script is running it.
220//
221// The script sets this before `make check`, and `make check` runs this suite.
222// Without the guard the two call each other forever — which is not a test-only
223// hazard: a real release would recurse in exactly the same way. The Release
224// workflow sets it too, for the same reason.
225func skipInsideARelease(t *testing.T) {
226 t.Helper()
227 if os.Getenv("TURBO_PYTHON_RELEASING") != "" {
228 t.Skip("running inside a release; not starting another one")
229 }
230}
231
232func TestTheTagScriptRunsTheSuiteBeforePublishing(t *testing.T) {
233 // A version people will download, and the proxy will cache, is the wrong
234 // place to find out the suite was red.
235 if !strings.Contains(readTagScript(t), "make --no-print-directory check") {
236 t.Error("the tagging script publishes without running make check")
237 }
238}
239
240func TestTheTagScriptRefusesAReplaceDirective(t *testing.T) {
241 // The proxy serves go.mod as written, so `go install …@TAG` on a module
242 // carrying a replace looks for turbo-core in a directory that does not
243 // exist on the installer's machine.
244 if !strings.Contains(readTagScript(t), "replace") {
245 t.Error("the tagging script does not check go.mod for a replace directive")
246 }
247}
248
249func TestThisModuleHasNoReplaceDirective(t *testing.T) {
250 // The check above only helps if it is true today as well.
251 data, err := os.ReadFile("go.mod")
252 if err != nil {
253 t.Fatalf("reading go.mod: %v", err)
254 }
255 for _, line := range strings.Split(string(data), "\n") {
256 if strings.HasPrefix(strings.TrimSpace(line), "replace ") {
257 t.Errorf("go.mod carries %q; a published module must not", line)
258 }
259 }
260}
261
262func TestTheReleaseToolingNeedsNoPersonalToken(t *testing.T) {
263 // The Release workflow publishes with the job's own GITHUB_TOKEN, which is
264 // the only credential Rickub's release API accepts. A script still reading
265 // a token file is a credential that cannot work and has to be kept
266 // somewhere all the same — and a 02 or 04 left in the tree is a second
267 // pipeline somebody will run by mistake.
268 for _, script := range []string{"01-release.tag.sh", "02-build-releases.sh"} {
269 data, err := os.ReadFile(script)
270 if err != nil {
271 t.Fatalf("reading %s: %v", script, err)
272 }
273 for _, secret := range []string{"token.env", "${TOKEN}"} {
274 if strings.Contains(string(data), secret) {
275 t.Errorf("%s still reads %s", script, secret)
276 }
277 }
278 }
279 for _, gone := range []string{"02-release.publish.sh", "04-release.upload-binaries.sh"} {
280 if _, err := os.Stat(gone); err == nil {
281 t.Errorf("%s is still there; the workflow publishes and attaches the binaries now", gone)
282 }
283 }
284}
285
286func TestTheTagScriptTagsAndPushesForReal(t *testing.T) {
287 // The whole flow, in a throwaway clone with its own bare remote, so no tag
288 // is ever created in the real repository. Reading the script is not the
289 // same as running it: every guard above was added because one of them was
290 // wrong once.
291 skipInsideARelease(t)
292 if _, err := exec.LookPath("git"); err != nil {
293 t.Skip("git is not available")
294 }
295
296 remote, clone := throwawayClone(t)
297
298 out, err := runAllowingFailure(t, clone, "./01-release.tag.sh")
299 if err != nil {
300 t.Fatalf("the tagging script failed:\n%s", out)
301 }
302 if !strings.Contains(out, "published") {
303 t.Errorf("the script did not report publishing:\n%s", out)
304 }
305
306 tags, _ := runAllowingFailure(t, remote, "git", "tag")
307 if !strings.Contains(tags, "v0.0.1-test") {
308 t.Errorf("the remote has tags %q, want v0.0.1-test", strings.TrimSpace(tags))
309 }
310}
311
312func TestTheTagScriptRefusesATagItAlreadyPublished(t *testing.T) {
313 // Moving a published version is not an option: the proxy caches what it
314 // fetched, and the release page already carries binaries with that number.
315 skipInsideARelease(t)
316 if _, err := exec.LookPath("git"); err != nil {
317 t.Skip("git is not available")
318 }
319
320 _, clone := throwawayClone(t)
321
322 if out, err := runAllowingFailure(t, clone, "./01-release.tag.sh"); err != nil {
323 t.Fatalf("the first release failed:\n%s", out)
324 }
325
326 out, err := runAllowingFailure(t, clone, "./01-release.tag.sh")
327
328 if err == nil {
329 t.Fatalf("the script published the same tag twice:\n%s", out)
330 }
331 if !strings.Contains(out, "already exists") {
332 t.Errorf("the refusal does not say the tag is taken:\n%s", out)
333 }
334}
335
336// throwawayClone sets up a bare remote and a clone of it holding a copy of
337// this module and a release.env naming a test version, and returns both paths.
338func throwawayClone(t *testing.T) (remote, clone string) {
339 t.Helper()
340
341 root := t.TempDir()
342 remote = filepath.Join(root, "remote.git")
343 clone = filepath.Join(root, "clone")
344
345 runOrFail(t, root, "git", "init", "--bare", "--initial-branch=main", remote)
346 runOrFail(t, root, "git", "clone", remote, clone)
347 copyModuleInto(t, clone)
348 runOrFail(t, clone, "git", "config", "user.email", "test@example.test")
349 runOrFail(t, clone, "git", "config", "user.name", "Release Test")
350 writeTestFile(t, filepath.Join(clone, "release.env"), "TAG=v0.0.1-test\nABOUT=\"a throwaway release\"\n")
351 return remote, clone
352}
353
354// copyModuleInto copies the module's source into a directory, so the script can
355// be run against a real checkout without touching this one.
356//
357// .git is left out because the target has its own; *.env because a test writes
358// its own release.env — copying this checkout's would release whatever version
359// happens to be in it; go.work because it would point the copy at a turbo-core
360// checkout that is not what a release builds against; and the build outputs
361// (bin, release, kits) and the demo project because they are hundreds of
362// megabytes the script never reads.
363//
364// The copying is done here rather than by shelling out to cp, which on a
365// network-backed working copy has been seen to write the right number of bytes
366// and the wrong ones: every file in the copy came out NUL-filled.
367func copyModuleInto(t *testing.T, target string) {
368 t.Helper()
369
370 entries, err := os.ReadDir(".")
371 if err != nil {
372 t.Fatalf("reading the module: %v", err)
373 }
374 for _, entry := range entries {
375 if leftOutOfTheCopy(entry.Name()) {
376 continue
377 }
378 copyTree(t, entry.Name(), filepath.Join(target, entry.Name()))
379 }
380}
381
382// leftOutOfTheCopy reports whether a top-level entry stays out of a throwaway
383// copy of the module.
384func leftOutOfTheCopy(name string) bool {
385 switch name {
386 case ".git", "bin", "release", "kits", "demo", "demos", "go.work", "go.work.sum":
387 return true
388 }
389 return strings.HasSuffix(name, ".env")
390}
391
392// copyTree copies a file or a directory to a new path.
393//
394// Anything that is neither a regular file nor a directory is skipped: the tool
395// directories beside the source hold symlinks into caches that do not exist in
396// a temporary copy, and the release scripts have no use for them.
397func copyTree(t *testing.T, from, to string) {
398 t.Helper()
399
400 err := filepath.WalkDir(from, func(path string, entry os.DirEntry, err error) error {
401 if err != nil {
402 return err
403 }
404 relative, err := filepath.Rel(from, path)
405 if err != nil {
406 return err
407 }
408 destination := filepath.Join(to, relative)
409
410 if entry.IsDir() {
411 return os.MkdirAll(destination, 0o755)
412 }
413 if !entry.Type().IsRegular() {
414 return nil
415 }
416 info, err := entry.Info()
417 if err != nil {
418 return err
419 }
420 data, err := os.ReadFile(path)
421 if err != nil {
422 return err
423 }
424 if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
425 return err
426 }
427 // The mode carries the execute bit, without which the scripts these
428 // tests exist to run cannot be run.
429 return os.WriteFile(destination, data, info.Mode().Perm())
430 })
431 if err != nil {
432 t.Fatalf("copying %s: %v", from, err)
433 }
434}
435
436// runOrFail executes a command in a directory, failing the test if it does not
437// succeed.
438func runOrFail(t *testing.T, dir string, name string, args ...string) {
439 t.Helper()
440
441 if out, err := runAllowingFailure(t, dir, name, args...); err != nil {
442 t.Fatalf("%s %v: %v\n%s", name, args, err, out)
443 }
444}
445
446// runAllowingFailure executes a command and returns its combined output along
447// with whether it succeeded.
448//
449// GOWORK is switched off for the child: a go.work beside this checkout points
450// at a turbo-core working tree, and a release is built against the published
451// module, which is what a clean clone would see.
452func runAllowingFailure(t *testing.T, dir string, name string, args ...string) (string, error) {
453 t.Helper()
454
455 command := exec.Command(name, args...)
456 command.Dir = dir
457 command.Env = append(os.Environ(), "GOWORK=off")
458 out, err := command.CombinedOutput()
459 return string(out), err
460}
461
462// writeTestFile creates a file, failing the test if it cannot.
463func writeTestFile(t *testing.T, path, contents string) {
464 t.Helper()
465
466 if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
467 t.Fatalf("writing %s: %v", path, err)
468 }
469}
470
471func TestTheBuildScriptTakesTheTagFromTheCommandLine(t *testing.T) {
472 // release.env is git-ignored, so the workflow has none: it passes the tag
473 // it was started by. A script that only reads the file builds nothing in
474 // CI, or builds whatever version the file last named.
475 script := readReleaseScript(t)
476
477 if !strings.Contains(script, `TAG="${1:-${TAG:-}}"`) {
478 t.Error("the build script does not take the tag from its first argument")
479 }
480 if !strings.Contains(script, `[ -f release.env ]`) {
481 t.Error("the build script requires release.env, which CI does not have")
482 }
483}
484
485func TestTheBuildScriptRefusesATagThatIsNotAVersion(t *testing.T) {
486 // The proxy will not serve a tag it cannot read as a version, so a typo
487 // here builds perfectly and then fails at every `go install`.
488 //
489 // The refusal comes before anything is built or written, so the script
490 // alone is enough: it is run from an empty directory with no release.env.
491 dir := t.TempDir()
492 script, err := os.ReadFile("02-build-releases.sh")
493 if err != nil {
494 t.Fatalf("reading the build script: %v", err)
495 }
496 writeTestFile(t, filepath.Join(dir, "02-build-releases.sh"), string(script))
497
498 out, err := runAllowingFailure(t, dir, "bash", "./02-build-releases.sh", "v0.o.0")
499
500 if err == nil {
501 t.Fatalf("the script accepted a tag that is not a version:\n%s", out)
502 }
503 if !strings.Contains(out, "v1.2.3") {
504 t.Errorf("the refusal does not say what a tag should look like:\n%s", out)
505 }
506}
507
508func TestTheBuildScriptDoesNotHandOffToAnUploadScript(t *testing.T) {
509 // 04 attached the binaries to a release page a personal token had created.
510 // The workflow does both now; a script still pointing at 04 sends the
511 // reader to run something that is not there.
512 if strings.Contains(readReleaseScript(t), "04-release") {
513 t.Error("the build script still hands off to 04-release.upload-binaries.sh")
514 }
515}
516
517// readWorkflow returns the release workflow's text.
518func readWorkflow(t *testing.T) string {
519 t.Helper()
520
521 data, err := os.ReadFile(filepath.Join(".github", "workflows", "release.yml"))
522 if err != nil {
523 t.Fatalf("reading the release workflow: %v", err)
524 }
525 return string(data)
526}
527
528func TestTheWorkflowPublishesOnATagPush(t *testing.T) {
529 // The tag push is the trigger: ./01-release.tag.sh ends by pushing one,
530 // and nothing else starts a release.
531 workflow := readWorkflow(t)
532
533 for _, want := range []string{"push:", "tags:", `- "v*"`} {
534 if !strings.Contains(workflow, want) {
535 t.Errorf("the workflow never declares %q", want)
536 }
537 }
538 // A workflow with the default read-only token cannot create a release, and
539 // fails at its last step after doing all the work.
540 if !strings.Contains(workflow, "contents: write") {
541 t.Error("the workflow does not ask for contents: write")
542 }
543}
544
545func TestTheWorkflowBuildsWithTheSameScriptAPersonRuns(t *testing.T) {
546 // A CI job that builds its own way is a second pipeline nobody tests, and
547 // the local one is then only ever exercised by accident.
548 if !strings.Contains(readWorkflow(t), "./02-build-releases.sh") {
549 t.Error("the workflow does not build the release with ./02-build-releases.sh")
550 }
551}
552
553func TestTheWorkflowAttachesWhatWasBuilt(t *testing.T) {
554 // Publishing a release page with no files attached is a silent half-job:
555 // the page exists and the downloads are not there.
556 workflow := readWorkflow(t)
557
558 for _, want := range []string{"turbo-python-*", "SHA256SUMS", "fail_on_unmatched_files: true"} {
559 if !strings.Contains(workflow, want) {
560 t.Errorf("the workflow never mentions %q", want)
561 }
562 }
563}
564
565func TestTheWorkflowLinksToTheDocumentationAtThatTag(t *testing.T) {
566 // A release page is not inside the repository tree, so a relative path
567 // from it 404s — and a link to the branch would rot as the branch moves.
568 workflow := readWorkflow(t)
569
570 if !strings.Contains(workflow, "blob/${GITHUB_REF_NAME}") {
571 t.Error("the release notes do not link into the repository at the released tag")
572 }
573 if !strings.Contains(workflow, "/docs/en/README.md") {
574 t.Error("the release notes do not link to the documentation")
575 }
576}
577
578func TestTheWorkflowNeedsNoPersonalToken(t *testing.T) {
579 // The release API behind Rickub's /gh shim accepts the job's own
580 // GITHUB_TOKEN and refuses a personal one, so a secret referenced here is
581 // a credential that cannot work and still has to be kept somewhere.
582 if strings.Contains(readWorkflow(t), "secrets.") {
583 t.Error("the workflow reads a secret; the job's own token is the only credential the release API takes")
584 }
585}
586
587func TestTheWorkflowDoesNotStartAReleaseInsideItself(t *testing.T) {
588 // The suite it runs includes tests that run ./01-release.tag.sh against a
589 // throwaway clone. Locally the script exports this before calling make;
590 // in CI nothing calls the script, so the job has to set it itself.
591 if !strings.Contains(readWorkflow(t), "TURBO_PYTHON_RELEASING") {
592 t.Error("the workflow runs the suite without TURBO_PYTHON_RELEASING set")
593 }
594}