// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package integration import ( "encoding/base64" "fmt" "net/http" "net/url" "strconv" "testing" "time" runnerv1 "gitea.dev/actions-proto-go/runner/v1" actions_model "gitea.dev/models/actions" auth_model "gitea.dev/models/auth" repo_model "gitea.dev/models/repo" unit_model "gitea.dev/models/unit" "gitea.dev/models/unittest" user_model "gitea.dev/models/user" "gitea.dev/modules/commitstatus" "gitea.dev/modules/queue" api "gitea.dev/modules/structs" "gitea.dev/services/forms" repo_service "gitea.dev/services/repository" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const scopedPushWorkflow = `name: Scoped Push on: push jobs: scoped-job: runs-on: ubuntu-latest steps: - run: echo scoped ` const scopedPRWorkflow = `name: Scoped PR on: pull_request jobs: scoped-pr-job: runs-on: ubuntu-latest steps: - run: echo scoped-pr ` func TestActionsScopedWorkflows(t *testing.T) { onGiteaRun(t, func(t *testing.T, u *url.URL) { user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) user2Session := loginUser(t, user2.Name) user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) // createTestRepo creates an Actions-enabled repo owned by user2, used as a scoped-workflow source or consumer. createTestRepo := func(t *testing.T, name string, private bool) *repo_model.Repository { apiRepo := createActionsTestRepo(t, user2Token, name, private) return unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID}) } // registerUserScopedSource registers `source` as a user-level scoped-workflow source for user2 and marks `required` entry names registerUserScopedSource := func(t *testing.T, source *repo_model.Repository, required ...string) { addReq := NewRequestWithValues(t, "POST", "/user/settings/actions/scoped-workflows/add", map[string]string{"repo_name": source.Name}) user2Session.MakeRequest(t, addReq, http.StatusOK) t.Cleanup(func() { removeReq := NewRequestWithValues(t, "POST", "/user/settings/actions/scoped-workflows/remove", map[string]string{"repo_id": strconv.FormatInt(source.ID, 10)}) user2Session.MakeRequest(t, removeReq, http.StatusOK) }) if len(required) > 0 { vals := url.Values{"repo_id": {strconv.FormatInt(source.ID, 10)}, "workflow_ids": required, "required_workflow_ids": required} for _, id := range required { // a pattern that matches the source's scoped check regardless of its `name:` (each test source has one workflow) vals.Set("required_patterns["+id+"]", source.FullName()+": * / *") } reqReq := NewRequestWithURLValues(t, "POST", "/user/settings/actions/scoped-workflows/required", vals) user2Session.MakeRequest(t, reqReq, http.StatusOK) } } t.Run("Trigger and run creation", func(t *testing.T) { // Registered at INSTANCE level via the admin route (owner/name resolution + OwnerID=0 storage); // the trigger->execute->rerun below proves an instance-level source drives a consumer run end-to-end and that a rerun stays scoped. adminSession := loginUser(t, "user1") source := createTestRepo(t, "sw-trigger-source", false) // commit the scoped workflow BEFORE registering so the source's own push does not self-trigger. createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/push.yaml", scopedPushWorkflow) adminAdd := NewRequestWithValues(t, "POST", "/-/admin/actions/scoped-workflows/add", map[string]string{"repo_name": source.FullName()}) adminSession.MakeRequest(t, adminAdd, http.StatusOK) t.Cleanup(func() { rm := NewRequestWithValues(t, "POST", "/-/admin/actions/scoped-workflows/remove", map[string]string{"repo_id": strconv.FormatInt(source.ID, 10)}) adminSession.MakeRequest(t, rm, http.StatusOK) }) unittest.AssertExistsAndLoadBean(t, &actions_model.ActionScopedWorkflowSource{OwnerID: 0, SourceRepoID: source.ID}) consumer := createTestRepo(t, "sw-trigger-consumer", false) runner := newMockRunner() runner.registerAsRepoRunner(t, consumer.OwnerName, consumer.Name, "sw-trigger-runner", []string{"ubuntu-latest"}, false) createRepoWorkflowFile(t, user2, user2Token, consumer, "marker.txt", "trigger") run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true}) assert.Equal(t, source.ID, run.WorkflowRepoID, "content source is the source repo") assert.Equal(t, "push.yaml", run.WorkflowID) assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID}), "only the scoped run, no repo-level run") job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID}) // runs in the CONSUMER's context and reaches a terminal state task := runner.fetchTask(t) _, taskJob, taskRun := getTaskAndJobAndRunByTaskID(t, task.Id) assert.Equal(t, consumer.ID, taskJob.RepoID) assert.Equal(t, run.ID, taskRun.ID) runner.execTask(t, task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS}) run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID}) assert.Equal(t, actions_model.StatusSuccess, run.Status) // rerun: the rerun is still a scoped run and again executes in the consumer's context rerunReq := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", consumer.OwnerName, consumer.Name, run.ID, job.ID)) user2Session.MakeRequest(t, rerunReq, http.StatusOK) unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunAttempt{RunID: run.ID, Attempt: 2}) task2 := runner.fetchTask(t) _, taskJob2, taskRun2 := getTaskAndJobAndRunByTaskID(t, task2.Id) assert.Equal(t, consumer.ID, taskJob2.RepoID) assert.True(t, taskRun2.IsScopedRun, "the rerun is still a scoped run") runner.execTask(t, task2, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS}) run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID}) assert.Equal(t, actions_model.StatusSuccess, run.Status) }) t.Run("Opt-out", func(t *testing.T) { // opt-out: a consumer can disable a non-required scoped workflow, but a required one cannot be disabled. source := createTestRepo(t, "sw-optout-source", false) createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/push.yaml", scopedPushWorkflow) registerUserScopedSource(t, source) // non-required // non-required: the kebab "Disable Workflow" item is an active link; disabling then makes a push produce no scoped run. consumer := createTestRepo(t, "sw-optout-consumer", false) optBody := user2Session.MakeRequest(t, NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions?workflow=push.yaml&scoped_workflow_source_repo_id=%d", consumer.OwnerName, consumer.Name, source.ID)), http.StatusOK).Body.String() assert.Contains(t, optBody, "Disable Workflow") assert.Contains(t, optBody, "disable?workflow=push.yaml", "non-required scoped workflow: Disable Workflow is a clickable link") disableReq := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/disable?workflow=push.yaml&scoped_workflow_source_repo_id=%d", consumer.OwnerName, consumer.Name, source.ID)) user2Session.MakeRequest(t, disableReq, http.StatusOK) createRepoWorkflowFile(t, user2, user2Token, consumer, "marker.txt", "trigger") assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true}), "opted-out scoped workflow must not run") // required: the kebab "Disable Workflow" item is rendered disabled (no link), and the disable endpoint rejects it. reqSource := createTestRepo(t, "sw-optout-req-source", false) createRepoWorkflowFile(t, user2, user2Token, reqSource, ".gitea/scoped_workflows/push.yaml", scopedPushWorkflow) registerUserScopedSource(t, reqSource, "push.yaml") // required reqConsumer := createTestRepo(t, "sw-optout-req-consumer", false) requiredBody := user2Session.MakeRequest(t, NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions?workflow=push.yaml&scoped_workflow_source_repo_id=%d", reqConsumer.OwnerName, reqConsumer.Name, reqSource.ID)), http.StatusOK).Body.String() assert.Contains(t, requiredBody, "Disable Workflow") assert.Contains(t, requiredBody, `class="item disabled"`, "required scoped workflow: Disable Workflow is rendered disabled") assert.NotContains(t, requiredBody, "disable?workflow=push.yaml", "required scoped workflow: Disable Workflow has no clickable link") rejectReq := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/disable?workflow=push.yaml&scoped_workflow_source_repo_id=%d", reqConsumer.OwnerName, reqConsumer.Name, reqSource.ID)) user2Session.MakeRequest(t, rejectReq, http.StatusBadRequest) // scoped_required_cannot_disable }) t.Run("Local uses resolves to source", func(t *testing.T) { // uses: ./ in a scoped workflow resolves against the SOURCE repo, not the consumer. // Here the reusable lib lives in the SCOPED workflow dir (allowed by ResolveUses), exercising that path end-to-end. source := createTestRepo(t, "sw-uses-source", false) createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/lib.yaml", `name: Lib on: workflow_call: jobs: lib_job_source: runs-on: ubuntu-latest steps: - run: echo from-source `) createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/caller.yaml", `name: Caller on: push jobs: caller_job: uses: ./.gitea/scoped_workflows/lib.yaml `) consumer := createTestRepo(t, "sw-uses-consumer", false) // a DIFFERENT lib at the same path in the consumer; if uses:./ mis-resolved we would see this job createRepoWorkflowFile(t, user2, user2Token, consumer, ".gitea/scoped_workflows/lib.yaml", `name: Lib on: workflow_call: jobs: lib_job_consumer: runs-on: ubuntu-latest steps: - run: echo from-consumer `) // register only AFTER both repos' scoped files exist, so the setup pushes do not trigger registerUserScopedSource(t, source) createRepoWorkflowFile(t, user2, user2Token, consumer, "marker.txt", "trigger") run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true, WorkflowID: "caller.yaml"}) callerJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "caller_job"}) assert.True(t, callerJob.IsReusableCaller) assert.True(t, callerJob.IsExpanded) assert.Equal(t, source.ID, callerJob.WorkflowSourceRepoID, "top-level caller's content source is the source repo") // the expanded child comes from the SOURCE's lib.yaml, not the consumer's same-path file unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "lib_job_source", ParentJobID: callerJob.ID}) unittest.AssertNotExistsBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "lib_job_consumer"}) }) t.Run("Workflow dispatch", func(t *testing.T) { // a scoped on:workflow_dispatch workflow can be triggered manually from the consumer, via both the web form and the API source := createTestRepo(t, "sw-dispatch-source", false) createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/dispatch.yaml", `name: Scoped Dispatch on: workflow_dispatch jobs: dispatch-job: runs-on: ubuntu-latest steps: - run: echo dispatch `) registerUserScopedSource(t, source) consumer := createTestRepo(t, "sw-dispatch-consumer", false) // web form: /run?...&scoped_workflow_source_repo_id= webReq := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/run?workflow=dispatch.yaml&scoped_workflow_source_repo_id=%d&ref=refs/heads/%s", consumer.OwnerName, consumer.Name, source.ID, consumer.DefaultBranch)) user2Session.MakeRequest(t, webReq, http.StatusSeeOther) run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true, WorkflowID: "dispatch.yaml"}) assert.Equal(t, source.ID, run.WorkflowRepoID, "content source is the source repo") assert.NotEmpty(t, run.WorkflowCommitSHA, "scoped dispatch records the source default-branch commit") assert.Contains(t, run.Ref, consumer.DefaultBranch, "dispatch runs on the chosen consumer ref") // API: /actions/workflows/dispatch.yaml/dispatches?scoped_workflow_source_repo_id= apiReq := NewRequestWithURLValues(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/dispatch.yaml/dispatches?scoped_workflow_source_repo_id=%d", consumer.OwnerName, consumer.Name, source.ID), url.Values{"ref": {consumer.DefaultBranch}}).AddTokenAuth(user2Token) MakeRequest(t, apiReq, http.StatusNoContent) assert.Equal(t, 2, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true, WorkflowID: "dispatch.yaml", Event: "workflow_dispatch"}), "both the web form and the API created a scoped dispatch run") }) t.Run("Required scoped check gates the PR merge", func(t *testing.T) { // A required scoped workflow's check gates PR merges on a protected branch and cannot be bypassed, // whether or not the branch enables its own status check. The scoped check is added to the required set dynamically. source := createTestRepo(t, "sw-gate-source", false) createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/pr.yaml", scopedPRWorkflow) registerUserScopedSource(t, source, "pr.yaml") // required // protectAndOpenPR protects consumer's default branch and opens a PR on `branch`, returning a merge-request builder. // When statusCheckEnabled it also configures "ci/manual" as the only CONFIGURED required context and satisfies it, // so the scoped check is the only thing that can gate the merge; otherwise the rule's own status check stays off. protectAndOpenPR := func(t *testing.T, consumer *repo_model.Repository, branch string, statusCheckEnabled bool) func() *RequestWrapper { pbValues := map[string]string{ "rule_name": consumer.DefaultBranch, "enable_push": "true", "block_admin_merge_override": "true", // otherwise the repo owner bypasses the status check } if statusCheckEnabled { pbValues["enable_status_check"] = "true" pbValues["status_check_contexts"] = "ci/manual" } user2Session.MakeRequest(t, NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/branches/edit", consumer.OwnerName, consumer.Name), pbValues), http.StatusSeeOther) prFile := &api.CreateFileOptions{ FileOptions: api.FileOptions{ BranchName: consumer.DefaultBranch, NewBranchName: branch, Message: "pr change", Author: api.Identity{Name: user2.Name, Email: user2.Email}, Committer: api.Identity{Name: user2.Name, Email: user2.Email}, Dates: api.CommitDateOptions{Author: time.Now(), Committer: time.Now()}, }, ContentBase64: base64.StdEncoding.EncodeToString([]byte("pr change")), } createWorkflowFile(t, user2Token, consumer.OwnerName, consumer.Name, "pr-change.txt", prFile) apiCtx := NewAPITestContext(t, user2.Name, consumer.Name, auth_model.AccessTokenScopeWriteRepository) pr, err := doAPICreatePullRequest(apiCtx, consumer.OwnerName, consumer.Name, consumer.DefaultBranch, branch)(t) require.NoError(t, err) if statusCheckEnabled { // satisfy the configured "ci/manual" check so only the scoped check can gate the merge manualStatus := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/statuses/%s", consumer.OwnerName, consumer.Name, pr.Head.Sha), api.CreateStatusOption{State: commitstatus.CommitStatusSuccess, Context: "ci/manual", TargetURL: "http://test.ci/"}).AddTokenAuth(user2Token) user2Session.MakeRequest(t, manualStatus, http.StatusCreated) } return func() *RequestWrapper { return NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", consumer.OwnerName, consumer.Name, pr.Index), &forms.MergePullRequestForm{Do: string(repo_model.MergeStyleMerge), MergeMessageField: "merge"}).AddTokenAuth(user2Token) } } t.Run("pending blocks, success allows", func(t *testing.T) { consumer := createTestRepo(t, "sw-gate-consumer", false) runner := newMockRunner() runner.registerAsRepoRunner(t, consumer.OwnerName, consumer.Name, "sw-gate-runner", []string{"ubuntu-latest"}, false) mergeReq := protectAndOpenPR(t, consumer, "gate-pr", true) run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true}) assert.Equal(t, source.ID, run.WorkflowRepoID) // the pending required scoped check blocks the merge assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second)) user2Session.MakeRequest(t, mergeReq(), http.StatusMethodNotAllowed) // the required scoped run succeeds -> merge allowed task := runner.fetchTask(t) runner.execTask(t, task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS}) assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second)) user2Session.MakeRequest(t, mergeReq(), http.StatusOK) }) t.Run("Actions disabled blocks merge (no bypass)", func(t *testing.T) { // must-present: disabling the consumer's Actions unit so the required scoped workflow cannot run. // Must BLOCK the merge (the required check is absent), not bypass it. consumer := createTestRepo(t, "sw-noact-consumer", false) require.NoError(t, repo_service.UpdateRepositoryUnits(t.Context(), consumer, nil, []unit_model.Type{unit_model.TypeActions})) mergeReq := protectAndOpenPR(t, consumer, "noact-pr", true) assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true}), "Actions disabled, so no scoped run is created") // the required scoped check never posted a status -> must-present blocks the merge (no bypass) assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second)) user2Session.MakeRequest(t, mergeReq(), http.StatusMethodNotAllowed) }) t.Run("status check disabled: the scoped check still gates", func(t *testing.T) { // the scoped check gates the merge even when the branch's OWN status check is off consumer := createTestRepo(t, "sw-nocheck-consumer", false) runner := newMockRunner() runner.registerAsRepoRunner(t, consumer.OwnerName, consumer.Name, "sw-nocheck-runner", []string{"ubuntu-latest"}, false) mergeReq := protectAndOpenPR(t, consumer, "nocheck-pr", false) unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true}) // pending scoped check blocks the merge despite the branch's own status check being off assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second)) user2Session.MakeRequest(t, mergeReq(), http.StatusMethodNotAllowed) // the required scoped run succeeds -> merge allowed task := runner.fetchTask(t) runner.execTask(t, task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS}) assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second)) user2Session.MakeRequest(t, mergeReq(), http.StatusOK) }) }) t.Run("Filtered required scoped check passes as skipped and allows merge", func(t *testing.T) { // A required scoped workflow excluded by a paths filter posts a skipped (success) commit status, // so the required check is satisfied and the PR can merge. const scopedFilteredPRWorkflow = `name: Scoped Filtered PR on: pull_request: paths: - src/** jobs: scoped-filtered-job: runs-on: ubuntu-latest steps: - run: echo scoped-filtered ` source := createTestRepo(t, "sw-filtered-source", false) createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/pr.yaml", scopedFilteredPRWorkflow) registerUserScopedSource(t, source, "pr.yaml") // required consumer := createTestRepo(t, "sw-filtered-consumer", false) // Protect the default branch (its own status check stays off, so only the required scoped check gates the merge). user2Session.MakeRequest(t, NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/branches/edit", consumer.OwnerName, consumer.Name), map[string]string{ "rule_name": consumer.DefaultBranch, "enable_push": "true", "block_admin_merge_override": "true", // otherwise the repo owner bypasses the status check }), http.StatusSeeOther) // Open a PR that changes a file NOT matching the workflow's `paths: [src/**]`, so it is filtered out. prFile := &api.CreateFileOptions{ FileOptions: api.FileOptions{ BranchName: consumer.DefaultBranch, NewBranchName: "filtered-pr", Message: "pr change", Author: api.Identity{Name: user2.Name, Email: user2.Email}, Committer: api.Identity{Name: user2.Name, Email: user2.Email}, Dates: api.CommitDateOptions{Author: time.Now(), Committer: time.Now()}, }, ContentBase64: base64.StdEncoding.EncodeToString([]byte("pr change")), } createWorkflowFile(t, user2Token, consumer.OwnerName, consumer.Name, "docs.txt", prFile) apiCtx := NewAPITestContext(t, user2.Name, consumer.Name, auth_model.AccessTokenScopeWriteRepository) pr, err := doAPICreatePullRequest(apiCtx, consumer.OwnerName, consumer.Name, consumer.DefaultBranch, "filtered-pr")(t) require.NoError(t, err) // Filtered: no scoped run is created, but a skipped commit status is posted on the PR head. assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true}), "filtered scoped workflow creates no run") assertSkippedCommitStatusExists(t, consumer.ID, pr.Head.Sha, "pull_request") // The skipped (success) status satisfies the required scoped check (prefixed with the source repo), so the merge is allowed. assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second)) mergeReq := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", consumer.OwnerName, consumer.Name, pr.Index), &forms.MergePullRequestForm{Do: string(repo_model.MergeStyleMerge), MergeMessageField: "merge"}).AddTokenAuth(user2Token) user2Session.MakeRequest(t, mergeReq, http.StatusOK) }) t.Run("Settings page required patterns", func(t *testing.T) { source := createTestRepo(t, "sw-settings-source", false) createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/push.yaml", scopedPushWorkflow) createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/manual.yaml", `name: Manual on: workflow_dispatch jobs: deploy: runs-on: ubuntu-latest steps: - run: echo `) // workflow_dispatch posts no status -> the settings page must warn instead of listing contexts registerUserScopedSource(t, source) // registered; each phase configures it via the /required endpoint pattern := source.FullName() + ": * / *" setConfigs := func(t *testing.T, vals url.Values) { vals.Set("repo_id", strconv.FormatInt(source.ID, 10)) user2Session.MakeRequest(t, NewRequestWithURLValues(t, "POST", "/user/settings/actions/scoped-workflows/required", vals), http.StatusOK) } loadSource := func(t *testing.T) *actions_model.ActionScopedWorkflowSource { return unittest.AssertExistsAndLoadBean(t, &actions_model.ActionScopedWorkflowSource{OwnerID: user2.ID, SourceRepoID: source.ID}) } settingsBody := func(t *testing.T) string { return user2Session.MakeRequest(t, NewRequest(t, "GET", "/user/settings/actions/scoped-workflows"), http.StatusOK).Body.String() } t.Run("renders the saved pattern and display-name default", func(t *testing.T) { setConfigs(t, url.Values{"workflow_ids": {"push.yaml"}, "required_workflow_ids": {"push.yaml"}, "required_patterns[push.yaml]": {pattern}}) body := settingsBody(t) assert.Contains(t, body, `name="required_patterns[push.yaml]"`, "patterns textarea uses the field name the parser expects") assert.Contains(t, body, pattern, "the saved pattern round-trips into the textarea") // the default prefill must use the workflow display name so it matches the status context the run posts (name: Scoped Push) assert.Contains(t, body, `data-default-pattern="`+source.FullName()+`: Scoped Push / *"`) // the expected-checks preview derives the exact context a run posts (job scoped-job, event push) for live glob matching assert.Contains(t, body, `data-context="`+source.FullName()+`: Scoped Push / scoped-job (push)"`) }) t.Run("live pattern kept as history after un-require", func(t *testing.T) { setConfigs(t, url.Values{"workflow_ids": {"push.yaml"}, "required_workflow_ids": {"push.yaml"}, "required_patterns[push.yaml]": {pattern}}) // un-require: the row still submits workflow_ids + its patterns (the hidden textarea), but not required_workflow_ids setConfigs(t, url.Values{"workflow_ids": {"push.yaml"}, "required_patterns[push.yaml]": {pattern}}) cfg := loadSource(t).WorkflowConfigs["push.yaml"] require.NotNil(t, cfg) assert.False(t, cfg.Required, "no longer required") assert.Equal(t, []string{pattern}, cfg.Patterns, "pattern retained as history") assert.Contains(t, settingsBody(t), pattern, "history pattern still rendered, so re-requiring restores it") }) t.Run("orphan config dropped when un-required", func(t *testing.T) { // An orphan entry (gone.yaml: required for a file that no longer exists in the source) has no history worth keeping: // un-checking Required must drop it entirely, unlike a live un-required workflow. setConfigs(t, url.Values{ "workflow_ids": {"push.yaml", "gone.yaml"}, "required_workflow_ids": {"push.yaml", "gone.yaml"}, "required_patterns[push.yaml]": {pattern}, "required_patterns[gone.yaml]": {pattern}, }) require.True(t, loadSource(t).IsWorkflowRequired("gone.yaml"), "orphan kept while still required") // un-require gone.yaml (its row + patterns are still submitted, as the settings page does); push.yaml stays required setConfigs(t, url.Values{ "workflow_ids": {"push.yaml", "gone.yaml"}, "required_workflow_ids": {"push.yaml"}, "required_patterns[push.yaml]": {pattern}, "required_patterns[gone.yaml]": {pattern}, }) src := loadSource(t) assert.Nil(t, src.WorkflowConfigs["gone.yaml"], "orphan dropped after un-require, not kept as history") assert.True(t, src.IsWorkflowRequired("push.yaml"), "live required workflow kept") }) t.Run("warns when a workflow posts no status checks", func(t *testing.T) { // manual.yaml only runs on workflow_dispatch, which posts no commit status: instead of listing expected checks, // its row shows a warning not to mark it required (must-present would block forever). body := settingsBody(t) assert.Contains(t, body, "posts no status checks", "the no-status-check warning is shown") assert.NotContains(t, body, `data-context="`+source.FullName()+`: Manual /`, "a workflow_dispatch-only workflow must list no expected contexts") }) }) t.Run("Distinct sources same filename", func(t *testing.T) { // two DIFFERENT source repos with the same filename run independently s1 := createTestRepo(t, "sw-multi-s1", false) createRepoWorkflowFile(t, user2, user2Token, s1, ".gitea/scoped_workflows/ci.yaml", scopedPushWorkflow) s2 := createTestRepo(t, "sw-multi-s2", false) createRepoWorkflowFile(t, user2, user2Token, s2, ".gitea/scoped_workflows/ci.yaml", scopedPushWorkflow) registerUserScopedSource(t, s1) registerUserScopedSource(t, s2) consumer := createTestRepo(t, "sw-multi-consumer", false) createRepoWorkflowFile(t, user2, user2Token, consumer, "marker.txt", "trigger") assert.Equal(t, 2, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true}), "same-named ci.yaml from two sources run independently") unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true, WorkflowRepoID: s1.ID}) unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true, WorkflowRepoID: s2.ID}) }) t.Run("Detection cache invalidates on source push", func(t *testing.T) { // The detection parse is cached per (source, default-branch SHA). source := createTestRepo(t, "sw-cache-source", false) created := createWorkflowFile(t, user2Token, source.OwnerName, source.Name, ".gitea/scoped_workflows/ci.yaml", getWorkflowCreateFileOptions(user2, source.DefaultBranch, "create ci", `name: CI on: pull_request jobs: j: runs-on: ubuntu-latest steps: - run: echo a `)) registerUserScopedSource(t, source) consumer := createTestRepo(t, "sw-cache-consumer", false) // warm the cache at the source's current SHA: the source triggers on pull_request, so a consumer push is no match createRepoWorkflowFile(t, user2, user2Token, consumer, "m1.txt", "trigger") assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true}), "source triggers on pull_request, so a consumer push must not create a scoped run") // switch the source's trigger to push on its default branch updateReq := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/contents/.gitea/scoped_workflows/ci.yaml", source.OwnerName, source.Name), &api.UpdateFileOptions{ SHA: created.Content.SHA, FileOptions: api.FileOptions{BranchName: source.DefaultBranch, Message: "switch to push"}, ContentBase64: base64.StdEncoding.EncodeToString([]byte(`name: CI on: push jobs: j: runs-on: ubuntu-latest steps: - run: echo a `)), }).AddTokenAuth(user2Token) MakeRequest(t, updateReq, http.StatusOK) // the next consumer push must re-detect against the new SHA (on: push) and create the scoped run createRepoWorkflowFile(t, user2, user2Token, consumer, "m2.txt", "trigger") assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true, Event: "push"}), "after the source switches to on: push, the next consumer push creates a scoped run") }) t.Run("Deletion cleans up source registration", func(t *testing.T) { source := createTestRepo(t, "sw-delete-source", false) addReq := NewRequestWithValues(t, "POST", "/user/settings/actions/scoped-workflows/add", map[string]string{"repo_name": source.Name}) user2Session.MakeRequest(t, addReq, http.StatusOK) unittest.AssertExistsAndLoadBean(t, &actions_model.ActionScopedWorkflowSource{OwnerID: user2.ID, SourceRepoID: source.ID}) delReq := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s", source.OwnerName, source.Name)).AddTokenAuth(user2Token) MakeRequest(t, delReq, http.StatusNoContent) unittest.AssertNotExistsBean(t, &actions_model.ActionScopedWorkflowSource{SourceRepoID: source.ID}) }) }) }