feat(actions): update actionslib, support self:, misc fixes (#39358)

Updates actionslib to https://gitea.com/gitea/actionslib/releases/tag/v1.2.1, moves workflow
parsing into it and aligns behaviour with GitHub.

1. `uses:` supports `self:` (Gitea-only feature) and `$/` paths.
1. `strategy`, `matrix`, `max-parallel` and `fail-fast` accept
expressions, including over `needs`. A job whose `name`, `runs-on` or
`continue-on-error` reads `needs` is resolved once they finish.
1. A job `if:` may only read `github`, `needs`, `vars` and `inputs` and
is decided before the matrix, as on github.com.
1. Matrix `fail-fast` cancels the other combinations, and `always()`
jobs keep running when a run is cancelled.
1. Invalid workflow files, including a malformed `on:` and unknown or
cyclic `needs`, show up on push as failed runs with the error.
1. A job whose `if:` or `concurrency:` fails to evaluate is skipped or
failed with the error, instead of staying blocked.
1. Reusable workflows: a missing and an unreadable repository fail
alike, public callers cannot use private workflows, nested jobs cannot
exceed the caller's token permissions.
1. Runner labels match case-insensitively, and `runs-on` accepts an
array from an expression.

Runner PR: https://gitea.com/gitea/runner/pulls/1247
Docs PR: https://gitea.com/gitea/docs/pulls/553
Fixes: https://github.com/go-gitea/gitea/issues/38990
Fixes: https://github.com/go-gitea/gitea/issues/39382
Fixes: https://github.com/go-gitea/gitea/issues/32364
Fixes: https://github.com/go-gitea/gitea/issues/36077
Fixes: https://github.com/go-gitea/gitea/issues/23277
Fixes: https://github.com/go-gitea/gitea/issues/29020
Co-authored-by: Claude (Opus 5) <noreply@anthropic.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
This commit is contained in:
silverwind
2026-09-25 00:06:42 +02:00
committed by GitHub
parent 64f31d9b70
commit f757631a47
59 changed files with 1509 additions and 2223 deletions
@@ -22,16 +22,6 @@ import (
"github.com/stretchr/testify/require"
)
// TestDynamicMatrixEvaluation covers a job whose matrix references ${{ needs.*.outputs.* }}: it is
// planned as a single placeholder and expanded once its dependency completes. `build` exercises the
// expansion, `report` that a downstream job sees the combinations' outputs, `gated` and `partial` the
// `if:` gates on either side of it, and `static` that a matrix expanded at plan time is left alone.
// `included` covers the placeholder shapes that only survive as long as nothing re-parses their
// payload: `include:` is still a scalar there, which act refuses to read at all.
// `partial` and `strict` gate on `matrix.*` from either direction: neither may be decided before the
// combinations exist, or the whole job is skipped instead of the combinations the gate excludes.
// A full rerun then re-derives the matrix from the new attempt's outputs instead of reusing the
// previous combinations, keeping the AttemptJobID of every combination that recurs.
func TestDynamicMatrixEvaluation(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
@@ -93,24 +83,6 @@ jobs:
value: ${{ fromJson(needs.generate.outputs.matrix) }}
steps:
- run: echo "${{ matrix.value }}"
partial:
needs: [generate]
if: ${{ matrix.value != 1 }}
runs-on: ubuntu-latest
strategy:
matrix:
value: ${{ fromJson(needs.generate.outputs.matrix) }}
steps:
- run: echo "${{ matrix.value }}"
strict:
needs: [generate]
if: matrix.value == 1
runs-on: ubuntu-latest
strategy:
matrix:
value: ${{ fromJson(needs.generate.outputs.matrix) }}
steps:
- run: echo "${{ matrix.value }}"
report:
needs: [build]
runs-on: ubuntu-latest
@@ -151,20 +123,12 @@ jobs:
return names
}
seen := execAttempt(t, 9)
seen := execAttempt(t, 7)
firstAttemptIDs := maps.Clone(attemptJobIDs)
// `gated` is decided before the matrix is touched, so it never expands and is skipped as one job;
// `partial (1)` and `strict (2)` are decided afterwards, each against its own combination.
assert.ElementsMatch(t, []string{
"build (1)", "build (2)", "included (x)", "included (y)",
"static (a)", "static (b)", "partial (2)", "strict (1)", "report",
"build (1)", "build (2)", "included (x)", "included (y)", "static (a)", "static (b)", "report",
}, seen)
for _, name := range []string{"partial (1)", "strict (2)"} {
skipped := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: name})
assert.Equal(t, actions_model.StatusSkipped, skipped.Status)
}
// A gate reading `matrix.*` must not be decided against the unexpanded placeholder.
unittest.AssertNotExistsBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "strict"})
assert.Equal(t, actions_model.StatusSkipped, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "gated"}).Status)
buildNeed, ok := reportTask.Needs["build"]
require.True(t, ok, "report must see the expanded combinations as its 'build' need")
@@ -186,10 +150,9 @@ jobs:
})
// The matrix now yields a third value, while `static` is cloned as-is rather than collapsed.
seenRerun := execAttempt(t, 11)
seenRerun := execAttempt(t, 8)
assert.ElementsMatch(t, []string{
"build (1)", "build (2)", "build (3)", "included (x)", "included (y)",
"static (a)", "static (b)", "partial (2)", "partial (3)", "strict (1)", "report",
"build (1)", "build (2)", "build (3)", "included (x)", "included (y)", "static (a)", "static (b)", "report",
}, seenRerun)
for name, firstID := range firstAttemptIDs {
assert.Equal(t, firstID, attemptJobIDs[name], "%s keeps its AttemptJobID across attempts", name)
@@ -0,0 +1,74 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"net/http"
"net/url"
"testing"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/commitstatus"
"gitea.dev/modules/json"
"gitea.dev/routers/web/repo/actions"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestActionsInvalidWorkflowPush(t *testing.T) {
onGiteaRun(t, func(t *testing.T, _ *url.URL) {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
for _, testCase := range []struct {
name string
content string
wantErrors []string
}{
{"expression", "on: push\nrun-name: '${{ github.ref'\njobs: {check: {if: unknown.x}}\n", []string{"Unrecognized named-value: &#39;unknown&#39;", "unclosed expression"}},
{"trigger", "on:\njobs: {check: {runs-on: ubuntu-latest, steps: [{run: echo hello}]}}\n", []string{"invalid event"}},
} {
t.Run(testCase.name, func(t *testing.T) {
repo := createActionsTestRepo(t, token, "invalid-workflow-"+testCase.name, false)
createWorkflowFile(t, token, user.Name, repo.Name, ".gitea/workflows/invalid.yml",
getWorkflowCreateFileOptions(user, repo.DefaultBranch, "invalid workflow", testCase.content))
runs, err := db.Find[actions_model.ActionRun](t.Context(), actions_model.FindRunOptions{RepoID: repo.ID})
require.NoError(t, err)
require.Len(t, runs, 1)
run := runs[0]
assert.Equal(t, actions_model.StatusFailure, run.Status)
statuses, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, run.CommitSHA, db.ListOptionsAll)
require.NoError(t, err)
require.Len(t, statuses, 1)
assert.Equal(t, commitstatus.CommitStatusFailure, statuses[0].State)
view := session.MakeRequest(t, NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d", user.Name, repo.Name, run.ID)), http.StatusOK)
var viewResponse actions.ViewResponse
require.NoError(t, json.Unmarshal(view.Body.Bytes(), &viewResponse))
assert.Empty(t, viewResponse.State.Run.Jobs)
require.Len(t, viewResponse.State.Run.JobSummaries, 1)
assert.Equal(t, "invalid.yml", viewResponse.State.Run.JobSummaries[0].JobName)
assert.Contains(t, string(viewResponse.State.Run.JobSummaries[0].SummaryHTML), "Invalid workflow file: invalid.yml")
actionsPage := session.MakeRequest(t, NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions", user.Name, repo.Name)), http.StatusOK)
filePage := session.MakeRequest(t, NewRequest(t, "GET", fmt.Sprintf("/%s/%s/src/branch/%s/.gitea/workflows/invalid.yml", user.Name, repo.Name, repo.DefaultBranch)), http.StatusOK)
for _, wantError := range testCase.wantErrors {
assert.Contains(t, string(viewResponse.State.Run.JobSummaries[0].SummaryHTML), wantError)
assert.Contains(t, actionsPage.Body.String(), wantError)
assert.Contains(t, filePage.Body.String(), wantError)
}
session.MakeRequest(t, NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", user.Name, repo.Name, run.ID)), http.StatusBadRequest)
})
}
})
}
+1
View File
@@ -260,6 +260,7 @@ jobs:
output_2: ${{ steps.gen_output.outputs.output_2 }}
output_3: ${{ steps.gen_output.outputs.output_3 }}
strategy:
fail-fast: false
matrix:
version: [1, 2, 3]
steps:
@@ -14,7 +14,9 @@ import (
runnerv1 "gitea.dev/actionslib/runner/v1"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
perm_model "gitea.dev/models/perm"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
actions_module "gitea.dev/modules/actions"
@@ -81,6 +83,8 @@ on:
jobs:
reusable1_job1:
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- run: echo 'reusable1_job1'
@@ -135,6 +139,8 @@ jobs:
caller_job2:
needs: [caller_job1]
permissions:
contents: read
uses: './.gitea/workflows/reusable1.yaml'
with:
str_input: 'from_caller_job2'
@@ -204,6 +210,8 @@ jobs:
_, r1Job1, _ := getTaskAndJobAndRunByTaskID(t, r1Job1Task.Id)
assert.Equal(t, "reusable1_job1", r1Job1.JobID)
assert.Equal(t, callerJob2ID, r1Job1.ParentJobID)
require.NotNil(t, r1Job1.TokenPermissions)
assert.Equal(t, perm_model.AccessModeRead, r1Job1.TokenPermissions.UnitAccessModes[unit.TypeCode])
payload := getWorkflowCallPayloadFromTask(t, r1Job1Task)
if assert.Len(t, payload.Inputs, 5) {
assert.Equal(t, "from_caller_job2", payload.Inputs["str_input"])
@@ -253,6 +261,8 @@ jobs:
r1Job3AttemptJobID = r1Job3.AttemptJobID
r2Job1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "reusable2_job1"})
assert.Equal(t, r1Job3ID, r2Job1.ParentJobID)
require.NotNil(t, r2Job1.TokenPermissions)
assert.Equal(t, perm_model.AccessModeRead, r2Job1.TokenPermissions.UnitAccessModes[unit.TypeCode])
r2Job1AttemptJobID = r2Job1.AttemptJobID
r2Job1Task := defaultRunner.fetchTask(t) // for reusable2_job1
@@ -575,7 +585,7 @@ jobs:
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
})
t.Run("Nested caller with missing callee fails instead of blocking", func(t *testing.T) {
t.Run("Nested caller with missing callee fails with the error as summary instead of blocking", func(t *testing.T) {
// When the expansion hits a terminal error (e.g. missing callee), the emitter must fail the caller and let the run finish as failed, not retry the expansion forever.
apiRepo := createActionsTestRepo(t, user2Token, "nested-caller-missing-callee", false)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
@@ -616,6 +626,9 @@ jobs:
assert.Equal(t, actions_model.StatusFailure, finalRun.Status)
runner.fetchNoTask(t) // no task scheduled for the failed caller; the run is not stuck
summary, err := actions_model.GetActionRunJobSummary(t.Context(), repo.ID, run.ID, badCaller.RunAttemptID, badCaller.ID, 0)
require.NoError(t, err)
assert.Contains(t, summary.Content, "does-not-exist.yml")
})
t.Run("Fork PR with secrets: inherit does not leak base repo secrets", func(t *testing.T) {