Files
gitea/tests/integration/actions_scoped_workflow_test.go

549 lines
30 KiB
Go

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