package server import ( "os" "path/filepath" "slices" "strings" "testing" "gopkg.in/yaml.v3" ) // The publish gate (rule 177, M462 #4984), pinned. release.yml holds every // verifying lane and every publishing job in one graph so that a publish can // depend on the lanes; these tests keep that dependency from eroding. // // The failure each one guards against is silent. A lane added without being // named in a publisher's needs runs, goes red, and :dev publishes anyway. A // condition relaxed to `!failure()` lets a lane that never started count as // a pass. Neither shows up as a broken build — only as a gate that no longer // gates. // gateLanes are the verifying jobs. Every publisher must need each of them and // require its success by name. var gateLanes = []string{"go", "integration", "web", "android", "govulncheck"} // gatePublishers push something other people can pull: image tags, and the // APK + sidecar attached to a Release. var gatePublishers = []string{"image-release", "release-assets"} // gateOthers are the remaining jobs and why they sit outside the gate: // android-release only builds a workflow artifact (nothing outside the run can // pull it), and verify-release reports on a finished release. var gateOthers = []string{"android-release", "verify-release"} type workflowJob struct { Needs any `yaml:"needs"` If string `yaml:"if"` } func releaseJobs(t *testing.T) map[string]workflowJob { t.Helper() body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml")) if err != nil { t.Fatal(err) } var wf struct { Jobs map[string]workflowJob `yaml:"jobs"` } if err := yaml.Unmarshal(body, &wf); err != nil { t.Fatalf("parse release.yml: %v", err) } if len(wf.Jobs) == 0 { t.Fatal("release.yml has no jobs") } return wf.Jobs } func jobNeeds(j workflowJob) []string { switch v := j.Needs.(type) { case string: return []string{v} case []any: out := make([]string, 0, len(v)) for _, n := range v { if s, ok := n.(string); ok { out = append(out, s) } } return out } return nil } // A job nobody has classified is the case that matters: a new lane that was // never added to the publishers' needs. func TestReleaseGate_EveryJobIsClassified(t *testing.T) { for name := range releaseJobs(t) { if slices.Contains(gateLanes, name) || slices.Contains(gatePublishers, name) || slices.Contains(gateOthers, name) { continue } t.Errorf("release.yml job %q is not classified. If it verifies, add it to gateLanes and to every publisher's needs and if; "+ "if it publishes, add it to gatePublishers and gate it; otherwise add it to gateOthers with the reason", name) } for _, want := range append(append(slices.Clone(gateLanes), gatePublishers...), gateOthers...) { if _, ok := releaseJobs(t)[want]; !ok { t.Errorf("release.yml has no %q job, which this test expects", want) } } } func TestReleaseGate_PublishersNeedEveryLaneToSucceed(t *testing.T) { jobs := releaseJobs(t) for _, pub := range gatePublishers { job, ok := jobs[pub] if !ok { t.Errorf("no %q job", pub) continue } needs := jobNeeds(job) cond := strings.Join(strings.Fields(job.If), " ") for _, lane := range gateLanes { if !slices.Contains(needs, lane) { t.Errorf("%s does not need %q: it can publish without waiting for that lane", pub, lane) } if !strings.Contains(cond, "needs."+lane+".result == 'success'") { t.Errorf("%s's if does not require needs.%s.result == 'success': a skipped or failed %s would not stop it\n if: %s", pub, lane, lane, cond) } } // always() and failure() both make a job run past a red dependency; // !cancelled() is fine only because the per-lane success checks above // carry the gate. for _, forbidden := range []string{"always()", "failure()"} { if strings.Contains(cond, forbidden) { t.Errorf("%s's if uses %s, which runs it past a failed lane\n if: %s", pub, forbidden, cond) } } } }