turbo-editors/turbo-corepublic Fork 0
main
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-core.git
git clone ssh://git@rickub.com/turbo-editors/turbo-core.git

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

release_test.go · 378 lines · 12.8 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 4h ago1// Tests for the release tooling. They live in the module root because that is
2// where the script is, and because a broken release script is only discovered
3// at the worst possible moment otherwise.
4package main
5
6import (
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strings"
11 "testing"
12)
13
14// skipInsideARelease stops a test that runs the release script from running
15// while the release script is running it.
16//
17// The script sets this before `make check`, and `make check` runs this suite.
18// Without the guard the two call each other forever — which is not a test-only
19// hazard: a real release would recurse in exactly the same way.
20func skipInsideARelease(t *testing.T) {
21 t.Helper()
22 if os.Getenv("TURBO_CORE_RELEASING") != "" {
23 t.Skip("running inside a release; not starting another one")
24 }
25}
26
27// readReleaseScript returns the tag script's text.
28func readReleaseScript(t *testing.T) string {
29 t.Helper()
30
31 data, err := os.ReadFile("01-release.tag.sh")
32 if err != nil {
33 t.Fatalf("reading the release script: %v", err)
34 }
35 return string(data)
36}
37
38func TestTheReleaseScriptStopsOnTheFirstFailure(t *testing.T) {
39 // Without this, `git tag` refusing an existing tag is skipped in silence
40 // and the push that follows pushes the *old* one. It happened in turbo-go.
41 if got := readReleaseScript(t); !strings.Contains(got, "set -euo pipefail") {
42 t.Error("the script does not stop on failure")
43 }
44}
45
46func TestTheReleaseScriptChecksBothRefsForTheTag(t *testing.T) {
47 // A tag deleted locally after a failed attempt still exists on origin, and
48 // that state is invisible from the local repository alone.
49 script := readReleaseScript(t)
50
51 for _, want := range []string{"refs/tags/${TAG}", "git ls-remote --tags origin"} {
52 if !strings.Contains(script, want) {
53 t.Errorf("the script never looks for %q", want)
54 }
55 }
56}
57
58func TestTheReleaseScriptRunsTheSuiteBeforePublishing(t *testing.T) {
59 // A version two editors will pin is the wrong place to find out the suite
60 // was red.
61 if got := readReleaseScript(t); !strings.Contains(got, "make --no-print-directory check") {
62 t.Error("the script publishes without running make check")
63 }
64}
65
66func TestTheReleaseScriptRefusesAReplaceDirective(t *testing.T) {
67 // The proxy serves go.mod as written, so a published library carrying a
68 // replace tells every consumer to look for turbo-core in a directory that
69 // does not exist on their machine.
70 if got := readReleaseScript(t); !strings.Contains(got, "replace") {
71 t.Error("the script does not check go.mod for a replace directive")
72 }
73}
74
75func TestThisModuleHasNoReplaceDirective(t *testing.T) {
76 // The check above only helps if it is true today as well.
77 data, err := os.ReadFile("go.mod")
78 if err != nil {
79 t.Fatalf("reading go.mod: %v", err)
80 }
81 for _, line := range strings.Split(string(data), "\n") {
82 if strings.HasPrefix(strings.TrimSpace(line), "replace ") {
83 t.Errorf("go.mod carries %q; a published library must not", line)
84 }
85 }
86}
87
88func TestTheReleaseScriptTagsOnlyAfterPushing(t *testing.T) {
89 // A rejected push must not leave a tag behind pointing at a commit the
90 // remote has never seen.
91 script := readReleaseScript(t)
92
93 push := strings.Index(script, "git push origin \"$(git rev-parse")
94 tag := strings.Index(script, "git tag -a")
95 if push < 0 || tag < 0 {
96 t.Fatal("the script neither pushes nor tags")
97 }
98 if tag < push {
99 t.Error("the script tags before it pushes")
100 }
101}
102
103func TestTheReleaseScriptSaysHowToPointTheEditorsAtTheNewVersion(t *testing.T) {
104 // Publishing is half the job; an editor still pinning the old version and
105 // a replace directive is the state this is designed to get out of.
106 script := readReleaseScript(t)
107
108 for _, want := range []string{"go mod edit -require", "-dropreplace"} {
109 if !strings.Contains(script, want) {
110 t.Errorf("the script never mentions %q", want)
111 }
112 }
113}
114
115func TestMakeVersionReportsSomethingUsable(t *testing.T) {
116 out, err := exec.Command("make", "--no-print-directory", "version").Output()
117 if err != nil {
118 t.Fatalf("make version: %v", err)
119 }
120 if strings.TrimSpace(string(out)) == "" {
121 t.Error("make version printed nothing")
122 }
123}
124
125func TestTheReleaseScriptTagsAndPushesForReal(t *testing.T) {
126 // The whole flow, in a throwaway clone with its own bare remote, so no tag
127 // is ever created in the real repository. Reading the script is not the
128 // same as running it: every guard above was added because one of them was
129 // wrong once.
130 skipInsideARelease(t)
131 if _, err := exec.LookPath("git"); err != nil {
132 t.Skip("git is not available")
133 }
134
135 root := t.TempDir()
136 remote := filepath.Join(root, "remote.git")
137 clone := filepath.Join(root, "clone")
138
139 run(t, root, "git", "init", "--bare", "--initial-branch=main", remote)
140 run(t, root, "git", "clone", remote, clone)
141 copyModuleInto(t, clone)
142 run(t, clone, "git", "config", "user.email", "test@example.test")
143 run(t, clone, "git", "config", "user.name", "Release Test")
144 writeFile(t, filepath.Join(clone, "release.env"), "TAG=v0.0.1-test\nABOUT=\"a throwaway release\"\n")
145
146 out, err := runAllowingFailure(t, clone, "./01-release.tag.sh")
147 if err != nil {
148 t.Fatalf("the release script failed:\n%s", out)
149 }
150 if !strings.Contains(out, "published") {
151 t.Errorf("the script did not report publishing:\n%s", out)
152 }
153
154 tags, _ := runAllowingFailure(t, remote, "git", "tag")
155 if !strings.Contains(tags, "v0.0.1-test") {
156 t.Errorf("the remote has tags %q, want v0.0.1-test", strings.TrimSpace(tags))
157 }
158}
159
160func TestTheReleaseScriptRefusesATagItAlreadyPublished(t *testing.T) {
161 // Moving a published version is not an option: two editors may pin it, and
162 // the proxy caches what it fetched.
163 skipInsideARelease(t)
164 if _, err := exec.LookPath("git"); err != nil {
165 t.Skip("git is not available")
166 }
167
168 root := t.TempDir()
169 remote := filepath.Join(root, "remote.git")
170 clone := filepath.Join(root, "clone")
171
172 run(t, root, "git", "init", "--bare", "--initial-branch=main", remote)
173 run(t, root, "git", "clone", remote, clone)
174 copyModuleInto(t, clone)
175 run(t, clone, "git", "config", "user.email", "test@example.test")
176 run(t, clone, "git", "config", "user.name", "Release Test")
177 writeFile(t, filepath.Join(clone, "release.env"), "TAG=v0.0.1-test\nABOUT=\"a throwaway release\"\n")
178
179 if out, err := runAllowingFailure(t, clone, "./01-release.tag.sh"); err != nil {
180 t.Fatalf("the first release failed:\n%s", out)
181 }
182
183 out, err := runAllowingFailure(t, clone, "./01-release.tag.sh")
184
185 if err == nil {
186 t.Fatalf("the script published the same tag twice:\n%s", out)
187 }
188 if !strings.Contains(out, "already exists") {
189 t.Errorf("the refusal does not say the tag is taken:\n%s", out)
190 }
191}
192
193// copyModuleInto copies the module's source into a directory, so the script can
194// be run against a real checkout without touching this one.
195//
196// .git is left out because the target has its own, and *.env because those hold
197// the release token — a test has no use for it, and copying a secret into a
198// temporary directory is how it ends up somewhere nobody looks.
199func copyModuleInto(t *testing.T, target string) {
200 t.Helper()
201
202 entries, err := os.ReadDir(".")
203 if err != nil {
204 t.Fatalf("reading the module: %v", err)
205 }
206 for _, entry := range entries {
207 if entry.Name() == ".git" || strings.HasSuffix(entry.Name(), ".env") {
208 continue
209 }
210 run(t, ".", "cp", "-r", entry.Name(), target)
211 }
212}
213
214// run executes a command in a directory, failing the test if it does not
215// succeed.
216func run(t *testing.T, dir string, name string, args ...string) {
217 t.Helper()
218
219 if out, err := runAllowingFailure(t, dir, name, args...); err != nil {
220 t.Fatalf("%s %v: %v\n%s", name, args, err, out)
221 }
222}
223
224// runAllowingFailure executes a command and returns its combined output along
225// with whether it succeeded.
226func runAllowingFailure(t *testing.T, dir string, name string, args ...string) (string, error) {
227 t.Helper()
228
229 command := exec.Command(name, args...)
230 command.Dir = dir
231 out, err := command.CombinedOutput()
232 return string(out), err
233}
234
235// writeFile creates a file, failing the test if it cannot.
236func writeFile(t *testing.T, path, contents string) {
237 t.Helper()
238
239 if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
240 t.Fatalf("writing %s: %v", path, err)
241 }
242}
243
244// readPublishScript returns the release-page script's text.
245func readPublishScript(t *testing.T) string {
246 t.Helper()
247
248 data, err := os.ReadFile("02-release.publish.sh")
249 if err != nil {
250 t.Fatalf("reading the publish script: %v", err)
251 }
252 return string(data)
253}
254
255func TestThePublishScriptBuildsItsJSONWithJq(t *testing.T) {
256 // Hand-written JSON in a heredoc breaks silently the day ABOUT contains a
257 // quote, a newline or a backtick — and the release notes are exactly where
258 // somebody puts a backtick.
259 script := readPublishScript(t)
260
261 if !strings.Contains(script, "jq -n") {
262 t.Error("the script builds its payload by hand rather than with jq")
263 }
264 // Comment lines are skipped: the script's own comment explains why it
265 // avoids that idiom, and a grep over the whole file matches the
266 // explanation. This test failed on its own prose before it was narrowed.
267 for _, line := range codeLines(script) {
268 if strings.Contains(line, `read -r -d ''`) {
269 t.Errorf("the script uses the read -r -d '' idiom, which always exits non-zero: %s", line)
270 }
271 }
272}
273
274func TestThePublishScriptStopsOnTheFirstFailure(t *testing.T) {
275 // Safe here precisely because it does not use that idiom.
276 if got := readPublishScript(t); !strings.Contains(got, "set -euo pipefail") {
277 t.Error("the script does not stop on failure")
278 }
279}
280
281func TestThePublishScriptTellsUnreachableFromAbsent(t *testing.T) {
282 // git ls-remote exits 2 when a ref is absent and 128 when it cannot reach
283 // the remote. Conflating them refuses a good release from any machine with
284 // no key loaded — which is how this was found.
285 script := readPublishScript(t)
286
287 if !strings.Contains(script, "lookup=$?") {
288 t.Error("the script does not look at ls-remote's exit code")
289 }
290 for _, want := range []string{"is not on origin", "cannot reach origin"} {
291 if !strings.Contains(script, want) {
292 t.Errorf("the script never says %q", want)
293 }
294 }
295}
296
297func TestThePublishScriptTellsTheHTTPStatusesApart(t *testing.T) {
298 // "❌ something went wrong" plus the API's raw JSON leaves the reader to
299 // guess which of four quite different problems this is.
300 script := readPublishScript(t)
301
302 for _, status := range []string{"201", "409", "401", "404"} {
303 if !strings.Contains(script, status) {
304 t.Errorf("the script does not handle HTTP %s", status)
305 }
306 }
307}
308
309func TestThePublishScriptDryRunSendsNothing(t *testing.T) {
310 // The only way to exercise this script here: the real call publishes on
311 // somebody's behalf, which a test may not do.
312 skipInsideARelease(t)
313 if _, err := exec.LookPath("jq"); err != nil {
314 t.Skip("jq is not installed")
315 }
316
317 dir := t.TempDir()
318 copyModuleInto(t, dir)
319 writeFile(t, filepath.Join(dir, "release.env"),
320 "TAG=\"v0.0.1-test\"\nABOUT=\"notes with a \\\" quote\"\nOWNER=\"turbo-editors\"\nREPO=\"turbo-core\"\n")
321 writeFile(t, filepath.Join(dir, "turbo-core.token.env"), "TOKEN=not-a-real-token\n")
322
323 out, err := runAllowingFailure(t, dir, "./02-release.publish.sh", "--dry-run")
324
325 if err != nil {
326 t.Fatalf("the dry run failed:\n%s", out)
327 }
328 if !strings.Contains(out, "nothing was sent") {
329 t.Errorf("the dry run does not say it sent nothing:\n%s", out)
330 }
331 if !strings.Contains(out, "POST https://codeberg.org/api/v1/repos/turbo-editors/turbo-core/releases") {
332 t.Errorf("the dry run does not show the request it would make:\n%s", out)
333 }
334 // The quote in ABOUT must have survived as data rather than breaking the
335 // JSON, which is the whole reason jq is in there.
336 if !strings.Contains(out, `notes with a \" quote`) {
337 t.Errorf("the quote in the notes was not escaped:\n%s", out)
338 }
339}
340
341func TestThePublishScriptRefusesWithoutAToken(t *testing.T) {
342 skipInsideARelease(t)
343
344 dir := t.TempDir()
345 copyModuleInto(t, dir)
346 writeFile(t, filepath.Join(dir, "release.env"),
347 "TAG=\"v0.0.1-test\"\nABOUT=\"notes\"\nOWNER=\"turbo-editors\"\nREPO=\"turbo-core\"\n")
348
349 out, err := runAllowingFailure(t, dir, "./02-release.publish.sh", "--dry-run")
350
351 if err == nil {
352 t.Fatalf("the script ran without a token:\n%s", out)
353 }
354 if !strings.Contains(out, "TOKEN is not set") {
355 t.Errorf("the refusal does not say the token is missing:\n%s", out)
356 }
357}
358
359func TestThePublishScriptLinksToTheDocumentationAtThatTag(t *testing.T) {
360 // A release page is not inside the repository tree, so a relative path from
361 // it 404s — and a link to the branch would rot as the branch moves.
362 script := readPublishScript(t)
363
364 if !strings.Contains(script, "/src/tag/${TAG}/docs/") {
365 t.Error("the release notes do not link to the documentation at the released tag")
366 }
367}
368
369// codeLines returns the lines of a shell script that are not comments.
370func codeLines(script string) []string {
371 var out []string
372 for _, line := range strings.Split(script, "\n") {
373 if trimmed := strings.TrimSpace(line); trimmed != "" && !strings.HasPrefix(trimmed, "#") {
374 out = append(out, line)
375 }
376 }
377 return out
378}