enhance(actions): improve reusable workflow uses handling and cancellation (#37991)

Follow up #37478

## Changes

1. #37478 doesn't support absolute URL in `uses`. This PR provides
partial support for URL-style reusable workflow references. A reusable
workflow can now be referenced by an absolute URL, as long as it points
to the local Gitea instance:

```yaml
jobs:
  call:
    uses: https://your-gitea.example.com/OWNER/REPO/.gitea/workflows/ci.yaml@v1
```

2. Show an error message in the UI for invalid `uses`.

<img width="1600" alt="image"
src="https://github.com/user-attachments/assets/21b34e61-bf10-4af1-b9fd-4ee4e9fde049"
/>

3. Fix reusable caller cancellation issue. A reusable caller's status is
aggregated from its children, so cancellation should processes a
caller's descendants deepest-first.

---------

Signed-off-by: Zettat123 <zettat123@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: Giteabot <teabot@gitea.io>
This commit is contained in:
Zettat123
2026-06-08 00:39:06 -06:00
committed by GitHub
parent 1e9ea9c8f5
commit 60f66a9bfd
6 changed files with 179 additions and 24 deletions

View File

@@ -4,6 +4,7 @@
package actions
import (
"cmp"
"context"
"fmt"
"slices"
@@ -671,18 +672,18 @@ func cancelOneJob(ctx context.Context, job *ActionRunJob) (*ActionRunJob, error)
func cancelReusableCaller(ctx context.Context, caller *ActionRunJob) ([]*ActionRunJob, error) {
cancelledJobs := make([]*ActionRunJob, 0)
if c, err := cancelOneJob(ctx, caller); err != nil {
return cancelledJobs, err
} else if c != nil {
cancelledJobs = append(cancelledJobs, c)
}
attemptJobs, err := GetRunJobsByRunAndAttemptID(ctx, caller.RunID, caller.RunAttemptID)
if err != nil {
return cancelledJobs, err
}
for _, c := range CollectAllDescendantJobs(caller, attemptJobs) {
// Cancel descendants deepest-first, then the caller: a caller's status is aggregated from its children,
// so each child must reach its final state before its parent caller is re-aggregated.
// A child's ID always exceeds its parent's, so descending ID is a valid deepest-first order.
descendants := CollectAllDescendantJobs(caller, attemptJobs)
slices.SortFunc(descendants, func(a, b *ActionRunJob) int { return cmp.Compare(b.ID, a.ID) })
for _, c := range descendants {
cancelled, err := cancelOneJob(ctx, c)
if err != nil {
return cancelledJobs, err
@@ -691,5 +692,11 @@ func cancelReusableCaller(ctx context.Context, caller *ActionRunJob) ([]*ActionR
cancelledJobs = append(cancelledJobs, cancelled)
}
}
if c, err := cancelOneJob(ctx, caller); err != nil {
return cancelledJobs, err
} else if c != nil {
cancelledJobs = append(cancelledJobs, c)
}
return cancelledJobs, nil
}

View File

@@ -131,3 +131,69 @@ func TestGetPriorAttemptChildrenByParent(t *testing.T) {
assertAttempt1Children(t, out)
})
}
// A reusable caller subtree with a Blocked descendant (e.g. a nested caller stuck on an invalid `uses:`) must aggregate to Cancelled, when the run is cancelled.
func TestCancelJobs_NestedBlockedReusableCaller(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
run := &ActionRun{
Title: "cancel-nested-caller",
RepoID: 4,
Index: 9701,
OwnerID: 1,
WorkflowID: "caller.yaml",
TriggerUserID: 1,
Ref: "refs/heads/master",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Event: "push",
TriggerEvent: "push",
EventPayload: "{}",
Status: StatusBlocked,
}
require.NoError(t, db.Insert(ctx, run))
attempt := &ActionRunAttempt{RepoID: run.RepoID, RunID: run.ID, Attempt: 1, TriggerUserID: 1, Status: StatusBlocked}
require.NoError(t, db.Insert(ctx, attempt))
run.LatestAttemptID = attempt.ID
require.NoError(t, UpdateRun(ctx, run, "latest_attempt_id"))
newJob := func(name string, attemptJobID, parentID int64, callUses string) *ActionRunJob {
job := &ActionRunJob{
RunID: run.ID,
RunAttemptID: attempt.ID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
Name: name,
JobID: name,
Attempt: 1,
Status: StatusBlocked,
AttemptJobID: attemptJobID,
IsReusableCaller: true,
CallUses: callUses,
ParentJobID: parentID,
}
require.NoError(t, db.Insert(ctx, job))
return job
}
// outer: a valid top-level caller that expanded; inner: a nested caller stuck Blocked (invalid uses, never expands).
outer := newJob("outer", 1, 0, "./.gitea/workflows/lib.yml")
inner := newJob("inner", 2, outer.ID, "https://other.example.com/o/r/.gitea/workflows/ci.yml@v1")
// Cancel all jobs of the attempt, ordered by id (parent before child).
jobs, err := GetRunJobsByRunAndAttemptID(ctx, run.ID, attempt.ID)
require.NoError(t, err)
_, err = CancelJobs(ctx, jobs)
require.NoError(t, err)
for _, j := range []*ActionRunJob{outer, inner} {
got := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: j.ID})
assert.Equal(t, StatusCancelled, got.Status, "job %q should be cancelled", j.JobID)
}
gotAttempt := unittest.AssertExistsAndLoadBean(t, &ActionRunAttempt{ID: attempt.ID})
assert.Equal(t, StatusCancelled, gotAttempt.Status, "attempt must aggregate to Cancelled")
gotRun := unittest.AssertExistsAndLoadBean(t, &ActionRun{ID: run.ID})
assert.Equal(t, StatusCancelled, gotRun.Status, "run must aggregate to Cancelled, not stay Blocked")
}