Fix actions concurrency groups cross-branch leak (#37311)#37331
Merged
silverwind merged 1 commit intogo-gitea:release/v1.26from Apr 21, 2026
Merged
Fix actions concurrency groups cross-branch leak (#37311)#37331silverwind merged 1 commit intogo-gitea:release/v1.26from
silverwind merged 1 commit intogo-gitea:release/v1.26from
Conversation
## Problem
Workflow-level concurrency groups were evaluated — and jobs were parsed
— before the run was persisted, so `run.ID` was `0` and `github.run_id`
in the expression context resolved to an empty string. Expressions like:
```yaml
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
```
collapsed to `<workflow>-` on every push event (`head_ref` is empty on
push), so `cancel-in-progress` cancelled in-progress runs across
**unrelated branches**, not just the current one.
Reproduced on a 1.26 instance:
- push to `master` → `ci` run starts
- push to `feature-branch` → the `master` run gets cancelled
GitHub Actions' documented semantic: on push events `github.run_id` is
unique per run, so the group is unique → no cancellation; on PR events
`github.head_ref` is the source branch → cancellation is per-PR.
## Fix
Insert the run **before** parsing jobs or evaluating workflow-level
concurrency, so `run.ID` is populated in time for every expression that
reads `github.run_id` — not just the concurrency group, but also
`run-name`, job names, and `runs-on`.
`jobparser.Parse` now runs inside the `InsertRun` transaction, after
`db.Insert(ctx, run)`. Workflow-level concurrency evaluation runs next
and only mutates `run` in memory. All concurrency-derived fields
(`raw_concurrency`, `concurrency_group`, `concurrency_cancel`) plus
`status` and `title` are persisted in a single final `UpdateRun` at
end-of-transaction — one `INSERT` + one `UPDATE` per run in both the
concurrency and non-concurrency paths (matches pre-branch parity, one
fewer `UpdateRepoRunsNumbers` `COUNT` than the interim state).
`GenerateGiteaContext` now sets `run_id` from `run.ID` unconditionally;
every caller passes a persisted run.
**Verification**: tested end-to-end on a 1.26 deployment. Before the
patch, two successive `ci` pushes (one to master, one to a feature
branch) cross-cancelled each other. After the patch, the same pushes —
in both orders (master→branch, branch→master) — run to completion
simultaneously across 15+ runs with zero cancellations.
**Regression tests** in `services/actions/context_test.go`:
- `TestEvaluateRunConcurrency_RunIDFallback` — unit check that
`EvaluateRunConcurrencyFillModel` resolves `github.run_id` from
`run.ID`.
- `TestPrepareRunAndInsert_ExpressionsSeeRunID` — full-flow check: calls
`PrepareRunAndInsert` with `${{ github.run_id }}` in both `run-name` and
the concurrency group, then asserts the persisted `Title`,
`ConcurrencyGroup`, and `RawConcurrency` contain / survive the run's ID.
Re-ordering `db.Insert` relative to either parse or concurrency eval
fails this test.
## Relation to go-gitea#37119
[go-gitea#37119](go-gitea#37119) also moves
concurrency evaluation into `InsertRun` but keeps it **before**
`db.Insert`, then tries to populate `run_id` only when `run.ID > 0` —
which is still `0` at that call site, so the cross-branch leak would
survive that PR as written. This PR fixes the ordering so that `run.ID`
is actually populated at eval time, and broadens it to cover parse-time
expression interpolation too.
Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com>
lunny
approved these changes
Apr 21, 2026
silverwind
approved these changes
Apr 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Backport #37311 by @silverwind
Problem
Workflow-level concurrency groups were evaluated — and jobs were parsed — before the run was persisted, so
run.IDwas0andgithub.run_idin the expression context resolved to an empty string. Expressions like:collapsed to
<workflow>-on every push event (head_refis empty on push), socancel-in-progresscancelled in-progress runs across unrelated branches, not just the current one.Reproduced on a 1.26 instance:
master→cirun startsfeature-branch→ themasterrun gets cancelledGitHub Actions' documented semantic: on push events
github.run_idis unique per run, so the group is unique → no cancellation; on PR eventsgithub.head_refis the source branch → cancellation is per-PR.Fix
Insert the run before parsing jobs or evaluating workflow-level concurrency, so
run.IDis populated in time for every expression that readsgithub.run_id— not just the concurrency group, but alsorun-name, job names, andruns-on.jobparser.Parsenow runs inside theInsertRuntransaction, afterdb.Insert(ctx, run). Workflow-level concurrency evaluation runs next and only mutatesrunin memory. All concurrency-derived fields (raw_concurrency,concurrency_group,concurrency_cancel) plusstatusandtitleare persisted in a single finalUpdateRunat end-of-transaction — oneINSERT+ oneUPDATEper run in both the concurrency and non-concurrency paths (matches pre-branch parity, one fewerUpdateRepoRunsNumbersCOUNTthan the interim state).GenerateGiteaContextnow setsrun_idfromrun.IDunconditionally; every caller passes a persisted run.Verification: tested end-to-end on a 1.26 deployment. Before the patch, two successive
cipushes (one to master, one to a feature branch) cross-cancelled each other. After the patch, the same pushes — in both orders (master→branch, branch→master) — run to completion simultaneously across 15+ runs with zero cancellations.Regression tests in
services/actions/context_test.go:TestEvaluateRunConcurrency_RunIDFallback— unit check thatEvaluateRunConcurrencyFillModelresolvesgithub.run_idfromrun.ID.TestPrepareRunAndInsert_ExpressionsSeeRunID— full-flow check: callsPrepareRunAndInsertwith${{ github.run_id }}in bothrun-nameand the concurrency group, then asserts the persistedTitle,ConcurrencyGroup, andRawConcurrencycontain / survive the run's ID. Re-orderingdb.Insertrelative to either parse or concurrency eval fails this test.Relation to #37119
#37119 also moves concurrency evaluation into
InsertRunbut keeps it beforedb.Insert, then tries to populaterun_idonly whenrun.ID > 0— which is still0at that call site, so the cross-branch leak would survive that PR as written. This PR fixes the ordering so thatrun.IDis actually populated at eval time, and broadens it to cover parse-time expression interpolation too.This PR was written with the help of Claude Opus 4.7