Some checks failed
check / check (push) Has been cancelled
Gitea cancels an in-flight run when a newer commit lands on the same branch and records the cancellation as `failure` / "Has been cancelled". The workflow rewrote that to `skipped`, but Gitea's Combine() folds `skipped` into `success`, so the combined-status API returned green for a commit nothing had ever tested. Rewrite it to `failure` / "Superseded by a newer commit; never tested" instead: red-but-honest, and never `pending`, which would block the commit forever. Re-running the superseded commit would have been better still, but is not reachable on this Gitea (1.25.4): its API exposes no rerun endpoint, workflow dispatch takes a ref rather than a SHA, and every replay would be a full uncached build with no bound on how many pile up behind a burst of merges. The step also stops hardcoding its status context: the logic moves into script/ci-mark-superseded, which derives the context from the workflow name, job id and event, and fails loudly when no status on the commit being built carries that context, so renaming the workflow or the job cannot silently disable the rewrite. The derivation is not byte-exact with Gitea's own rule -- Gitea uses the job's display `name:` where the runner exports the job id -- so adding a `name:` to the job turns every push red rather than quietly doing nothing; the script header says so, because that loud failure is the point. That is item 2 of #147; item 1 there is untouched. Nothing about the walk may fail quietly, since the script exists to stop CI lying quietly. An ANCESTOR_LIMIT that is set but not a positive integer aborts instead of passing an unusable value to git and discarding the error; the root-commit case is detected explicitly so every other rev-list failure aborts too; and per-ancestor status reads carry the same `--retry 3 --max-time 30` as the head-commit read and abort on failure rather than losing curl's exit status through a pipe. Tests drive the script against a fake Gitea covering the cancelled, laundered-skipped, genuinely-failed, passing and renamed cases, an unparseable ANCESTOR_LIMIT and an ancestor whose status read answers HTTP 500, so jq joins the builder image to run them.
163 lines
3.6 KiB
Go
163 lines
3.6 KiB
Go
package ciscript_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
// commitStatus is the part of an entry in Gitea's combined-status
|
|
// response that script/ci-mark-superseded reads.
|
|
type commitStatus struct {
|
|
Context string `json:"context"`
|
|
Status string `json:"status"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
// postedStatus is the part of a create-status request body the script
|
|
// writes.
|
|
type postedStatus struct {
|
|
Context string `json:"context"`
|
|
State string `json:"state"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
// fakeGitea serves the two endpoints the script talks to. Like Gitea,
|
|
// the newest status for a context replaces the previous one, so a
|
|
// second run of the script sees what the first one wrote.
|
|
type fakeGitea struct {
|
|
mu sync.Mutex
|
|
statuses map[string][]commitStatus
|
|
posted map[string][]postedStatus
|
|
// failRead is a commit whose combined-status read answers HTTP
|
|
// 500, standing in for a status API that is down.
|
|
failRead string
|
|
}
|
|
|
|
// newFakeGitea returns the fake and the base URL to hand the script as
|
|
// GITHUB_API_URL.
|
|
func newFakeGitea(t *testing.T) (*fakeGitea, string) {
|
|
t.Helper()
|
|
|
|
fake := &fakeGitea{
|
|
mu: sync.Mutex{},
|
|
statuses: map[string][]commitStatus{},
|
|
posted: map[string][]postedStatus{},
|
|
failRead: "",
|
|
}
|
|
|
|
srv := httptest.NewServer(fake.routes())
|
|
t.Cleanup(srv.Close)
|
|
|
|
return fake, srv.URL
|
|
}
|
|
|
|
func (f *fakeGitea) routes() http.Handler {
|
|
mux := http.NewServeMux()
|
|
|
|
mux.HandleFunc(
|
|
"GET /repos/{owner}/{repo}/commits/{sha}/status",
|
|
f.handleCombined,
|
|
)
|
|
mux.HandleFunc(
|
|
"POST /repos/{owner}/{repo}/statuses/{sha}",
|
|
f.handleCreate,
|
|
)
|
|
|
|
return mux
|
|
}
|
|
|
|
func (f *fakeGitea) handleCombined(
|
|
w http.ResponseWriter, r *http.Request,
|
|
) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
|
|
sha := r.PathValue("sha")
|
|
if f.failRead != "" && f.failRead == sha {
|
|
http.Error(w, "boom", http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
body := struct {
|
|
Statuses []commitStatus `json:"statuses"`
|
|
}{Statuses: f.statuses[sha]}
|
|
|
|
payload, err := json.Marshal(body)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
_, _ = w.Write(payload)
|
|
}
|
|
|
|
func (f *fakeGitea) handleCreate(w http.ResponseWriter, r *http.Request) {
|
|
var got postedStatus
|
|
|
|
err := json.NewDecoder(r.Body).Decode(&got)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
sha := r.PathValue("sha")
|
|
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
|
|
f.posted[sha] = append(f.posted[sha], got)
|
|
f.replaceLocked(sha, commitStatus{
|
|
Context: got.Context,
|
|
Status: got.State,
|
|
Description: got.Description,
|
|
})
|
|
|
|
w.WriteHeader(http.StatusCreated)
|
|
}
|
|
|
|
// failStatusRead makes the combined-status read for one commit answer
|
|
// HTTP 500.
|
|
func (f *fakeGitea) failStatusRead(sha string) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
|
|
f.failRead = sha
|
|
}
|
|
|
|
// setStatus gives a commit its latest status for a context.
|
|
func (f *fakeGitea) setStatus(sha string, status commitStatus) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
|
|
f.replaceLocked(sha, status)
|
|
}
|
|
|
|
// postedFor returns the statuses the script created for a commit.
|
|
func (f *fakeGitea) postedFor(sha string) []postedStatus {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
|
|
return append([]postedStatus(nil), f.posted[sha]...)
|
|
}
|
|
|
|
// replaceLocked requires f.mu.
|
|
func (f *fakeGitea) replaceLocked(sha string, status commitStatus) {
|
|
for i, existing := range f.statuses[sha] {
|
|
if existing.Context == status.Context {
|
|
f.statuses[sha][i] = status
|
|
|
|
return
|
|
}
|
|
}
|
|
|
|
f.statuses[sha] = append(f.statuses[sha], status)
|
|
}
|