ci: one workflow graph, so nothing publishes on red; add govulncheck and npm audit (M462 #4984)
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
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
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>
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user