From ce59de3d4e32aeafdd3d4432764435dd3ce25e22 Mon Sep 17 00:00:00 2001 From: silverwind Date: Mon, 20 Apr 2026 09:38:35 +0200 Subject: [PATCH 1/4] actions: populate github.run_id during pre-insert concurrency eval Workflow-level concurrency groups were evaluated in PrepareRunAndInsert before the run was inserted, with run.ID still 0 and run.Index still 0. GenerateGiteaContext hardcoded run_id to "", so expressions like ${{ github.head_ref || github.run_id }} collapsed to the same string on every push event, causing cancel-in-progress to cancel unrelated runs across different branches. Move the workflow-level evaluation into InsertRun after run.Index is assigned, and fall back to run.Index in the context when run.ID is not yet set. run.Index is unique per repo, which is enough for concurrency grouping (groups are scoped per repo). Co-Authored-By: Claude (Opus 4.7) --- services/actions/context.go | 16 +++++++++++++++- services/actions/context_test.go | 10 ++++++++++ services/actions/run.go | 22 +++++++++++++--------- 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/services/actions/context.go b/services/actions/context.go index 626ae6ee6bfbb..083f7251893ee 100644 --- a/services/actions/context.go +++ b/services/actions/context.go @@ -22,6 +22,20 @@ import ( type GiteaContext map[string]any +// runIDForContext returns a stable identifier for the run suitable for use as +// github.run_id in expression contexts. Prefer the persisted run.ID; fall back +// to run.Index during pre-insert workflow-level concurrency evaluation, where +// run.ID is still 0 but run.Index is already assigned. +func runIDForContext(run *actions_model.ActionRun) string { + if run.ID > 0 { + return strconv.FormatInt(run.ID, 10) + } + if run.Index > 0 { + return strconv.FormatInt(run.Index, 10) + } + return "" +} + // GenerateGiteaContext generate the gitea context without token and gitea_runtime_token // job can be nil when generating a context for parsing workflow-level expressions func GenerateGiteaContext(run *actions_model.ActionRun, job *actions_model.ActionRunJob) GiteaContext { @@ -73,7 +87,7 @@ func GenerateGiteaContext(run *actions_model.ActionRun, job *actions_model.Actio "repository_owner": run.Repo.OwnerName, // string, The repository owner's name. For example, Codertocat. "repositoryUrl": run.Repo.HTMLURL(), // string, The Git URL to the repository. For example, git://github.com/codertocat/hello-world.git. "retention_days": "", // string, The number of days that workflow run logs and artifacts are kept. - "run_id": "", // string, A unique number for each workflow run within a repository. This number does not change if you re-run the workflow run. + "run_id": runIDForContext(run), // string, A unique number for each workflow run within a repository. This number does not change if you re-run the workflow run. "run_number": strconv.FormatInt(run.Index, 10), // string, A unique number for each run of a particular workflow in a repository. This number begins at 1 for the workflow's first run, and increments with each new run. This number does not change if you re-run the workflow run. "run_attempt": "", // string, A unique number for each attempt of a particular workflow run in a repository. This number begins at 1 for the workflow run's first attempt, and increments with each re-run. "secret_source": "Actions", // string, The source of a secret used in a workflow. Possible values are None, Actions, Dependabot, or Codespaces. diff --git a/services/actions/context_test.go b/services/actions/context_test.go index 74ef694021a1a..01132ce653652 100644 --- a/services/actions/context_test.go +++ b/services/actions/context_test.go @@ -12,6 +12,16 @@ import ( "github.com/stretchr/testify/assert" ) +func TestRunIDForContext(t *testing.T) { + // Regression: workflow-level concurrency is evaluated before the run is + // inserted (run.ID == 0), so github.run_id must fall back to run.Index — + // otherwise ${{ github.head_ref || github.run_id }} collapses to the same + // string across all push events, cancelling runs across unrelated branches. + assert.Equal(t, "42", runIDForContext(&actions_model.ActionRun{ID: 42, Index: 7})) + assert.Equal(t, "7", runIDForContext(&actions_model.ActionRun{ID: 0, Index: 7})) + assert.Empty(t, runIDForContext(&actions_model.ActionRun{ID: 0, Index: 0})) +} + func TestFindTaskNeeds(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) diff --git a/services/actions/run.go b/services/actions/run.go index e9fcdcaf43d60..323166615e094 100644 --- a/services/actions/run.go +++ b/services/actions/run.go @@ -13,6 +13,7 @@ import ( "code.gitea.io/gitea/modules/util" notify_service "code.gitea.io/gitea/services/notify" + act_model "github.com/nektos/act/pkg/model" "go.yaml.in/yaml/v4" ) @@ -34,13 +35,6 @@ func PrepareRunAndInsert(ctx context.Context, content []byte, run *actions_model return fmt.Errorf("ReadWorkflowRawConcurrency: %w", err) } - if wfRawConcurrency != nil { - err = EvaluateRunConcurrencyFillModel(ctx, run, wfRawConcurrency, vars, inputsWithDefaults) - if err != nil { - return fmt.Errorf("EvaluateRunConcurrencyFillModel: %w", err) - } - } - giteaCtx := GenerateGiteaContext(run, nil) jobs, err := jobparser.Parse(content, jobparser.WithVars(vars), jobparser.WithGitContext(giteaCtx.ToGitHubContext()), jobparser.WithInputs(inputsWithDefaults)) @@ -52,7 +46,7 @@ func PrepareRunAndInsert(ctx context.Context, content []byte, run *actions_model run.Title = jobs[0].RunName } - if err = InsertRun(ctx, run, jobs, vars, inputsWithDefaults); err != nil { + if err = InsertRun(ctx, run, jobs, vars, inputsWithDefaults, wfRawConcurrency); err != nil { return fmt.Errorf("InsertRun: %w", err) } @@ -74,7 +68,7 @@ func PrepareRunAndInsert(ctx context.Context, content []byte, run *actions_model // InsertRun inserts a run // The title will be cut off at 255 characters if it's longer than 255 characters. -func InsertRun(ctx context.Context, run *actions_model.ActionRun, jobs []*jobparser.SingleWorkflow, vars map[string]string, inputs map[string]any) error { +func InsertRun(ctx context.Context, run *actions_model.ActionRun, jobs []*jobparser.SingleWorkflow, vars map[string]string, inputs map[string]any, wfRawConcurrency *act_model.RawConcurrency) error { return db.WithTx(ctx, func(ctx context.Context) error { index, err := db.GetNextResourceIndex(ctx, "action_run_index", run.RepoID) if err != nil { @@ -83,6 +77,16 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, jobs []*jobpar run.Index = index run.Title = util.EllipsisDisplayString(run.Title, 255) + // Evaluate workflow-level concurrency now that run.Index is populated, + // so expressions referencing github.run_id resolve to a per-run unique + // value instead of an empty string (which would collapse all pushes to + // the same group across branches). + if wfRawConcurrency != nil { + if err := EvaluateRunConcurrencyFillModel(ctx, run, wfRawConcurrency, vars, inputs); err != nil { + return fmt.Errorf("EvaluateRunConcurrencyFillModel: %w", err) + } + } + // check run (workflow-level) concurrency run.Status, err = PrepareToStartRunWithConcurrency(ctx, run) if err != nil { From e9498a6d9ae24b968b6e433cb81dd6e52b260990 Mon Sep 17 00:00:00 2001 From: silverwind Date: Mon, 20 Apr 2026 10:10:07 +0200 Subject: [PATCH 2/4] actions: insert run before evaluating workflow-level concurrency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on PR #37311: the previous revision overloaded github.run_id to fall back to run.Index when run.ID was not yet set, which was semantically incorrect — run_id should always mean run.ID. Instead, reorder InsertRun so that db.Insert happens before concurrency evaluation. This gives the run a real ID at eval time, so ${{ github.head_ref || github.run_id }} resolves to a per-run unique value and no longer collapses across branches on push events. The concurrency-derived fields (concurrency_group, concurrency_cancel, status) are persisted with a follow-up UpdateRun. Replace the narrow context unit test with a broader regression test exercising EvaluateRunConcurrencyFillModel against two persisted fixture runs, asserting distinct ConcurrencyGroup values for the real head_ref || run_id expression. Co-Authored-By: Claude (Opus 4.7) --- services/actions/context.go | 20 +++++-------------- services/actions/context_test.go | 34 ++++++++++++++++++++++++-------- services/actions/run.go | 30 +++++++++++++++------------- 3 files changed, 47 insertions(+), 37 deletions(-) diff --git a/services/actions/context.go b/services/actions/context.go index 083f7251893ee..db053e756390b 100644 --- a/services/actions/context.go +++ b/services/actions/context.go @@ -22,20 +22,6 @@ import ( type GiteaContext map[string]any -// runIDForContext returns a stable identifier for the run suitable for use as -// github.run_id in expression contexts. Prefer the persisted run.ID; fall back -// to run.Index during pre-insert workflow-level concurrency evaluation, where -// run.ID is still 0 but run.Index is already assigned. -func runIDForContext(run *actions_model.ActionRun) string { - if run.ID > 0 { - return strconv.FormatInt(run.ID, 10) - } - if run.Index > 0 { - return strconv.FormatInt(run.Index, 10) - } - return "" -} - // GenerateGiteaContext generate the gitea context without token and gitea_runtime_token // job can be nil when generating a context for parsing workflow-level expressions func GenerateGiteaContext(run *actions_model.ActionRun, job *actions_model.ActionRunJob) GiteaContext { @@ -87,7 +73,7 @@ func GenerateGiteaContext(run *actions_model.ActionRun, job *actions_model.Actio "repository_owner": run.Repo.OwnerName, // string, The repository owner's name. For example, Codertocat. "repositoryUrl": run.Repo.HTMLURL(), // string, The Git URL to the repository. For example, git://github.com/codertocat/hello-world.git. "retention_days": "", // string, The number of days that workflow run logs and artifacts are kept. - "run_id": runIDForContext(run), // string, A unique number for each workflow run within a repository. This number does not change if you re-run the workflow run. + "run_id": "", // string, A unique number for each workflow run within a repository. This number does not change if you re-run the workflow run. "run_number": strconv.FormatInt(run.Index, 10), // string, A unique number for each run of a particular workflow in a repository. This number begins at 1 for the workflow's first run, and increments with each new run. This number does not change if you re-run the workflow run. "run_attempt": "", // string, A unique number for each attempt of a particular workflow run in a repository. This number begins at 1 for the workflow run's first attempt, and increments with each re-run. "secret_source": "Actions", // string, The source of a secret used in a workflow. Possible values are None, Actions, Dependabot, or Codespaces. @@ -101,6 +87,10 @@ func GenerateGiteaContext(run *actions_model.ActionRun, job *actions_model.Actio "gitea_default_actions_url": setting.Actions.DefaultActionsURL.URL(), } + if run.ID > 0 { + gitContext["run_id"] = strconv.FormatInt(run.ID, 10) + } + if job != nil { gitContext["job"] = job.JobID gitContext["run_id"] = strconv.FormatInt(job.RunID, 10) diff --git a/services/actions/context_test.go b/services/actions/context_test.go index 01132ce653652..45f5e6f271fdb 100644 --- a/services/actions/context_test.go +++ b/services/actions/context_test.go @@ -9,17 +9,35 @@ import ( actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/unittest" + act_model "github.com/nektos/act/pkg/model" "github.com/stretchr/testify/assert" ) -func TestRunIDForContext(t *testing.T) { - // Regression: workflow-level concurrency is evaluated before the run is - // inserted (run.ID == 0), so github.run_id must fall back to run.Index — - // otherwise ${{ github.head_ref || github.run_id }} collapses to the same - // string across all push events, cancelling runs across unrelated branches. - assert.Equal(t, "42", runIDForContext(&actions_model.ActionRun{ID: 42, Index: 7})) - assert.Equal(t, "7", runIDForContext(&actions_model.ActionRun{ID: 0, Index: 7})) - assert.Empty(t, runIDForContext(&actions_model.ActionRun{ID: 0, Index: 0})) +func TestEvaluateRunConcurrency_RunIDFallback(t *testing.T) { + // Regression: two push-event runs evaluating + // ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + // must produce distinct concurrency groups. head_ref is empty on push, + // so github.run_id is the only uniqueness source; if it evaluated to "" + // (as it did before run.ID was populated pre-evaluation), both runs would + // share a group and cancel-in-progress would cross-cancel unrelated + // branches. + assert.NoError(t, unittest.PrepareTestDatabase()) + ctx := t.Context() + + runA := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 791}) + runB := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 792}) + + expr := &act_model.RawConcurrency{ + Group: "${{ github.workflow }}-${{ github.head_ref || github.run_id }}", + CancelInProgress: "true", + } + + assert.NoError(t, EvaluateRunConcurrencyFillModel(ctx, runA, expr, nil, nil)) + assert.NoError(t, EvaluateRunConcurrencyFillModel(ctx, runB, expr, nil, nil)) + + assert.Contains(t, runA.ConcurrencyGroup, "791") + assert.Contains(t, runB.ConcurrencyGroup, "792") + assert.NotEqual(t, runA.ConcurrencyGroup, runB.ConcurrencyGroup) } func TestFindTaskNeeds(t *testing.T) { diff --git a/services/actions/run.go b/services/actions/run.go index 323166615e094..debc86548a9da 100644 --- a/services/actions/run.go +++ b/services/actions/run.go @@ -76,25 +76,27 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, jobs []*jobpar } run.Index = index run.Title = util.EllipsisDisplayString(run.Title, 255) + run.Status = actions_model.StatusWaiting + + // Insert before evaluating workflow-level concurrency so that run.ID + // is populated: expressions like `${{ github.head_ref || github.run_id }}` + // need a per-run unique value at group evaluation time, otherwise the + // group collapses across unrelated branches on push events. + if err := db.Insert(ctx, run); err != nil { + return err + } - // Evaluate workflow-level concurrency now that run.Index is populated, - // so expressions referencing github.run_id resolve to a per-run unique - // value instead of an empty string (which would collapse all pushes to - // the same group across branches). if wfRawConcurrency != nil { if err := EvaluateRunConcurrencyFillModel(ctx, run, wfRawConcurrency, vars, inputs); err != nil { return fmt.Errorf("EvaluateRunConcurrencyFillModel: %w", err) } - } - - // check run (workflow-level) concurrency - run.Status, err = PrepareToStartRunWithConcurrency(ctx, run) - if err != nil { - return err - } - - if err := db.Insert(ctx, run); err != nil { - return err + run.Status, err = PrepareToStartRunWithConcurrency(ctx, run) + if err != nil { + return err + } + if err := actions_model.UpdateRun(ctx, run, "concurrency_group", "concurrency_cancel", "status"); err != nil { + return err + } } if err := run.LoadRepo(ctx); err != nil { From 4016eef1a0375408f1f3405f4ee359940ee3f06b Mon Sep 17 00:00:00 2001 From: silverwind Date: Tue, 21 Apr 2026 00:14:24 +0200 Subject: [PATCH 3/4] actions: populate run.ID before parsing workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on PR #37311: jobparser.Parse also needs a non-zero run.ID so that github.run_id interpolates correctly in run-name, job names, and runs-on — not just in workflow concurrency group expressions. Moves jobparser.Parse inside the InsertRun transaction, after db.Insert(ctx, run), and consolidates all concurrency field persistence (raw_concurrency, concurrency_group, concurrency_cancel, status) into the single final UpdateRun at end-of-transaction. This also fixes an intermediate regression where raw_concurrency was set in memory but never written to the DB — services/actions/rerun.go reads it from the DB on rerun. Simplifies GenerateGiteaContext to set run_id from run.ID unconditionally; all callers now pass a persisted run. Adds TestPrepareRunAndInsert_ExpressionsSeeRunID exercising the full PrepareRunAndInsert flow with \${{ github.run_id }} in both run-name and concurrency group; a re-ordering regression would fail both assertions. Co-Authored-By: Claude (Opus 4.7) --- services/actions/context.go | 6 +--- services/actions/context_test.go | 58 ++++++++++++++++++++++++++++---- services/actions/run.go | 51 ++++++++++++---------------- 3 files changed, 74 insertions(+), 41 deletions(-) diff --git a/services/actions/context.go b/services/actions/context.go index db053e756390b..69d59376232a0 100644 --- a/services/actions/context.go +++ b/services/actions/context.go @@ -73,7 +73,7 @@ func GenerateGiteaContext(run *actions_model.ActionRun, job *actions_model.Actio "repository_owner": run.Repo.OwnerName, // string, The repository owner's name. For example, Codertocat. "repositoryUrl": run.Repo.HTMLURL(), // string, The Git URL to the repository. For example, git://github.com/codertocat/hello-world.git. "retention_days": "", // string, The number of days that workflow run logs and artifacts are kept. - "run_id": "", // string, A unique number for each workflow run within a repository. This number does not change if you re-run the workflow run. + "run_id": strconv.FormatInt(run.ID, 10), // string, A unique number for each workflow run within a repository. This number does not change if you re-run the workflow run. "run_number": strconv.FormatInt(run.Index, 10), // string, A unique number for each run of a particular workflow in a repository. This number begins at 1 for the workflow's first run, and increments with each new run. This number does not change if you re-run the workflow run. "run_attempt": "", // string, A unique number for each attempt of a particular workflow run in a repository. This number begins at 1 for the workflow run's first attempt, and increments with each re-run. "secret_source": "Actions", // string, The source of a secret used in a workflow. Possible values are None, Actions, Dependabot, or Codespaces. @@ -87,10 +87,6 @@ func GenerateGiteaContext(run *actions_model.ActionRun, job *actions_model.Actio "gitea_default_actions_url": setting.Actions.DefaultActionsURL.URL(), } - if run.ID > 0 { - gitContext["run_id"] = strconv.FormatInt(run.ID, 10) - } - if job != nil { gitContext["job"] = job.JobID gitContext["run_id"] = strconv.FormatInt(job.RunID, 10) diff --git a/services/actions/context_test.go b/services/actions/context_test.go index 45f5e6f271fdb..4ade67111cfa1 100644 --- a/services/actions/context_test.go +++ b/services/actions/context_test.go @@ -4,6 +4,7 @@ package actions import ( + "strconv" "testing" actions_model "code.gitea.io/gitea/models/actions" @@ -11,16 +12,14 @@ import ( act_model "github.com/nektos/act/pkg/model" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestEvaluateRunConcurrency_RunIDFallback(t *testing.T) { - // Regression: two push-event runs evaluating - // ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - // must produce distinct concurrency groups. head_ref is empty on push, - // so github.run_id is the only uniqueness source; if it evaluated to "" - // (as it did before run.ID was populated pre-evaluation), both runs would - // share a group and cancel-in-progress would cross-cancel unrelated - // branches. + // Unit-level check that EvaluateRunConcurrencyFillModel resolves + // github.run_id from run.ID. The full-flow regression — that run.ID is + // non-zero by the time evaluation happens — is in + // TestPrepareRunAndInsert_ExpressionsSeeRunID. assert.NoError(t, unittest.PrepareTestDatabase()) ctx := t.Context() @@ -40,6 +39,51 @@ func TestEvaluateRunConcurrency_RunIDFallback(t *testing.T) { assert.NotEqual(t, runA.ConcurrencyGroup, runB.ConcurrencyGroup) } +func TestPrepareRunAndInsert_ExpressionsSeeRunID(t *testing.T) { + // Regression for the cross-branch concurrency leak: github.run_id must + // be available during BOTH jobparser.Parse (run-name) and workflow-level + // concurrency evaluation. Re-ordering db.Insert relative to either step + // would leave run.ID at 0 and break this test. + assert.NoError(t, unittest.PrepareTestDatabase()) + ctx := t.Context() + + content := []byte(`name: cross-branch +run-name: "Run ${{ github.run_id }}" +on: push +concurrency: + group: group-${{ github.run_id }} + cancel-in-progress: true +jobs: + hello: + runs-on: ubuntu-latest + steps: + - run: echo hi +`) + + run := &actions_model.ActionRun{ + Title: "before parse", + RepoID: 4, + OwnerID: 1, + WorkflowID: "expr-runid.yaml", + TriggerUserID: 1, + Ref: "refs/heads/master", + CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0", + Event: "push", + TriggerEvent: "push", + EventPayload: "{}", + } + require.NoError(t, PrepareRunAndInsert(ctx, content, run, nil)) + require.Positive(t, run.ID) + + persisted := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID}) + runIDStr := strconv.FormatInt(run.ID, 10) + assert.Equal(t, "Run "+runIDStr, persisted.Title) + assert.Equal(t, "group-"+runIDStr, persisted.ConcurrencyGroup) + // Rerun reads raw_concurrency from the DB to re-evaluate the group; + // see services/actions/rerun.go. Must survive the insert. + assert.NotEmpty(t, persisted.RawConcurrency) +} + func TestFindTaskNeeds(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) diff --git a/services/actions/run.go b/services/actions/run.go index debc86548a9da..ca2340ffa87a4 100644 --- a/services/actions/run.go +++ b/services/actions/run.go @@ -35,18 +35,7 @@ func PrepareRunAndInsert(ctx context.Context, content []byte, run *actions_model return fmt.Errorf("ReadWorkflowRawConcurrency: %w", err) } - giteaCtx := GenerateGiteaContext(run, nil) - - jobs, err := jobparser.Parse(content, jobparser.WithVars(vars), jobparser.WithGitContext(giteaCtx.ToGitHubContext()), jobparser.WithInputs(inputsWithDefaults)) - if err != nil { - return fmt.Errorf("parse workflow: %w", err) - } - - if len(jobs) > 0 && jobs[0].RunName != "" { - run.Title = jobs[0].RunName - } - - if err = InsertRun(ctx, run, jobs, vars, inputsWithDefaults, wfRawConcurrency); err != nil { + if err = InsertRun(ctx, run, content, vars, inputsWithDefaults, wfRawConcurrency); err != nil { return fmt.Errorf("InsertRun: %w", err) } @@ -68,7 +57,7 @@ func PrepareRunAndInsert(ctx context.Context, content []byte, run *actions_model // InsertRun inserts a run // The title will be cut off at 255 characters if it's longer than 255 characters. -func InsertRun(ctx context.Context, run *actions_model.ActionRun, jobs []*jobparser.SingleWorkflow, vars map[string]string, inputs map[string]any, wfRawConcurrency *act_model.RawConcurrency) error { +func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte, vars map[string]string, inputs map[string]any, wfRawConcurrency *act_model.RawConcurrency) error { return db.WithTx(ctx, func(ctx context.Context) error { index, err := db.GetNextResourceIndex(ctx, "action_run_index", run.RepoID) if err != nil { @@ -78,14 +67,25 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, jobs []*jobpar run.Title = util.EllipsisDisplayString(run.Title, 255) run.Status = actions_model.StatusWaiting - // Insert before evaluating workflow-level concurrency so that run.ID - // is populated: expressions like `${{ github.head_ref || github.run_id }}` - // need a per-run unique value at group evaluation time, otherwise the - // group collapses across unrelated branches on push events. + // Insert before parsing jobs or evaluating workflow-level concurrency + // so that run.ID is populated. Expressions referencing github.run_id — + // in run-name, job names, runs-on, or a workflow-level concurrency + // group like `${{ github.head_ref || github.run_id }}` — would otherwise + // interpolate to an empty string. if err := db.Insert(ctx, run); err != nil { return err } + giteaCtx := GenerateGiteaContext(run, nil) + jobs, err := jobparser.Parse(content, jobparser.WithVars(vars), jobparser.WithGitContext(giteaCtx.ToGitHubContext()), jobparser.WithInputs(inputs)) + if err != nil { + return fmt.Errorf("parse workflow: %w", err) + } + + if len(jobs) > 0 && jobs[0].RunName != "" { + run.Title = util.EllipsisDisplayString(jobs[0].RunName, 255) + } + if wfRawConcurrency != nil { if err := EvaluateRunConcurrencyFillModel(ctx, run, wfRawConcurrency, vars, inputs); err != nil { return fmt.Errorf("EvaluateRunConcurrencyFillModel: %w", err) @@ -94,17 +94,6 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, jobs []*jobpar if err != nil { return err } - if err := actions_model.UpdateRun(ctx, run, "concurrency_group", "concurrency_cancel", "status"); err != nil { - return err - } - } - - if err := run.LoadRepo(ctx); err != nil { - return err - } - - if err := actions_model.UpdateRepoRunsNumbers(ctx, run.Repo); err != nil { - return err } runJobs := make([]*actions_model.ActionRunJob, 0, len(jobs)) @@ -174,7 +163,11 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, jobs []*jobpar } run.Status = actions_model.AggregateJobStatus(runJobs) - if err := actions_model.UpdateRun(ctx, run, "status"); err != nil { + cols := []string{"status", "title"} + if wfRawConcurrency != nil { + cols = append(cols, "raw_concurrency", "concurrency_group", "concurrency_cancel") + } + if err := actions_model.UpdateRun(ctx, run, cols...); err != nil { return err } From 4a0769ded1b22ec2b958c802869d215fb476c2f3 Mon Sep 17 00:00:00 2001 From: silverwind Date: Tue, 21 Apr 2026 01:14:46 +0200 Subject: [PATCH 4/4] actions: skip title column in final UpdateRun when unchanged The default title (commit message) was already persisted by db.Insert; only include "title" in the final UpdateRun when jobs[0].RunName actually overrode it. Co-Authored-By: Claude (Opus 4.7) --- services/actions/run.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/services/actions/run.go b/services/actions/run.go index ca2340ffa87a4..432bb19628468 100644 --- a/services/actions/run.go +++ b/services/actions/run.go @@ -82,7 +82,8 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte return fmt.Errorf("parse workflow: %w", err) } - if len(jobs) > 0 && jobs[0].RunName != "" { + titleChanged := len(jobs) > 0 && jobs[0].RunName != "" + if titleChanged { run.Title = util.EllipsisDisplayString(jobs[0].RunName, 255) } @@ -163,7 +164,10 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte } run.Status = actions_model.AggregateJobStatus(runJobs) - cols := []string{"status", "title"} + cols := []string{"status"} + if titleChanged { + cols = append(cols, "title") + } if wfRawConcurrency != nil { cols = append(cols, "raw_concurrency", "concurrency_group", "concurrency_cancel") }