package ciscript_test import ( "maps" "os" "os/exec" "path/filepath" "slices" "strings" "testing" "github.com/stretchr/testify/require" "gopkg.in/yaml.v3" ) const ( // supersededDesc is the description script/ci-mark-superseded // writes, and the one an earlier revision of it wrote alongside a // `skipped` state. supersededDesc = "Superseded by a newer commit; never tested" // liveContext is the commit-status context Gitea uses for this // repository's runs, as seen in its API. The script derives it from // the workflow and job names rather than hardcoding it; the // derivation is checked against this value below. liveContext = "check / check (push)" scriptPath = "../../script/ci-mark-superseded" workflow = "../../.gitea/workflows/check.yml" // failure is the only state that neither folds into a combined // `success` (as `skipped` does) nor blocks the commit forever (as // `pending` does). failure = "failure" ) // repo is a throwaway git history: parent is the commit a run would be // cancelled on, head the commit that superseded it. type repo struct { dir string head string parent string } // scriptEnv is the run identity the Gitea runner exports and the script // builds its context string from. type scriptEnv struct { workflow string job string event string } func defaultEnv() scriptEnv { return scriptEnv{workflow: "check", job: "check", event: "push"} } func cancelled() commitStatus { return commitStatus{ Context: liveContext, Status: failure, Description: "Has been cancelled", } } func running() commitStatus { return commitStatus{ Context: liveContext, Status: "pending", Description: "Has started running", } } func TestMarkSuperseded(t *testing.T) { t.Parallel() cases := map[string]struct { parent commitStatus wantMark bool }{ "a cancelled run is marked": { parent: cancelled(), wantMark: true, }, "a laundered skipped status is marked": { parent: commitStatus{ Context: liveContext, Status: "skipped", Description: supersededDesc, }, wantMark: true, }, "a genuine failure is left alone": { parent: commitStatus{ Context: liveContext, Status: failure, Description: "Failing after 3m1s", }, wantMark: false, }, "a passing run is left alone": { parent: commitStatus{ Context: liveContext, Status: "success", Description: "Successful in 2m52s", }, wantMark: false, }, "another context is left alone": { parent: commitStatus{ Context: "other / other (push)", Status: failure, Description: "Has been cancelled", }, wantMark: false, }, } for name, tc := range cases { t.Run(name, func(t *testing.T) { t.Parallel() requireTools(t) history := newRepo(t) fake, api := newFakeGitea(t) fake.setStatus(history.head, running()) fake.setStatus(history.parent, tc.parent) out, err := runScript(t, history, api, defaultEnv()) require.NoError(t, err, out) posted := fake.postedFor(history.parent) if !tc.wantMark { require.Empty(t, posted) return } require.Equal(t, []postedStatus{{ Context: liveContext, // Not `skipped`: Gitea's combined status folds // that into `success`, which is what made a // never-tested commit read green. State: failure, Description: supersededDesc, }}, posted) }) } } // A second run must not rewrite what the first one wrote, or every // later push would post a duplicate status. func TestMarkSupersededIsIdempotent(t *testing.T) { t.Parallel() requireTools(t) history := newRepo(t) fake, api := newFakeGitea(t) fake.setStatus(history.head, running()) fake.setStatus(history.parent, cancelled()) for range 2 { out, err := runScript(t, history, api, defaultEnv()) require.NoError(t, err, out) } require.Len(t, fake.postedFor(history.parent), 1) } // Renaming the workflow or the job changes the context string Gitea // uses. The script must say so instead of quietly matching nothing. func TestMarkSupersededRejectsAnUnknownContext(t *testing.T) { t.Parallel() requireTools(t) history := newRepo(t) fake, api := newFakeGitea(t) fake.setStatus(history.head, running()) fake.setStatus(history.parent, cancelled()) env := defaultEnv() env.job = "renamed" out, err := runScript(t, history, api, env) require.Error(t, err) require.Contains(t, out, "renamed") require.Contains(t, out, liveContext) require.Empty(t, fake.postedFor(history.parent)) } // The derived context must equal the one Gitea actually uses, which is // built from the same workflow and job names. func TestDerivedContextMatchesGitea(t *testing.T) { t.Parallel() requireTools(t) name, job := workflowIdentity(t) history := newRepo(t) fake, api := newFakeGitea(t) fake.setStatus(history.head, running()) fake.setStatus(history.parent, cancelled()) out, err := runScript(t, history, api, scriptEnv{ workflow: name, job: job, event: "push", }) require.NoError(t, err, out) posted := fake.postedFor(history.parent) require.Len(t, posted, 1) require.Equal(t, liveContext, posted[0].Context) } // workflowIdentity reads the workflow name and its single job id out of // the checked-in workflow file. func workflowIdentity(t *testing.T) (string, string) { t.Helper() raw, err := os.ReadFile(workflow) require.NoError(t, err) var parsed struct { Name string `yaml:"name"` Jobs map[string]any `yaml:"jobs"` } require.NoError(t, yaml.Unmarshal(raw, &parsed)) jobs := slices.Collect(maps.Keys(parsed.Jobs)) require.Len(t, jobs, 1) return parsed.Name, jobs[0] } func runScript( t *testing.T, history repo, api string, env scriptEnv, ) (string, error) { t.Helper() script, err := filepath.Abs(scriptPath) require.NoError(t, err) //nolint:gosec // fixed argv, repo-local script under test cmd := exec.CommandContext(t.Context(), "sh", script) cmd.Dir = history.dir cmd.Env = append(os.Environ(), "GITHUB_API_URL="+api, "GITHUB_REPOSITORY=sneak/webhooker", "GITHUB_SHA="+history.head, "GITHUB_WORKFLOW="+env.workflow, "GITHUB_JOB="+env.job, "GITHUB_EVENT_NAME="+env.event, "GITEA_TOKEN=test-token", ) out, err := cmd.CombinedOutput() return string(out), err } func newRepo(t *testing.T) repo { t.Helper() dir := t.TempDir() git := func(args ...string) string { //nolint:gosec // fixed argv, arguments are test constants cmd := exec.CommandContext(t.Context(), "git", args...) cmd.Dir = dir out, err := cmd.CombinedOutput() require.NoError(t, err, string(out)) return strings.TrimSpace(string(out)) } commit := func(message string) string { git( "-c", "user.email=ci@example.invalid", "-c", "user.name=ci", "-c", "commit.gpgsign=false", "commit", "-q", "--allow-empty", "-m", message, ) return git("rev-parse", "HEAD") } git("init", "-q", "-b", "main") parent := commit("parent") head := commit("head") return repo{dir: dir, head: head, parent: parent} } func requireTools(t *testing.T) { t.Helper() for _, tool := range []string{"sh", "git", "curl", "jq"} { _, err := exec.LookPath(tool) if err != nil { t.Skipf("%s is not installed: %v", tool, err) } } }