Files
minstrel/internal/server/release_gate_test.go
T
bvandeusenandClaude Opus 5.5 b36125fa67
release / govulncheck (push) Failing after 2s
release / go (push) Successful in 1m49s
release / web (push) Successful in 1m8s
release / integration (push) Successful in 4m39s
release / android (push) Successful in 5m45s
release / Build signed APK (releases and dev) (push) Successful in 5m58s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
ci: one workflow graph, so nothing publishes on red; add govulncheck and npm audit (M462 #4984)
test-go, test-web and android were separate workflows on the same push as
release.yml, so the image build could not see their verdict: :dev meant
"it built", never "it passed". All lanes now live in release.yml, and
both publishing jobs (image-release and the new release-assets) need
every lane and require `result == 'success'` from each by name, so a
skipped lane blocks the publish just as a failed one does (rule 177).

- New lanes: govulncheck (in golang:1.26-bookworm, the builder's image,
  so it checks the stdlib that ships) and `npm audit --omit=dev` in web.
- Attaching the APK to a Release moved out of android-release into
  release-assets, behind the gate; the APK still builds in parallel.
- `docker buildx build --pull`, so floating base tags can't serve a
  stale Go patch release from the runner's cache.
- Integration wait uses `pg_isready` via docker exec: the old /dev/tcp
  probe never connects under dash (rule 81) and burned two minutes a run.
- workflow_dispatch input force_red fails the go lane on purpose, to
  watch the gate refuse.
- release_gate_test.go pins the gate: every job must be classified, and
  every publisher must need and require success from every lane.

Lanes have no path filters any more; a web-only push runs the Go suite
too, because "not run" must never read as "passed".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:51:37 -04:00

121 lines
3.9 KiB
Go

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)
}
}
}
}