mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-25 22:23:42 +09:00
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>
451 lines
14 KiB
Go
451 lines
14 KiB
Go
// Copyright 2023 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package actions
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"gitea.dev/modules/git"
|
|
"gitea.dev/modules/setting"
|
|
api "gitea.dev/modules/structs"
|
|
"gitea.dev/modules/test"
|
|
webhook_module "gitea.dev/modules/webhook"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func fullWorkflowContent(part string) []byte {
|
|
return []byte(`
|
|
name: test
|
|
` + part + `
|
|
jobs:
|
|
test:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- run: echo hello
|
|
`)
|
|
}
|
|
|
|
func TestReadWorkflowEventsStaticErrors(t *testing.T) {
|
|
for content, static := range map[string]bool{
|
|
"on: push\njobs: {}": true,
|
|
"on: push\njobs: {test: {needs: absent}}": true,
|
|
"on: push\njobs: {one: {needs: two}, two: {needs: one}}": true,
|
|
"on: push\njobs: {test: {strategy: {matrix: {os: []}}}}": true,
|
|
"on: push\nrun-name: ${{ secrets.TOKEN }}\njobs: {test: {}}": true,
|
|
"on: push\nrun-name: ${{ fromJSON(inputs.x) }}\njobs: {test: {steps: [{run: echo}]}}": false,
|
|
} {
|
|
_, gotStatic, err := readWorkflowEvents([]byte(content))
|
|
require.Error(t, err, content)
|
|
assert.Equal(t, static, gotStatic, content)
|
|
}
|
|
for _, content := range []string{"on: push\njobs: {test: {steps: [{run: echo}]}}", "on: push\nrun-name: ${{ github.ref }}\njobs: {test: {}}"} {
|
|
_, _, err := readWorkflowEvents([]byte(content))
|
|
assert.NoError(t, err, content)
|
|
}
|
|
}
|
|
|
|
func TestIsWorkflow(t *testing.T) {
|
|
defer test.MockVariableValue(&setting.Actions.WorkflowDirs)()
|
|
|
|
tests := []struct {
|
|
name string
|
|
dirs []string
|
|
path string
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "default with yml extension",
|
|
dirs: []string{".gitea/workflows", ".github/workflows"},
|
|
path: ".gitea/workflows/test.yml",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "default with yaml extension",
|
|
dirs: []string{".gitea/workflows", ".github/workflows"},
|
|
path: ".github/workflows/test.yaml",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "only gitea configured, github path rejected",
|
|
dirs: []string{".gitea/workflows"},
|
|
path: ".github/workflows/test.yml",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "only github configured, gitea path rejected",
|
|
dirs: []string{".github/workflows"},
|
|
path: ".gitea/workflows/test.yml",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "custom workflow dir",
|
|
dirs: []string{".custom/workflows"},
|
|
path: ".custom/workflows/deploy.yml",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "non-workflow file",
|
|
dirs: []string{".gitea/workflows", ".github/workflows"},
|
|
path: ".gitea/workflows/readme.md",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "directory boundary",
|
|
dirs: []string{".gitea/workflows"},
|
|
path: ".gitea/workflows2/test.yml",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "unrelated path",
|
|
dirs: []string{".gitea/workflows", ".github/workflows"},
|
|
path: "src/main.go",
|
|
expected: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
setting.Actions.WorkflowDirs = tt.dirs
|
|
assert.Equal(t, tt.expected, IsWorkflow(tt.path))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDetectMatched(t *testing.T) {
|
|
testCases := []struct {
|
|
desc string
|
|
commit *git.Commit
|
|
triggedEvent webhook_module.HookEventType
|
|
payload api.Payloader
|
|
yamlOn string
|
|
expected detectResult
|
|
}{
|
|
{
|
|
desc: "HookEventCreate(create) matches GithubEventCreate(create)",
|
|
triggedEvent: webhook_module.HookEventCreate,
|
|
payload: nil,
|
|
yamlOn: "on: create",
|
|
expected: detectMatched,
|
|
},
|
|
{
|
|
desc: "HookEventIssues(issues) `opened` action matches GithubEventIssues(issues)",
|
|
triggedEvent: webhook_module.HookEventIssues,
|
|
payload: &api.IssuePayload{Action: api.HookIssueOpened},
|
|
yamlOn: "on: issues",
|
|
expected: detectMatched,
|
|
},
|
|
{
|
|
desc: "HookEventIssues(issues) `milestoned` action matches GithubEventIssues(issues)",
|
|
triggedEvent: webhook_module.HookEventIssues,
|
|
payload: &api.IssuePayload{Action: api.HookIssueMilestoned},
|
|
yamlOn: "on: issues",
|
|
expected: detectMatched,
|
|
},
|
|
{
|
|
desc: "HookEventPullRequestSync(pull_request_sync) matches GithubEventPullRequest(pull_request)",
|
|
triggedEvent: webhook_module.HookEventPullRequestSync,
|
|
payload: &api.PullRequestPayload{Action: api.HookIssueSynchronized},
|
|
yamlOn: "on: pull_request",
|
|
expected: detectMatched,
|
|
},
|
|
{
|
|
desc: "HookEventPullRequest(pull_request) `label_updated` action doesn't match GithubEventPullRequest(pull_request) with no activity type",
|
|
triggedEvent: webhook_module.HookEventPullRequest,
|
|
payload: &api.PullRequestPayload{Action: api.HookIssueLabelUpdated},
|
|
yamlOn: "on: pull_request",
|
|
expected: detectNotApplicable,
|
|
},
|
|
{
|
|
desc: "HookEventPullRequest(pull_request) `closed` action doesn't match GithubEventPullRequest(pull_request) with no activity type",
|
|
triggedEvent: webhook_module.HookEventPullRequest,
|
|
payload: &api.PullRequestPayload{Action: api.HookIssueClosed},
|
|
yamlOn: "on: pull_request",
|
|
expected: detectNotApplicable,
|
|
},
|
|
{
|
|
desc: "HookEventPullRequest(pull_request) `closed` action doesn't match GithubEventPullRequest(pull_request) with branches",
|
|
triggedEvent: webhook_module.HookEventPullRequest,
|
|
payload: &api.PullRequestPayload{
|
|
Action: api.HookIssueClosed,
|
|
PullRequest: &api.PullRequest{
|
|
Base: &api.PRBranchInfo{},
|
|
},
|
|
},
|
|
yamlOn: "on:\n pull_request:\n branches: [main]",
|
|
expected: detectNotApplicable,
|
|
},
|
|
{
|
|
desc: "HookEventPullRequest(pull_request) `label_updated` action matches GithubEventPullRequest(pull_request) with `label` activity type",
|
|
triggedEvent: webhook_module.HookEventPullRequest,
|
|
payload: &api.PullRequestPayload{Action: api.HookIssueLabelUpdated},
|
|
yamlOn: "on:\n pull_request:\n types: [labeled]",
|
|
expected: detectMatched,
|
|
},
|
|
{
|
|
desc: "HookEventPullRequestReviewComment(pull_request_review_comment) matches GithubEventPullRequestReviewComment(pull_request_review_comment)",
|
|
triggedEvent: webhook_module.HookEventPullRequestReviewComment,
|
|
payload: &api.PullRequestPayload{Action: api.HookIssueReviewed},
|
|
yamlOn: "on:\n pull_request_review_comment:\n types: [created]",
|
|
expected: detectMatched,
|
|
},
|
|
{
|
|
desc: "HookEventPullRequestReviewRejected(pull_request_review_rejected) doesn't match GithubEventPullRequestReview(pull_request_review) with `dismissed` activity type (we don't support `dismissed` at present)",
|
|
triggedEvent: webhook_module.HookEventPullRequestReviewRejected,
|
|
payload: &api.PullRequestPayload{Action: api.HookIssueReviewed},
|
|
yamlOn: "on:\n pull_request_review:\n types: [dismissed]",
|
|
expected: detectNotApplicable,
|
|
},
|
|
{
|
|
desc: "HookEventRelease(release) `published` action matches GithubEventRelease(release) with `published` activity type",
|
|
triggedEvent: webhook_module.HookEventRelease,
|
|
payload: &api.ReleasePayload{Action: api.HookReleasePublished},
|
|
yamlOn: "on:\n release:\n types: [published]",
|
|
expected: detectMatched,
|
|
},
|
|
{
|
|
desc: "HookEventPackage(package) `created` action doesn't match GithubEventRegistryPackage(registry_package) with `updated` activity type",
|
|
triggedEvent: webhook_module.HookEventPackage,
|
|
payload: &api.PackagePayload{Action: api.HookPackageCreated},
|
|
yamlOn: "on:\n registry_package:\n types: [updated]",
|
|
expected: detectNotApplicable,
|
|
},
|
|
{
|
|
desc: "HookEventWiki(wiki) matches GithubEventGollum(gollum)",
|
|
triggedEvent: webhook_module.HookEventWiki,
|
|
payload: nil,
|
|
yamlOn: "on: gollum",
|
|
expected: detectMatched,
|
|
},
|
|
{
|
|
desc: "HookEventSchedule(schedule) matches GithubEventSchedule(schedule)",
|
|
triggedEvent: webhook_module.HookEventSchedule,
|
|
payload: nil,
|
|
yamlOn: "on: schedule",
|
|
expected: detectMatched,
|
|
},
|
|
{
|
|
desc: "push to tag matches workflow with paths condition (should skip paths check)",
|
|
triggedEvent: webhook_module.HookEventPush,
|
|
payload: &api.PushPayload{
|
|
Ref: "refs/tags/v1.0.0",
|
|
Before: "0000000",
|
|
Commits: []*api.PayloadCommit{
|
|
{
|
|
ID: "abcdef123456",
|
|
Added: []string{"src/main.go"},
|
|
Message: "Release v1.0.0",
|
|
},
|
|
},
|
|
},
|
|
commit: nil,
|
|
yamlOn: "on:\n push:\n paths:\n - src/**",
|
|
expected: detectMatched,
|
|
},
|
|
{
|
|
desc: "push branch filter excludes -> filtered out",
|
|
triggedEvent: webhook_module.HookEventPush,
|
|
payload: &api.PushPayload{
|
|
Ref: "refs/heads/feature/x",
|
|
Before: "0000000",
|
|
Commits: []*api.PayloadCommit{{ID: "abc", Added: []string{"a.go"}, Message: "x"}},
|
|
},
|
|
commit: nil,
|
|
yamlOn: "on:\n push:\n branches: [main]",
|
|
expected: detectFilteredOut,
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.desc, func(t *testing.T) {
|
|
evts, err := GetEventsFromContent(fullWorkflowContent(tc.yamlOn))
|
|
assert.NoError(t, err)
|
|
assert.Len(t, evts, 1)
|
|
assert.Equal(t, tc.expected, detectWorkflowMatch(t.Context(), nil, tc.commit, tc.triggedEvent, tc.payload, evts[0]))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMatchIssuesEvent(t *testing.T) {
|
|
testCases := []struct {
|
|
desc string
|
|
payload *api.IssuePayload
|
|
yamlOn string
|
|
expected bool
|
|
eventType string
|
|
}{
|
|
{
|
|
desc: "Label deletion should trigger unlabeled event",
|
|
payload: &api.IssuePayload{
|
|
Action: api.HookIssueLabelUpdated,
|
|
Issue: &api.Issue{
|
|
Labels: []*api.Label{},
|
|
},
|
|
Changes: &api.ChangesPayload{
|
|
RemovedLabels: []*api.Label{
|
|
{ID: 123, Name: "deleted-label"},
|
|
},
|
|
},
|
|
},
|
|
yamlOn: "on:\n issues:\n types: [unlabeled]",
|
|
expected: true,
|
|
eventType: "unlabeled",
|
|
},
|
|
{
|
|
desc: "Label deletion with existing labels should trigger unlabeled event",
|
|
payload: &api.IssuePayload{
|
|
Action: api.HookIssueLabelUpdated,
|
|
Issue: &api.Issue{
|
|
Labels: []*api.Label{
|
|
{ID: 456, Name: "existing-label"},
|
|
},
|
|
},
|
|
Changes: &api.ChangesPayload{
|
|
AddedLabels: nil,
|
|
RemovedLabels: []*api.Label{
|
|
{ID: 123, Name: "deleted-label"},
|
|
},
|
|
},
|
|
},
|
|
yamlOn: "on:\n issues:\n types: [unlabeled]",
|
|
expected: true,
|
|
eventType: "unlabeled",
|
|
},
|
|
{
|
|
desc: "Label addition should trigger labeled event",
|
|
payload: &api.IssuePayload{
|
|
Action: api.HookIssueLabelUpdated,
|
|
Issue: &api.Issue{
|
|
Labels: []*api.Label{
|
|
{ID: 123, Name: "new-label"},
|
|
},
|
|
},
|
|
Changes: &api.ChangesPayload{
|
|
AddedLabels: []*api.Label{
|
|
{ID: 123, Name: "new-label"},
|
|
},
|
|
RemovedLabels: []*api.Label{}, // Empty array, no labels removed
|
|
},
|
|
},
|
|
yamlOn: "on:\n issues:\n types: [labeled]",
|
|
expected: true,
|
|
eventType: "labeled",
|
|
},
|
|
{
|
|
desc: "Label clear should trigger unlabeled event",
|
|
payload: &api.IssuePayload{
|
|
Action: api.HookIssueLabelCleared,
|
|
Issue: &api.Issue{
|
|
Labels: []*api.Label{},
|
|
},
|
|
},
|
|
yamlOn: "on:\n issues:\n types: [unlabeled]",
|
|
expected: true,
|
|
eventType: "unlabeled",
|
|
},
|
|
{
|
|
desc: "Both adding and removing labels should trigger labeled event",
|
|
payload: &api.IssuePayload{
|
|
Action: api.HookIssueLabelUpdated,
|
|
Issue: &api.Issue{
|
|
Labels: []*api.Label{
|
|
{ID: 789, Name: "new-label"},
|
|
},
|
|
},
|
|
Changes: &api.ChangesPayload{
|
|
AddedLabels: []*api.Label{
|
|
{ID: 789, Name: "new-label"},
|
|
},
|
|
RemovedLabels: []*api.Label{
|
|
{ID: 123, Name: "deleted-label"},
|
|
},
|
|
},
|
|
},
|
|
yamlOn: "on:\n issues:\n types: [labeled]",
|
|
expected: true,
|
|
eventType: "labeled",
|
|
},
|
|
{
|
|
desc: "Both adding and removing labels should trigger unlabeled event",
|
|
payload: &api.IssuePayload{
|
|
Action: api.HookIssueLabelUpdated,
|
|
Issue: &api.Issue{
|
|
Labels: []*api.Label{
|
|
{ID: 789, Name: "new-label"},
|
|
},
|
|
},
|
|
Changes: &api.ChangesPayload{
|
|
AddedLabels: []*api.Label{
|
|
{ID: 789, Name: "new-label"},
|
|
},
|
|
RemovedLabels: []*api.Label{
|
|
{ID: 123, Name: "deleted-label"},
|
|
},
|
|
},
|
|
},
|
|
yamlOn: "on:\n issues:\n types: [unlabeled]",
|
|
expected: true,
|
|
eventType: "unlabeled",
|
|
},
|
|
{
|
|
desc: "Both adding and removing labels should trigger both events",
|
|
payload: &api.IssuePayload{
|
|
Action: api.HookIssueLabelUpdated,
|
|
Issue: &api.Issue{
|
|
Labels: []*api.Label{
|
|
{ID: 789, Name: "new-label"},
|
|
},
|
|
},
|
|
Changes: &api.ChangesPayload{
|
|
AddedLabels: []*api.Label{
|
|
{ID: 789, Name: "new-label"},
|
|
},
|
|
RemovedLabels: []*api.Label{
|
|
{ID: 123, Name: "deleted-label"},
|
|
},
|
|
},
|
|
},
|
|
yamlOn: "on:\n issues:\n types: [labeled, unlabeled]",
|
|
expected: true,
|
|
eventType: "multiple",
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.desc, func(t *testing.T) {
|
|
evts, err := GetEventsFromContent(fullWorkflowContent(tc.yamlOn))
|
|
assert.NoError(t, err)
|
|
assert.Len(t, evts, 1)
|
|
|
|
// Test if the event matches as expected
|
|
assert.Equal(t, tc.expected, matchIssuesEvent(tc.payload, evts[0]))
|
|
|
|
// For extra validation, check that action mapping works correctly
|
|
if tc.eventType == "multiple" {
|
|
// Skip direct action mapping validation for multiple events case
|
|
// as one action can map to multiple event types
|
|
return
|
|
}
|
|
|
|
// Determine expected action for single event case
|
|
var expectedAction string
|
|
switch tc.payload.Action {
|
|
case api.HookIssueLabelUpdated:
|
|
if tc.eventType == "labeled" {
|
|
expectedAction = "labeled"
|
|
} else if tc.eventType == "unlabeled" {
|
|
expectedAction = "unlabeled"
|
|
}
|
|
case api.HookIssueLabelCleared:
|
|
expectedAction = "unlabeled"
|
|
default:
|
|
expectedAction = string(tc.payload.Action)
|
|
}
|
|
|
|
assert.Equal(t, expectedAction, tc.eventType, "Event type should match expected")
|
|
})
|
|
}
|
|
}
|