mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-10 05:24:18 +09:00
Pairs with https://gitea.com/gitea/runner/pulls/1143. Gitea depends on `gitea.com/gitea/runner` for exactly two packages: `act/model` and `act/exprparser`, the workflow model and the expression evaluator it needs to parse workflows and to build the task payload the runner consumes. Pulling the whole runner module in for that is heavy and puts shared code in the repository of one of the two consumers. Both packages now live in `gitea.dev/actionslib` (`pkg/model`, `pkg/exprparser`), the module Gitea and the runner already share for the runner API, so the dependency on the runner repository is dropped here. ### Changes - `gitea.com/gitea/runner/act/model` -> `gitea.dev/actionslib/pkg/model`, `.../act/exprparser` -> `gitea.dev/actionslib/pkg/exprparser` (22 files, import paths only). - `routers/api/actions/runner/interceptor.go` takes the `x-runner-uuid` / `x-runner-token` names from `gitea.dev/actionslib/pkg/protocol` instead of repeating the literals the runner also has. - `go.mod`: `gitea.com/gitea/runner` removed. --------- Signed-off-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: Zettat123 <zettat123@gmail.com>
549 lines
30 KiB
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/actionslib/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})
|
|
})
|
|
})
|
|
}
|