From 9a73a1fb83eb1461f6f52448321681d47101d8d8 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Fri, 24 Oct 2025 12:46:54 +0800 Subject: [PATCH 1/6] Make "update file" API can create a new file when SHA is not set (#35738) Fix #19008, use GitHub's behavior (empty SHA to create a new file) --- modules/structs/repo_file.go | 22 ++++++------ routers/api/v1/repo/file.go | 9 +++-- templates/swagger/v1_json.tmpl | 16 +++++---- .../integration/api_repo_file_delete_test.go | 26 +++++++------- .../integration/api_repo_file_update_test.go | 35 +++++++++++-------- 5 files changed, 58 insertions(+), 50 deletions(-) diff --git a/modules/structs/repo_file.go b/modules/structs/repo_file.go index 4729bde491..59665062b7 100644 --- a/modules/structs/repo_file.go +++ b/modules/structs/repo_file.go @@ -24,13 +24,6 @@ type FileOptions struct { Signoff bool `json:"signoff"` } -type FileOptionsWithSHA struct { - FileOptions - // the blob ID (SHA) for the file that already exists, it is required for changing existing files - // required: true - SHA string `json:"sha" binding:"Required"` -} - func (f *FileOptions) GetFileOptions() *FileOptions { return f } @@ -41,7 +34,7 @@ type FileOptionsInterface interface { var _ FileOptionsInterface = (*FileOptions)(nil) -// CreateFileOptions options for creating files +// CreateFileOptions options for creating a file // Note: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used) type CreateFileOptions struct { FileOptions @@ -50,16 +43,21 @@ type CreateFileOptions struct { ContentBase64 string `json:"content"` } -// DeleteFileOptions options for deleting files (used for other File structs below) +// DeleteFileOptions options for deleting a file // Note: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used) type DeleteFileOptions struct { - FileOptionsWithSHA + FileOptions + // the blob ID (SHA) for the file to delete + // required: true + SHA string `json:"sha" binding:"Required"` } -// UpdateFileOptions options for updating files +// UpdateFileOptions options for updating or creating a file // Note: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used) type UpdateFileOptions struct { - FileOptionsWithSHA + FileOptions + // the blob ID (SHA) for the file that already exists to update, or leave it empty to create a new file + SHA string `json:"sha"` // content must be base64 encoded // required: true ContentBase64 string `json:"content"` diff --git a/routers/api/v1/repo/file.go b/routers/api/v1/repo/file.go index 2b6348c2fb..ba98263819 100644 --- a/routers/api/v1/repo/file.go +++ b/routers/api/v1/repo/file.go @@ -525,7 +525,7 @@ func CreateFile(ctx *context.APIContext) { func UpdateFile(ctx *context.APIContext) { // swagger:operation PUT /repos/{owner}/{repo}/contents/{filepath} repository repoUpdateFile // --- - // summary: Update a file in a repository + // summary: Update a file in a repository if SHA is set, or create the file if SHA is not set // consumes: // - application/json // produces: @@ -554,6 +554,8 @@ func UpdateFile(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/FileResponse" + // "201": + // "$ref": "#/responses/FileResponse" // "403": // "$ref": "#/responses/error" // "404": @@ -572,8 +574,9 @@ func UpdateFile(ctx *context.APIContext) { ctx.APIError(http.StatusUnprocessableEntity, err) return } + willCreate := apiOpts.SHA == "" opts.Files = append(opts.Files, &files_service.ChangeRepoFile{ - Operation: "update", + Operation: util.Iif(willCreate, "create", "update"), ContentReader: contentReader, SHA: apiOpts.SHA, FromTreePath: apiOpts.FromPath, @@ -587,7 +590,7 @@ func UpdateFile(ctx *context.APIContext) { handleChangeRepoFilesError(ctx, err) } else { fileResponse := files_service.GetFileResponseFromFilesResponse(filesResponse, 0) - ctx.JSON(http.StatusOK, fileResponse) + ctx.JSON(util.Iif(willCreate, http.StatusCreated, http.StatusOK), fileResponse) } } diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 966aff12f8..0cefa6795f 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -7634,7 +7634,7 @@ "tags": [ "repository" ], - "summary": "Update a file in a repository", + "summary": "Update a file in a repository if SHA is set, or create the file if SHA is not set", "operationId": "repoUpdateFile", "parameters": [ { @@ -7671,6 +7671,9 @@ "200": { "$ref": "#/responses/FileResponse" }, + "201": { + "$ref": "#/responses/FileResponse" + }, "403": { "$ref": "#/responses/error" }, @@ -22886,7 +22889,7 @@ "x-go-package": "code.gitea.io/gitea/modules/structs" }, "CreateFileOptions": { - "description": "CreateFileOptions options for creating files\nNote: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used)", + "description": "CreateFileOptions options for creating a file\nNote: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used)", "type": "object", "required": [ "content" @@ -23904,7 +23907,7 @@ "x-go-package": "code.gitea.io/gitea/modules/structs" }, "DeleteFileOptions": { - "description": "DeleteFileOptions options for deleting files (used for other File structs below)\nNote: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used)", + "description": "DeleteFileOptions options for deleting a file\nNote: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used)", "type": "object", "required": [ "sha" @@ -23940,7 +23943,7 @@ "x-go-name": "NewBranchName" }, "sha": { - "description": "the blob ID (SHA) for the file that already exists, it is required for changing existing files", + "description": "the blob ID (SHA) for the file to delete", "type": "string", "x-go-name": "SHA" }, @@ -28700,10 +28703,9 @@ "x-go-package": "code.gitea.io/gitea/modules/structs" }, "UpdateFileOptions": { - "description": "UpdateFileOptions options for updating files\nNote: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used)", + "description": "UpdateFileOptions options for updating or creating a file\nNote: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used)", "type": "object", "required": [ - "sha", "content" ], "properties": { @@ -28747,7 +28749,7 @@ "x-go-name": "NewBranchName" }, "sha": { - "description": "the blob ID (SHA) for the file that already exists, it is required for changing existing files", + "description": "the blob ID (SHA) for the file that already exists to update, or leave it empty to create a new file", "type": "string", "x-go-name": "SHA" }, diff --git a/tests/integration/api_repo_file_delete_test.go b/tests/integration/api_repo_file_delete_test.go index 9dd47f93e6..59e2131618 100644 --- a/tests/integration/api_repo_file_delete_test.go +++ b/tests/integration/api_repo_file_delete_test.go @@ -20,21 +20,19 @@ import ( func getDeleteFileOptions() *api.DeleteFileOptions { return &api.DeleteFileOptions{ - FileOptionsWithSHA: api.FileOptionsWithSHA{ - FileOptions: api.FileOptions{ - BranchName: "master", - NewBranchName: "master", - Message: "Removing the file new/file.txt", - Author: api.Identity{ - Name: "John Doe", - Email: "johndoe@example.com", - }, - Committer: api.Identity{ - Name: "Jane Doe", - Email: "janedoe@example.com", - }, + SHA: "103ff9234cefeee5ec5361d22b49fbb04d385885", + FileOptions: api.FileOptions{ + BranchName: "master", + NewBranchName: "master", + Message: "Removing the file new/file.txt", + Author: api.Identity{ + Name: "John Doe", + Email: "johndoe@example.com", + }, + Committer: api.Identity{ + Name: "Jane Doe", + Email: "janedoe@example.com", }, - SHA: "103ff9234cefeee5ec5361d22b49fbb04d385885", }, } } diff --git a/tests/integration/api_repo_file_update_test.go b/tests/integration/api_repo_file_update_test.go index 9a56711da6..6e6aae389f 100644 --- a/tests/integration/api_repo_file_update_test.go +++ b/tests/integration/api_repo_file_update_test.go @@ -28,21 +28,19 @@ func getUpdateFileOptions() *api.UpdateFileOptions { content := "This is updated text" contentEncoded := base64.StdEncoding.EncodeToString([]byte(content)) return &api.UpdateFileOptions{ - FileOptionsWithSHA: api.FileOptionsWithSHA{ - FileOptions: api.FileOptions{ - BranchName: "master", - NewBranchName: "master", - Message: "My update of new/file.txt", - Author: api.Identity{ - Name: "John Doe", - Email: "johndoe@example.com", - }, - Committer: api.Identity{ - Name: "Anne Doe", - Email: "annedoe@example.com", - }, + SHA: "103ff9234cefeee5ec5361d22b49fbb04d385885", + FileOptions: api.FileOptions{ + BranchName: "master", + NewBranchName: "master", + Message: "My update of new/file.txt", + Author: api.Identity{ + Name: "John Doe", + Email: "johndoe@example.com", + }, + Committer: api.Identity{ + Name: "Anne Doe", + Email: "annedoe@example.com", }, - SHA: "103ff9234cefeee5ec5361d22b49fbb04d385885", }, ContentBase64: contentEncoded, } @@ -180,6 +178,15 @@ func TestAPIUpdateFile(t *testing.T) { assert.Equal(t, expectedDownloadURL, *fileResponse.Content.DownloadURL) assert.Equal(t, updateFileOptions.Message+"\n", fileResponse.Commit.Message) + // Test updating a file without SHA (should create the file) + updateFileOptions = getUpdateFileOptions() + updateFileOptions.SHA = "" + req = NewRequestWithJSON(t, "PUT", "/api/v1/repos/user2/repo1/contents/update-create.txt", &updateFileOptions).AddTokenAuth(token2) + resp = MakeRequest(t, req, http.StatusCreated) + DecodeJSON(t, resp, &fileResponse) + assert.Equal(t, "08bd14b2e2852529157324de9c226b3364e76136", fileResponse.Content.SHA) + assert.Equal(t, setting.AppURL+"user2/repo1/raw/branch/master/update-create.txt", *fileResponse.Content.DownloadURL) + // Test updating a file and renaming it updateFileOptions = getUpdateFileOptions() updateFileOptions.BranchName = repo1.DefaultBranch From 0d740a6a7231b254306d0b1513ff27e32646cd5e Mon Sep 17 00:00:00 2001 From: Zettat123 Date: Fri, 24 Oct 2025 13:02:52 -0600 Subject: [PATCH 2/6] Improve online runner check (#35722) This PR moves "no online runner" warning to the runs list. A job's `runs-on` may contain expressions like `runs-on: [self-hosted, "${{ inputs.chosen-os }}"]` so the value of `runs-on` may be different in each run. We cannot check it through the workflow file.
Screenshots Before: 3d2a91746271d8b1f12c8f7d20eba550 After: image
This PR also splits `prepareWorkflowDispatchTemplate` function into 2 functions: - `prepareWorkflowTemplate` get and check all of the workflows - `prepareWorkflowDispatchTemplate` only prepare workflow dispatch config for `workflow_dispatch` workflows. --------- Co-authored-by: wxiaoguang --- models/actions/runner.go | 8 + models/actions/task.go | 17 +- routers/web/repo/actions/actions.go | 218 +++++++++++++++----------- templates/repo/actions/list.tmpl | 12 +- templates/repo/actions/runs_list.tmpl | 8 + web_src/css/base.css | 1 + 6 files changed, 147 insertions(+), 117 deletions(-) diff --git a/models/actions/runner.go b/models/actions/runner.go index 81d4249ae0..84398b143b 100644 --- a/models/actions/runner.go +++ b/models/actions/runner.go @@ -14,6 +14,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/shared/types" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" @@ -173,6 +174,13 @@ func (r *ActionRunner) GenerateToken() (err error) { return err } +// CanMatchLabels checks whether the runner's labels can match a job's "runs-on" +// See https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idruns-on +func (r *ActionRunner) CanMatchLabels(jobRunsOn []string) bool { + runnerLabelSet := container.SetOf(r.AgentLabels...) + return runnerLabelSet.Contains(jobRunsOn...) // match all labels +} + func init() { db.RegisterModel(&ActionRunner{}) } diff --git a/models/actions/task.go b/models/actions/task.go index 7417af8b45..8b4ecf28f7 100644 --- a/models/actions/task.go +++ b/models/actions/task.go @@ -13,7 +13,6 @@ import ( auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/unit" - "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" @@ -245,7 +244,7 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask var job *ActionRunJob log.Trace("runner labels: %v", runner.AgentLabels) for _, v := range jobs { - if isSubset(runner.AgentLabels, v.RunsOn) { + if runner.CanMatchLabels(v.RunsOn) { job = v break } @@ -475,20 +474,6 @@ func FindOldTasksToExpire(ctx context.Context, olderThan timeutil.TimeStamp, lim Find(&tasks) } -func isSubset(set, subset []string) bool { - m := make(container.Set[string], len(set)) - for _, v := range set { - m.Add(v) - } - - for _, v := range subset { - if !m.Contains(v) { - return false - } - } - return true -} - func convertTimestamp(timestamp *timestamppb.Timestamp) timeutil.TimeStamp { if timestamp.GetSeconds() == 0 && timestamp.GetNanos() == 0 { return timeutil.TimeStamp(0) diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go index 202da407d2..43c56752e8 100644 --- a/routers/web/repo/actions/actions.go +++ b/routers/web/repo/actions/actions.go @@ -28,7 +28,7 @@ import ( "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" - "github.com/nektos/act/pkg/model" + act_model "github.com/nektos/act/pkg/model" "gopkg.in/yaml.v3" ) @@ -38,9 +38,10 @@ const ( tplViewActions templates.TplName = "repo/actions/view" ) -type Workflow struct { - Entry git.TreeEntry - ErrMsg string +type WorkflowInfo struct { + Entry git.TreeEntry + ErrMsg string + Workflow *act_model.Workflow } // MustEnableActions check if actions are enabled in settings @@ -77,7 +78,11 @@ func List(ctx *context.Context) { return } - workflows := prepareWorkflowDispatchTemplate(ctx, commit) + workflows, curWorkflowID := prepareWorkflowTemplate(ctx, commit) + if ctx.Written() { + return + } + prepareWorkflowDispatchTemplate(ctx, workflows, curWorkflowID) if ctx.Written() { return } @@ -112,55 +117,41 @@ func WorkflowDispatchInputs(ctx *context.Context) { ctx.ServerError("GetTagCommit/GetBranchCommit", err) return } - prepareWorkflowDispatchTemplate(ctx, commit) + workflows, curWorkflowID := prepareWorkflowTemplate(ctx, commit) + if ctx.Written() { + return + } + prepareWorkflowDispatchTemplate(ctx, workflows, curWorkflowID) if ctx.Written() { return } ctx.HTML(http.StatusOK, tplDispatchInputsActions) } -func prepareWorkflowDispatchTemplate(ctx *context.Context, commit *git.Commit) (workflows []Workflow) { - workflowID := ctx.FormString("workflow") - ctx.Data["CurWorkflow"] = workflowID - ctx.Data["CurWorkflowExists"] = false - - var curWorkflow *model.Workflow +func prepareWorkflowTemplate(ctx *context.Context, commit *git.Commit) (workflows []WorkflowInfo, curWorkflowID string) { + curWorkflowID = ctx.FormString("workflow") _, entries, err := actions.ListWorkflows(commit) if err != nil { ctx.ServerError("ListWorkflows", err) - return nil + return nil, "" } - // Get all runner labels - runners, err := db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{ - RepoID: ctx.Repo.Repository.ID, - IsOnline: optional.Some(true), - WithAvailable: true, - }) - if err != nil { - ctx.ServerError("FindRunners", err) - return nil - } - allRunnerLabels := make(container.Set[string]) - for _, r := range runners { - allRunnerLabels.AddMultiple(r.AgentLabels...) - } - - workflows = make([]Workflow, 0, len(entries)) + workflows = make([]WorkflowInfo, 0, len(entries)) for _, entry := range entries { - workflow := Workflow{Entry: *entry} + workflow := WorkflowInfo{Entry: *entry} content, err := actions.GetContentFromEntry(entry) if err != nil { ctx.ServerError("GetContentFromEntry", err) - return nil + return nil, "" } - wf, err := model.ReadWorkflow(bytes.NewReader(content)) + wf, err := act_model.ReadWorkflow(bytes.NewReader(content)) if err != nil { workflow.ErrMsg = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error()) workflows = append(workflows, workflow) continue } + workflow.Workflow = wf // The workflow must contain at least one job without "needs". Otherwise, a deadlock will occur and no jobs will be able to run. hasJobWithoutNeeds := false // Check whether you have matching runner and a job without "needs" @@ -173,22 +164,6 @@ func prepareWorkflowDispatchTemplate(ctx *context.Context, commit *git.Commit) ( if !hasJobWithoutNeeds && len(j.Needs()) == 0 { hasJobWithoutNeeds = true } - runsOnList := j.RunsOn() - for _, ro := range runsOnList { - if strings.Contains(ro, "${{") { - // Skip if it contains expressions. - // The expressions could be very complex and could not be evaluated here, - // so just skip it, it's OK since it's just a tooltip message. - continue - } - if !allRunnerLabels.Contains(ro) { - workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_matching_online_runner_helper", ro) - break - } - } - if workflow.ErrMsg != "" { - break - } } if !hasJobWithoutNeeds { workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job_without_needs") @@ -197,61 +172,75 @@ func prepareWorkflowDispatchTemplate(ctx *context.Context, commit *git.Commit) ( workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job") } workflows = append(workflows, workflow) - - if workflow.Entry.Name() == workflowID { - curWorkflow = wf - ctx.Data["CurWorkflowExists"] = true - } } ctx.Data["workflows"] = workflows ctx.Data["RepoLink"] = ctx.Repo.Repository.Link() - + ctx.Data["AllowDisableOrEnableWorkflow"] = ctx.Repo.IsAdmin() actionsConfig := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions).ActionsConfig() ctx.Data["ActionsConfig"] = actionsConfig + ctx.Data["CurWorkflow"] = curWorkflowID + ctx.Data["CurWorkflowDisabled"] = actionsConfig.IsWorkflowDisabled(curWorkflowID) - if len(workflowID) > 0 && ctx.Repo.CanWrite(unit.TypeActions) { - ctx.Data["AllowDisableOrEnableWorkflow"] = ctx.Repo.IsAdmin() - isWorkflowDisabled := actionsConfig.IsWorkflowDisabled(workflowID) - ctx.Data["CurWorkflowDisabled"] = isWorkflowDisabled - - if !isWorkflowDisabled && curWorkflow != nil { - workflowDispatchConfig := workflowDispatchConfig(curWorkflow) - if workflowDispatchConfig != nil { - ctx.Data["WorkflowDispatchConfig"] = workflowDispatchConfig - - branchOpts := git_model.FindBranchOptions{ - RepoID: ctx.Repo.Repository.ID, - IsDeletedBranch: optional.Some(false), - ListOptions: db.ListOptions{ - ListAll: true, - }, - } - branches, err := git_model.FindBranchNames(ctx, branchOpts) - if err != nil { - ctx.ServerError("FindBranchNames", err) - return nil - } - // always put default branch on the top if it exists - if slices.Contains(branches, ctx.Repo.Repository.DefaultBranch) { - branches = util.SliceRemoveAll(branches, ctx.Repo.Repository.DefaultBranch) - branches = append([]string{ctx.Repo.Repository.DefaultBranch}, branches...) - } - ctx.Data["Branches"] = branches - - tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID) - if err != nil { - ctx.ServerError("GetTagNamesByRepoID", err) - return nil - } - ctx.Data["Tags"] = tags - } - } - } - return workflows + return workflows, curWorkflowID } -func prepareWorkflowList(ctx *context.Context, workflows []Workflow) { +func prepareWorkflowDispatchTemplate(ctx *context.Context, workflowInfos []WorkflowInfo, curWorkflowID string) { + actionsConfig := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions).ActionsConfig() + if curWorkflowID == "" || !ctx.Repo.CanWrite(unit.TypeActions) || actionsConfig.IsWorkflowDisabled(curWorkflowID) { + return + } + + var curWorkflow *act_model.Workflow + for _, workflowInfo := range workflowInfos { + if workflowInfo.Entry.Name() == curWorkflowID { + if workflowInfo.Workflow == nil { + log.Debug("CurWorkflowID %s is found but its workflowInfo.Workflow is nil", curWorkflowID) + return + } + curWorkflow = workflowInfo.Workflow + break + } + } + + if curWorkflow == nil { + return + } + + ctx.Data["CurWorkflowExists"] = true + curWfDispatchCfg := workflowDispatchConfig(curWorkflow) + if curWfDispatchCfg == nil { + return + } + + ctx.Data["WorkflowDispatchConfig"] = curWfDispatchCfg + + branchOpts := git_model.FindBranchOptions{ + RepoID: ctx.Repo.Repository.ID, + IsDeletedBranch: optional.Some(false), + ListOptions: db.ListOptions{ + ListAll: true, + }, + } + branches, err := git_model.FindBranchNames(ctx, branchOpts) + if err != nil { + ctx.ServerError("FindBranchNames", err) + return + } + // always put default branch on the top + branches = util.SliceRemoveAll(branches, ctx.Repo.Repository.DefaultBranch) + branches = append([]string{ctx.Repo.Repository.DefaultBranch}, branches...) + ctx.Data["Branches"] = branches + + tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.ServerError("GetTagNamesByRepoID", err) + return + } + ctx.Data["Tags"] = tags +} + +func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo) { actorID := ctx.FormInt64("actor") status := ctx.FormInt("status") workflowID := ctx.FormString("workflow") @@ -302,6 +291,45 @@ func prepareWorkflowList(ctx *context.Context, workflows []Workflow) { log.Error("LoadIsRefDeleted", err) } + // Check for each run if there is at least one online runner that can run its jobs + runErrors := make(map[int64]string) + runners, err := db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{ + RepoID: ctx.Repo.Repository.ID, + IsOnline: optional.Some(true), + WithAvailable: true, + }) + if err != nil { + ctx.ServerError("FindRunners", err) + return + } + for _, run := range runs { + if !run.Status.In(actions_model.StatusWaiting, actions_model.StatusRunning) { + continue + } + jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) + if err != nil { + ctx.ServerError("GetRunJobsByRunID", err) + return + } + for _, job := range jobs { + if !job.Status.IsWaiting() { + continue + } + hasOnlineRunner := false + for _, runner := range runners { + if runner.CanMatchLabels(job.RunsOn) { + hasOnlineRunner = true + break + } + } + if !hasOnlineRunner { + runErrors[run.ID] = ctx.Locale.TrString("actions.runs.no_matching_online_runner_helper", strings.Join(job.RunsOn, ",")) + break + } + } + } + ctx.Data["RunErrors"] = runErrors + ctx.Data["Runs"] = runs actors, err := actions_model.GetActors(ctx, ctx.Repo.Repository.ID) @@ -362,7 +390,7 @@ type WorkflowDispatch struct { Inputs []WorkflowDispatchInput } -func workflowDispatchConfig(w *model.Workflow) *WorkflowDispatch { +func workflowDispatchConfig(w *act_model.Workflow) *WorkflowDispatch { switch w.RawOn.Kind { case yaml.ScalarNode: var val string diff --git a/templates/repo/actions/list.tmpl b/templates/repo/actions/list.tmpl index 7d782c0ade..fe8c26b523 100644 --- a/templates/repo/actions/list.tmpl +++ b/templates/repo/actions/list.tmpl @@ -7,14 +7,14 @@ {{if .HasWorkflowsOrRuns}}
-
diff --git a/templates/repo/settings/options.tmpl b/templates/repo/settings/options.tmpl index fc42056e0a..b4680431b8 100644 --- a/templates/repo/settings/options.tmpl +++ b/templates/repo/settings/options.tmpl @@ -509,18 +509,6 @@
- {{if .EnableActions}} - {{$isActionsEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypeActions}} - {{$isActionsGlobalDisabled := ctx.Consts.RepoUnitTypeActions.UnitGlobalDisabled}} -
- -
- - -
-
- {{end}} - {{if not .IsMirror}}
{{$pullRequestEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypePullRequests}} diff --git a/tests/integration/actions_settings_test.go b/tests/integration/actions_settings_test.go new file mode 100644 index 0000000000..935d8bbceb --- /dev/null +++ b/tests/integration/actions_settings_test.go @@ -0,0 +1,62 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "testing" + + actions_model "code.gitea.io/gitea/models/actions" + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + + "github.com/stretchr/testify/assert" +) + +func TestActionsCollaborativeOwner(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + // user2 is the owner of "reusable_workflow" repo + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + user2Session := loginUser(t, user2.Name) + user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + repo := createActionsTestRepo(t, user2Token, "reusable_workflow", true) + + // a private repo(id=6) of user10 will try to clone "reusable_workflow" repo + user10 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 10}) + // task id is 55 and its repo_id=6 + task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 55, RepoID: 6}) + taskToken := "674f727a81ed2f195bccab036cccf86a182199eb" + tokenHash := auth_model.HashToken(taskToken, task.TokenSalt) + assert.Equal(t, task.TokenHash, tokenHash) + + dstPath := t.TempDir() + u.Path = fmt.Sprintf("%s/%s.git", repo.Owner.UserName, repo.Name) + u.User = url.UserPassword("gitea-actions", taskToken) + + // the git clone will fail + doGitCloneFail(u)(t) + + // add user10 to the list of collaborative owners + req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/actions/general/collaborative_owner/add", repo.Owner.UserName, repo.Name), map[string]string{ + "_csrf": GetUserCSRFToken(t, user2Session), + "collaborative_owner": user10.Name, + }) + user2Session.MakeRequest(t, req, http.StatusOK) + + // the git clone will be successful + doGitClone(dstPath, u)(t) + + // remove user10 from the list of collaborative owners + req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/actions/general/collaborative_owner/delete?id=%d", repo.Owner.UserName, repo.Name, user10.ID), map[string]string{ + "_csrf": GetUserCSRFToken(t, user2Session), + }) + user2Session.MakeRequest(t, req, http.StatusOK) + + // the git clone will fail + doGitCloneFail(u)(t) + }) +} diff --git a/web_src/js/features/comp/SearchUserBox.ts b/web_src/js/features/comp/SearchUserBox.ts index 4b13a2141f..9cba18b356 100644 --- a/web_src/js/features/comp/SearchUserBox.ts +++ b/web_src/js/features/comp/SearchUserBox.ts @@ -10,10 +10,11 @@ export function initCompSearchUserBox() { const allowEmailInput = searchUserBox.getAttribute('data-allow-email') === 'true'; const allowEmailDescription = searchUserBox.getAttribute('data-allow-email-description') ?? undefined; + const includeOrgs = searchUserBox.getAttribute('data-include-orgs') === 'true'; fomanticQuery(searchUserBox).search({ minCharacters: 2, apiSettings: { - url: `${appSubUrl}/user/search_candidates?q={query}`, + url: `${appSubUrl}/user/search_candidates?q={query}&orgs=${includeOrgs}`, onResponse(response: any) { const resultItems = []; const searchQuery = searchUserBox.querySelector('input').value;