gitforge/tests/integration/actions_job_test.go
Andreas Ahlenstorf 82c46d8199 fix: add forgejo_runtime_token to actions context (#13424)
Forgejo only added the legacy `gitea_runtime_token`.

## Checklist

The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. All work and communication must conform to Forgejo's [AI Agreement](https://codeberg.org/forgejo/governance/src/branch/main/AIAgreement.md). There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org).

### Tests for Go changes

(can be removed for JavaScript changes)

- I added test coverage for Go changes...
  - [x] in their respective `*_test.go` for unit tests.
  - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I ran...
  - [x] `make pr-go` before pushing

### Tests for JavaScript changes

(can be removed for Go changes)

- I added test coverage for JavaScript changes...
  - [ ] in `web_src/js/*.test.js` if it can be unit tested.
  - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)).

### Documentation

- [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change.
- [ ] I did not document these changes and I do not expect someone else to do it.

### Release notes

- [ ] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change.
- [x] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change.

*The decision if the pull request will be shown in the release notes is up to the mergers / release team.*

The content of the `release-notes/<pull request number>.md` file will serve as the basis for the release notes. If the file does not exist, the title of the pull request will be used instead.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/13424
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org>
2026-07-15 03:12:47 +02:00

1739 lines
60 KiB
Go

// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"context"
"encoding/base64"
"fmt"
"net/http"
"net/url"
"reflect"
"slices"
"strconv"
"testing"
"time"
actions_model "forgejo.org/models/actions"
auth_model "forgejo.org/models/auth"
repo_model "forgejo.org/models/repo"
"forgejo.org/models/unittest"
user_model "forgejo.org/models/user"
"forgejo.org/modules/git"
"forgejo.org/modules/json"
"forgejo.org/modules/setting"
api "forgejo.org/modules/structs"
"forgejo.org/modules/webhook"
actions_service "forgejo.org/services/actions"
notify_service "forgejo.org/services/notify"
"forgejo.org/tests"
runnerv1 "code.forgejo.org/forgejo/actions-proto/runner/v1"
"connectrpc.com/connect"
gouuid "github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func TestActionsJobWithNeeds(t *testing.T) {
if !setting.Database.Type.IsSQLite3() {
t.Skip()
}
testCases := []struct {
treePath string
fileContent string
outcomes map[string]*mockTaskOutcome
expectedStatuses map[string]string
}{
{
treePath: ".gitea/workflows/job-with-needs.yml",
fileContent: `name: job-with-needs
on:
push:
paths:
- '.gitea/workflows/job-with-needs.yml'
jobs:
job1:
runs-on: ubuntu-latest
steps:
- run: echo job1
job2:
runs-on: ubuntu-latest
needs: [job1]
steps:
- run: echo job2
`,
outcomes: map[string]*mockTaskOutcome{
"job1": {
result: runnerv1.Result_RESULT_SUCCESS,
},
"job2": {
result: runnerv1.Result_RESULT_SUCCESS,
},
},
expectedStatuses: map[string]string{
"job1": actions_model.StatusSuccess.String(),
"job2": actions_model.StatusSuccess.String(),
},
},
{
treePath: ".gitea/workflows/job-with-needs-fail.yml",
fileContent: `name: job-with-needs-fail
on:
push:
paths:
- '.gitea/workflows/job-with-needs-fail.yml'
jobs:
job1:
runs-on: ubuntu-latest
steps:
- run: echo job1
job2:
runs-on: ubuntu-latest
needs: [job1]
steps:
- run: echo job2
`,
outcomes: map[string]*mockTaskOutcome{
"job1": {
result: runnerv1.Result_RESULT_FAILURE,
},
},
expectedStatuses: map[string]string{
"job1": actions_model.StatusFailure.String(),
"job2": actions_model.StatusSkipped.String(),
},
},
{
treePath: ".gitea/workflows/job-with-needs-fail-if.yml",
fileContent: `name: job-with-needs-fail-if
on:
push:
paths:
- '.gitea/workflows/job-with-needs-fail-if.yml'
jobs:
job1:
runs-on: ubuntu-latest
steps:
- run: echo job1
job2:
runs-on: ubuntu-latest
if: ${{ always() }}
needs: [job1]
steps:
- run: echo job2
`,
outcomes: map[string]*mockTaskOutcome{
"job1": {
result: runnerv1.Result_RESULT_FAILURE,
},
"job2": {
result: runnerv1.Result_RESULT_SUCCESS,
},
},
expectedStatuses: map[string]string{
"job1": actions_model.StatusFailure.String(),
"job2": actions_model.StatusSuccess.String(),
},
},
}
onApplicationRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user2.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
apiRepo := createActionsTestRepo(t, token, "actions-jobs-with-needs", false)
runner := newMockRunner()
runner.registerAsRepoRunner(t, user2.Name, apiRepo.Name, "mock-runner", []string{"ubuntu-latest"})
for _, tc := range testCases {
t.Run(fmt.Sprintf("test %s", tc.treePath), func(t *testing.T) {
// create the workflow file
opts := getWorkflowCreateFileOptions(user2, apiRepo.DefaultBranch, fmt.Sprintf("create %s", tc.treePath), tc.fileContent)
fileResp := createWorkflowFile(t, token, user2.Name, apiRepo.Name, tc.treePath, opts)
// fetch and execute task
for i := 0; i < len(tc.outcomes); i++ {
task := runner.fetchTask(t)
jobName := getTaskJobNameByTaskID(t, token, user2.Name, apiRepo.Name, task.Id)
outcome := tc.outcomes[jobName]
assert.NotNil(t, outcome)
runner.execTask(t, task, outcome)
}
// check result
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/tasks", user2.Name, apiRepo.Name)).
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var actionTaskRespAfter api.ActionTaskResponse
DecodeJSON(t, resp, &actionTaskRespAfter)
for _, apiTask := range actionTaskRespAfter.Entries {
if apiTask.HeadSHA != fileResp.Commit.SHA {
continue
}
status := apiTask.Status
assert.Equal(t, status, tc.expectedStatuses[apiTask.Name])
}
})
}
httpContext := NewAPITestContext(t, user2.Name, apiRepo.Name, auth_model.AccessTokenScopeWriteUser)
doAPIDeleteRepository(httpContext)(t)
})
}
func TestActionsJobNeedsMatrix(t *testing.T) {
if !setting.Database.Type.IsSQLite3() {
t.Skip()
}
testCases := []struct {
treePath string
fileContent string
outcomes map[string]*mockTaskOutcome
expectedTaskNeeds map[string]*runnerv1.TaskNeed // jobID => TaskNeed
}{
{
treePath: ".gitea/workflows/jobs-outputs-with-matrix.yml",
fileContent: `name: jobs-outputs-with-matrix
on:
push:
paths:
- '.gitea/workflows/jobs-outputs-with-matrix.yml'
jobs:
job1:
runs-on: ubuntu-latest
outputs:
output_1: ${{ steps.gen_output.outputs.output_1 }}
output_2: ${{ steps.gen_output.outputs.output_2 }}
output_3: ${{ steps.gen_output.outputs.output_3 }}
strategy:
matrix:
version: [1, 2, 3]
steps:
- name: Generate output
id: gen_output
run: |
version="${{ matrix.version }}"
echo "output_${version}=${version}" >> "$GITHUB_OUTPUT"
job2:
runs-on: ubuntu-latest
needs: [job1]
steps:
- run: echo '${{ toJSON(needs.job1.outputs) }}'
`,
outcomes: map[string]*mockTaskOutcome{
"job1 (1)": {
result: runnerv1.Result_RESULT_SUCCESS,
outputs: map[string]string{
"output_1": "1",
"output_2": "",
"output_3": "",
},
},
"job1 (2)": {
result: runnerv1.Result_RESULT_SUCCESS,
outputs: map[string]string{
"output_1": "",
"output_2": "2",
"output_3": "",
},
},
"job1 (3)": {
result: runnerv1.Result_RESULT_SUCCESS,
outputs: map[string]string{
"output_1": "",
"output_2": "",
"output_3": "3",
},
},
},
expectedTaskNeeds: map[string]*runnerv1.TaskNeed{
"job1": {
Result: runnerv1.Result_RESULT_SUCCESS,
Outputs: map[string]string{
"output_1": "1",
"output_2": "2",
"output_3": "3",
},
},
},
},
{
treePath: ".gitea/workflows/jobs-outputs-with-matrix-failure.yml",
fileContent: `name: jobs-outputs-with-matrix-failure
on:
push:
paths:
- '.gitea/workflows/jobs-outputs-with-matrix-failure.yml'
jobs:
job1:
runs-on: ubuntu-latest
outputs:
output_1: ${{ steps.gen_output.outputs.output_1 }}
output_2: ${{ steps.gen_output.outputs.output_2 }}
output_3: ${{ steps.gen_output.outputs.output_3 }}
strategy:
matrix:
version: [1, 2, 3]
steps:
- name: Generate output
id: gen_output
run: |
version="${{ matrix.version }}"
echo "output_${version}=${version}" >> "$GITHUB_OUTPUT"
job2:
runs-on: ubuntu-latest
if: ${{ always() }}
needs: [job1]
steps:
- run: echo '${{ toJSON(needs.job1.outputs) }}'
`,
outcomes: map[string]*mockTaskOutcome{
"job1 (1)": {
result: runnerv1.Result_RESULT_SUCCESS,
outputs: map[string]string{
"output_1": "1",
"output_2": "",
"output_3": "",
},
},
"job1 (2)": {
result: runnerv1.Result_RESULT_FAILURE,
outputs: map[string]string{
"output_1": "",
"output_2": "",
"output_3": "",
},
},
"job1 (3)": {
result: runnerv1.Result_RESULT_SUCCESS,
outputs: map[string]string{
"output_1": "",
"output_2": "",
"output_3": "3",
},
},
},
expectedTaskNeeds: map[string]*runnerv1.TaskNeed{
"job1": {
Result: runnerv1.Result_RESULT_FAILURE,
Outputs: map[string]string{
"output_1": "1",
"output_2": "",
"output_3": "3",
},
},
},
},
}
onApplicationRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user2.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
apiRepo := createActionsTestRepo(t, token, "actions-jobs-outputs-with-matrix", false)
runner := newMockRunner()
runner.registerAsRepoRunner(t, user2.Name, apiRepo.Name, "mock-runner", []string{"ubuntu-latest"})
for _, tc := range testCases {
t.Run(fmt.Sprintf("test %s", tc.treePath), func(t *testing.T) {
opts := getWorkflowCreateFileOptions(user2, apiRepo.DefaultBranch, fmt.Sprintf("create %s", tc.treePath), tc.fileContent)
createWorkflowFile(t, token, user2.Name, apiRepo.Name, tc.treePath, opts)
for i := 0; i < len(tc.outcomes); i++ {
task := runner.fetchTask(t)
jobName := getTaskJobNameByTaskID(t, token, user2.Name, apiRepo.Name, task.Id)
outcome := tc.outcomes[jobName]
assert.NotNil(t, outcome)
runner.execTask(t, task, outcome)
}
task := runner.fetchTask(t)
actualTaskNeeds := task.Needs
assert.Len(t, actualTaskNeeds, len(tc.expectedTaskNeeds))
for jobID, tn := range tc.expectedTaskNeeds {
actualNeed := actualTaskNeeds[jobID]
assert.Equal(t, tn.Result, actualNeed.Result)
assert.Len(t, actualNeed.Outputs, len(tn.Outputs))
for outputKey, outputValue := range tn.Outputs {
assert.Equal(t, outputValue, actualNeed.Outputs[outputKey])
}
}
})
}
httpContext := NewAPITestContext(t, user2.Name, apiRepo.Name, auth_model.AccessTokenScopeWriteUser)
doAPIDeleteRepository(httpContext)(t)
})
}
func TestActionsGiteaContext(t *testing.T) {
if !setting.Database.Type.IsSQLite3() {
t.Skip()
}
testCases := []struct {
name string
treePath string
fileContent string
enableOpenIDConnect bool
}{
{
name: "openid_connect_disabled",
treePath: ".gitea/workflows/pull.yml",
fileContent: `name: Pull Request
on: pull_request
jobs:
wf1-job:
runs-on: ubuntu-latest
steps:
- run: echo 'test the pull'
`,
enableOpenIDConnect: false,
},
{
name: "openid_connect_enabled",
treePath: ".gitea/workflows/pull-enabled.yml",
fileContent: `name: Pull Request
on: pull_request
jobs:
wf1-job:
enable-openid-connect: true
runs-on: ubuntu-latest
steps:
- run: echo 'test the pull'
`,
enableOpenIDConnect: true,
},
}
onApplicationRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
user2Session := loginUser(t, user2.Name)
user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
apiBaseRepo := createActionsTestRepo(t, user2Token, "actions-gitea-context", false)
baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiBaseRepo.ID})
user2APICtx := NewAPITestContext(t, baseRepo.OwnerName, baseRepo.Name, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
runner := newMockRunner()
runner.registerAsRepoRunner(t, baseRepo.OwnerName, baseRepo.Name, "mock-runner", []string{"ubuntu-latest"})
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
opts := getWorkflowCreateFileOptions(user2, baseRepo.DefaultBranch, fmt.Sprintf("create %s", tc.treePath), tc.fileContent)
createWorkflowFile(t, user2Token, baseRepo.OwnerName, baseRepo.Name, tc.treePath, opts)
// user2 creates a pull request
doAPICreateFile(user2APICtx, "user2-patch.txt", &api.CreateFileOptions{
FileOptions: api.FileOptions{
NewBranchName: tc.name,
Message: "create user2-patch.txt",
Author: api.Identity{
Name: user2.Name,
Email: user2.Email,
},
Committer: api.Identity{
Name: user2.Name,
Email: user2.Email,
},
Dates: api.CommitDateOptions{
Author: time.Now(),
Committer: time.Now(),
},
},
ContentBase64: base64.StdEncoding.EncodeToString([]byte("user2-fix")),
})(t)
apiPull, err := doAPICreatePullRequest(user2APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, tc.name)(t)
require.NoError(t, err)
task := runner.fetchTask(t)
gtCtx := task.Context.GetFields()
actionTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.Id})
actionRunJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: actionTask.JobID})
actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: actionRunJob.RunID})
require.NoError(t, actionRun.LoadAttributes(t.Context()))
assert.Equal(t, user2.Name, gtCtx["actor"].GetStringValue())
assert.Equal(t, setting.AppURL+"api/v1", gtCtx["api_url"].GetStringValue())
assert.Equal(t, apiPull.Base.Ref, gtCtx["base_ref"].GetStringValue())
runEvent := map[string]any{}
require.NoError(t, json.Unmarshal([]byte(actionRun.EventPayload), &runEvent))
assert.True(t, reflect.DeepEqual(gtCtx["event"].GetStructValue().AsMap(), runEvent))
assert.Equal(t, actionRun.TriggerEvent, gtCtx["event_name"].GetStringValue())
assert.Equal(t, apiPull.Head.Ref, gtCtx["head_ref"].GetStringValue())
assert.Equal(t, actionRunJob.JobID, gtCtx["job"].GetStringValue())
assert.Equal(t, actionRun.Ref, gtCtx["ref"].GetStringValue())
assert.Equal(t, (git.RefName(actionRun.Ref)).ShortName(), gtCtx["ref_name"].GetStringValue())
assert.False(t, gtCtx["ref_protected"].GetBoolValue())
assert.Equal(t, (git.RefName(actionRun.Ref)).RefType(), gtCtx["ref_type"].GetStringValue())
assert.Equal(t, actionRun.Repo.OwnerName+"/"+actionRun.Repo.Name, gtCtx["repository"].GetStringValue())
assert.Equal(t, actionRun.Repo.OwnerName, gtCtx["repository_owner"].GetStringValue())
assert.Equal(t, actionRun.Repo.HTMLURL(), gtCtx["repositoryUrl"].GetStringValue())
assert.Equal(t, fmt.Sprint(actionRunJob.RunID), gtCtx["run_id"].GetStringValue())
assert.Equal(t, fmt.Sprint(actionRun.Index), gtCtx["run_number"].GetStringValue())
assert.Equal(t, fmt.Sprint(actionRunJob.Attempt), gtCtx["run_attempt"].GetStringValue())
assert.Equal(t, "Actions", gtCtx["secret_source"].GetStringValue())
assert.Equal(t, setting.AppURL, gtCtx["server_url"].GetStringValue())
assert.Equal(t, actionRun.CommitSHA, gtCtx["sha"].GetStringValue())
assert.Equal(t, actionRun.WorkflowID, gtCtx["workflow"].GetStringValue())
assert.Contains(t, gtCtx["workflow_ref"].GetStringValue(), fmt.Sprintf("user2/actions-gitea-context/%s@refs/pull", tc.treePath))
assert.Equal(t, setting.Actions.DefaultActionsURL.URL(), gtCtx["gitea_default_actions_url"].GetStringValue())
assert.Equal(t, setting.Actions.DefaultActionsURL.URL(), gtCtx["forgejo_default_actions_url"].GetStringValue())
assert.Equal(t, setting.AppVer, gtCtx["forgejo_server_version"].GetStringValue())
token := gtCtx["token"].GetStringValue()
assert.Equal(t, actionTask.TokenLastEight, token[len(token)-8:])
assert.NotEmpty(t, gtCtx["gitea_runtime_token"].GetStringValue())
assert.NotEmpty(t, gtCtx["forgejo_runtime_token"].GetStringValue())
if tc.enableOpenIDConnect {
assert.NotEmpty(t, gtCtx["forgejo_actions_id_token_request_token"].GetStringValue())
assert.Equal(t,
fmt.Sprintf("%sapi/actions/_apis/pipelines/workflows/%d/idtoken?placeholder=true",
setting.AppURL, actionRunJob.RunID), gtCtx["forgejo_actions_id_token_request_url"].GetStringValue(),
)
} else {
assert.Empty(t, gtCtx["forgejo_actions_id_token_request_token"].GetStringValue())
assert.Empty(t, gtCtx["forgejo_actions_id_token_request_url"].GetStringValue())
}
})
}
doAPIDeleteRepository(user2APICtx)(t)
})
}
func TestActionsRunsOnInputsWorkflowDispatch(t *testing.T) {
if !setting.Database.Type.IsSQLite3() {
t.Skip()
}
onApplicationRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user2.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
testRepository := createActionsTestRepo(t, token, "actions-runs-on-inputs-workflow-dispatch", false)
ubuntuRunner := newMockRunner()
ubuntuRunner.registerAsRepoRunner(t, user2.Name, testRepository.Name, "ubuntu-runner", []string{"ubuntu"})
windowsRunner := newMockRunner()
windowsRunner.registerAsRepoRunner(t, user2.Name, testRepository.Name, "windows-runner", []string{"windows"})
workflowPath := ".gitea/workflows/pull.yaml"
workflow := `name: Test runs-on with inputs
on:
workflow_dispatch:
inputs:
image:
required: true
type: string
jobs:
test:
runs-on: ${{ inputs.image }}
steps:
- run: echo "Running on ${{ inputs.image }}"
`
options := getWorkflowCreateFileOptions(user2, testRepository.DefaultBranch, fmt.Sprintf("create %s", workflowPath), workflow)
createWorkflowFile(t, token, user2.Name, testRepository.Name, workflowPath, options)
url := fmt.Sprintf("/%s/%s/actions/manual", user2.Name, testRepository.Name)
request := NewRequestWithValues(t, "POST", url, map[string]string{
"inputs[image]": "windows",
"ref": testRepository.DefaultBranch,
"workflow": "pull.yaml",
"actor": strconv.FormatInt(user2.ID, 10),
})
session.MakeRequest(t, request, http.StatusSeeOther)
assert.Nil(t, ubuntuRunner.maybeFetchTask(t))
task := windowsRunner.fetchTask(t)
actionTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.Id})
actionRunJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: actionTask.JobID})
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: actionRunJob.RunID})
assert.Equal(t, "Test runs-on with inputs", run.Title)
})
}
func TestActionsRunsOnVars(t *testing.T) {
if !setting.Database.Type.IsSQLite3() {
t.Skip()
}
onApplicationRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user2.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
testRepository := createActionsTestRepo(t, token, "actions-runs-on-vars", false)
ubuntuRunner := newMockRunner()
ubuntuRunner.registerAsRepoRunner(t, user2.Name, testRepository.Name, "ubuntu-runner", []string{"ubuntu"})
windowsRunner := newMockRunner()
windowsRunner.registerAsRepoRunner(t, user2.Name, testRepository.Name, "windows-runner", []string{"windows"})
workflowPath := ".gitea/workflows/pull.yaml"
workflow := `name: Test runs-on with vars
on:
workflow_dispatch:
jobs:
test:
runs-on: ${{ vars.runner }}
steps:
- run: echo "Running on ${{ vars.runner }}"
`
options := getWorkflowCreateFileOptions(user2, testRepository.DefaultBranch, fmt.Sprintf("create %s", workflowPath), workflow)
createWorkflowFile(t, token, user2.Name, testRepository.Name, workflowPath, options)
varCreationURL := fmt.Sprintf("/%s/%s/settings/actions/variables/new", user2.Name, testRepository.Name)
varCreationRequest := NewRequestWithValues(t, "POST", varCreationURL, map[string]string{
"name": "runner",
"data": "ubuntu",
})
session.MakeRequest(t, varCreationRequest, http.StatusOK)
dispatchURL := fmt.Sprintf("/%s/%s/actions/manual", user2.Name, testRepository.Name)
dispatchRequest := NewRequestWithValues(t, "POST", dispatchURL, map[string]string{
"ref": testRepository.DefaultBranch,
"workflow": "pull.yaml",
"actor": strconv.FormatInt(user2.ID, 10),
})
session.MakeRequest(t, dispatchRequest, http.StatusSeeOther)
assert.Nil(t, windowsRunner.maybeFetchTask(t))
task := ubuntuRunner.fetchTask(t)
actionTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.Id})
actionRunJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: actionTask.JobID})
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: actionRunJob.RunID})
assert.Equal(t, "Test runs-on with vars", run.Title)
})
}
func TestActionsEphemeral(t *testing.T) {
if !setting.Database.Type.IsSQLite3() {
t.Skip()
}
onApplicationRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
user2Session := loginUser(t, user2.Name)
user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
apiBaseRepo := createActionsTestRepo(t, user2Token, "actions-gitea-context", false)
baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiBaseRepo.ID})
user2APICtx := NewAPITestContext(t, baseRepo.OwnerName, baseRepo.Name, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
runner := newMockRunner()
runner.registerAsEphemeralRepoRunner(t, baseRepo.OwnerName, baseRepo.Name, "mock-runner", []string{"ubuntu-latest"})
// verify CleanupEphemeralRunners does not remove this runner
err := actions_service.CleanupEphemeralRunners(t.Context())
require.NoError(t, err)
// init the workflow
wfTreePath := ".gitea/workflows/pull.yml"
wfFileContent := `name: Pull Request
on: pull_request
jobs:
wf1-job:
runs-on: ubuntu-latest
steps:
- run: echo 'test the pull'
wf2-job:
runs-on: ubuntu-latest
steps:
- run: echo 'test the pull'
`
opts := getWorkflowCreateFileOptions(user2, baseRepo.DefaultBranch, fmt.Sprintf("create %s", wfTreePath), wfFileContent)
createWorkflowFile(t, user2Token, baseRepo.OwnerName, baseRepo.Name, wfTreePath, opts)
// user2 creates a pull request
doAPICreateFile(user2APICtx, "user2-patch.txt", &api.CreateFileOptions{
FileOptions: api.FileOptions{
NewBranchName: "user2/patch-1",
Message: "create user2-patch.txt",
Author: api.Identity{
Name: user2.Name,
Email: user2.Email,
},
Committer: api.Identity{
Name: user2.Name,
Email: user2.Email,
},
Dates: api.CommitDateOptions{
Author: time.Now(),
Committer: time.Now(),
},
},
ContentBase64: base64.StdEncoding.EncodeToString([]byte("user2-fix")),
})(t)
_, err = doAPICreatePullRequest(user2APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, "user2/patch-1")(t)
require.NoError(t, err)
task := runner.fetchTask(t)
actionTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.Id})
actionRunJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: actionTask.JobID})
actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: actionRunJob.RunID})
require.NoError(t, actionRun.LoadAttributes(t.Context()))
runEvent := map[string]any{}
require.NoError(t, json.Unmarshal([]byte(actionRun.EventPayload), &runEvent))
// verify CleanupEphemeralRunners does not remove this runner
err = actions_service.CleanupEphemeralRunners(t.Context())
require.NoError(t, err)
resp, err := runner.client.runnerServiceClient.FetchTask(t.Context(), connect.NewRequest(&runnerv1.FetchTaskRequest{
TasksVersion: 0,
}))
require.NoError(t, err)
assert.Nil(t, resp.Msg.Task)
// verify CleanupEphemeralRunners does not remove this runner
err = actions_service.CleanupEphemeralRunners(t.Context())
require.NoError(t, err)
runner.client.runnerServiceClient.UpdateTask(t.Context(), connect.NewRequest(&runnerv1.UpdateTaskRequest{
State: &runnerv1.TaskState{
Id: actionTask.ID,
Result: runnerv1.Result_RESULT_SUCCESS,
},
}))
resp, err = runner.client.runnerServiceClient.FetchTask(t.Context(), connect.NewRequest(&runnerv1.FetchTaskRequest{
TasksVersion: 0,
}))
require.Error(t, err)
assert.Nil(t, resp)
// create an runner that picks a job and get force cancelled
runnerToBeRemoved := newMockRunner()
runnerToBeRemoved.registerAsEphemeralRepoRunner(t, baseRepo.OwnerName, baseRepo.Name, "mock-runner-to-be-removed", []string{"ubuntu-latest"})
taskToStopAPIObj := runnerToBeRemoved.fetchTask(t)
taskToStop := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: taskToStopAPIObj.Id})
// verify CleanupEphemeralRunners does not remove the custom crafted runner
err = actions_service.CleanupEphemeralRunners(t.Context())
require.NoError(t, err)
runnerToRemove := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: taskToStop.RunnerID})
err = actions_service.StopTask(t.Context(), taskToStop.ID, actions_model.StatusFailure)
require.NoError(t, err)
// verify CleanupEphemeralRunners does remove the custom crafted runner
err = actions_service.CleanupEphemeralRunners(t.Context())
require.NoError(t, err)
unittest.AssertNotExistsBean(t, &actions_model.ActionRunner{ID: runnerToRemove.ID})
// this cleanup is required to allow further tests to pass
doAPIDeleteRepository(user2APICtx)(t)
})
}
func createActionsTestRepo(t *testing.T, authToken, repoName string, isPrivate bool) *api.Repository {
req := NewRequestWithJSON(t, "POST", "/api/v1/user/repos", &api.CreateRepoOption{
Name: repoName,
Private: isPrivate,
Readme: "Default",
AutoInit: true,
DefaultBranch: "main",
}).AddTokenAuth(authToken)
resp := MakeRequest(t, req, http.StatusCreated)
var apiRepo api.Repository
DecodeJSON(t, resp, &apiRepo)
return &apiRepo
}
func getWorkflowCreateFileOptions(u *user_model.User, branch, msg, content string) *api.CreateFileOptions {
return &api.CreateFileOptions{
FileOptions: api.FileOptions{
BranchName: branch,
Message: msg,
Author: api.Identity{
Name: u.Name,
Email: u.Email,
},
Committer: api.Identity{
Name: u.Name,
Email: u.Email,
},
Dates: api.CommitDateOptions{
Author: time.Now(),
Committer: time.Now(),
},
},
ContentBase64: base64.StdEncoding.EncodeToString([]byte(content)),
}
}
func createWorkflowFile(t *testing.T, authToken, ownerName, repoName, treePath string, opts *api.CreateFileOptions) *api.FileResponse {
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", ownerName, repoName, treePath), opts).
AddTokenAuth(authToken)
resp := MakeRequest(t, req, http.StatusCreated)
var fileResponse api.FileResponse
DecodeJSON(t, resp, &fileResponse)
return &fileResponse
}
// getTaskJobNameByTaskID get the job name of the task by task ID
// there is currently not an API for querying a task by ID so we have to list all the tasks
func getTaskJobNameByTaskID(t *testing.T, authToken, ownerName, repoName string, taskID int64) string {
// FIXME: we may need to query several pages
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/tasks", ownerName, repoName)).
AddTokenAuth(authToken)
resp := MakeRequest(t, req, http.StatusOK)
var taskRespBefore api.ActionTaskResponse
DecodeJSON(t, resp, &taskRespBefore)
for _, apiTask := range taskRespBefore.Entries {
if apiTask.ID == taskID {
return apiTask.Name
}
}
return ""
}
func TestActionsRunsEvaluateIf(t *testing.T) {
if !setting.Database.Type.IsSQLite3() {
t.Skip()
}
onApplicationRun(t, func(t *testing.T, u *url.URL) {
t.Run("skip all jobs instantly", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := arif.dispatchSingleJob("${{ 'abc' == 'def' }}").ID
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
arif.assertNoRunnableJobs()
assert.Equal(t, actions_model.StatusSkipped, run.Status)
job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID})
assert.Equal(t, actions_model.StatusSkipped, job.Status)
})
t.Run("skip entire run fires notifier", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
notifier := notify_service.NewMockNotifier(t)
notifier.On("Run").Return()
notifier.On("PushCommits", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return()
notifier.On("ActionRunNowDone",
mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
mock.MatchedBy(func(run *actions_model.ActionRun) bool {
return run.Title == ".forgejo/workflows/serverside_if.yml" && run.Status == actions_model.StatusSkipped
}),
actions_model.StatusWaiting, // priorStatus
mock.MatchedBy(func(lastRun *actions_model.ActionRun) bool { return lastRun == nil })).
Return()
notify_service.RegisterNotifier(notifier)
defer notify_service.UnregisterNotifier(notifier)
arif.dispatchSingleJob("${{ 'abc' == 'def' }}")
arif.assertNoRunnableJobs()
})
t.Run("skip single job instantly", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
arif.dispatchMultipleJobs("${{ 'abc' == 'abc' }}", "${{ 'abc' == 'def' }}")
task := arif.mockRunTask()
arif.assertNoRunnableJobs() // just one runnable job
actionTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.Id})
actionRunJob1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: actionTask.JobID})
assert.Equal(t, "test-1", actionRunJob1.Name)
assert.Equal(t, actions_model.StatusSuccess, actionRunJob1.Status)
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: actionRunJob1.RunID})
assert.Equal(t, actions_model.StatusSuccess, run.Status)
actionRunJob2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-2"})
assert.Equal(t, actions_model.StatusSkipped, actionRunJob2.Status)
})
t.Run("if clause needs another job, then is skipped", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := arif.dispatchDependentJob().ID
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusWaiting, run.Status)
job1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-1"})
assert.Equal(t, actions_model.StatusWaiting, job1.Status)
job2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-2"})
assert.Equal(t, actions_model.StatusBlocked, job2.Status) // `if` clause contains `needs`, can't be evaluated at schedule-time, still gets blocked
arif.mockRunTask()
job1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-1"})
assert.Equal(t, actions_model.StatusSuccess, job1.Status)
job2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-2"})
assert.Equal(t, actions_model.StatusSkipped, job2.Status) // `if` clause contains `needs`, `if` is false, it is skipped
arif.assertNoRunnableJobs()
})
t.Run("if clause needs another job, then is run", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := arif.dispatchDependentJob().ID
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusWaiting, run.Status)
job1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-1"})
assert.Equal(t, actions_model.StatusWaiting, job1.Status)
job2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-2"})
assert.Equal(t, actions_model.StatusBlocked, job2.Status) // `if` clause contains `needs`, can't be evaluated at schedule-time, still gets blocked
arif.mockRunTaskAndFail()
job1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-1"})
assert.Equal(t, actions_model.StatusFailure, job1.Status)
job2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-2"})
assert.Equal(t, actions_model.StatusWaiting, job2.Status) // `if` clause contains `needs`, 'if' is true, goes to waiting
arif.mockRunTask()
})
t.Run("if clause default / success() skips automatically when needed job fails", func(t *testing.T) {
test := func(t *testing.T, dispatch func(arif *ActionsRunIfTester) int64) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := dispatch(arif)
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusWaiting, run.Status)
job1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-1"})
assert.Equal(t, actions_model.StatusWaiting, job1.Status)
job2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-2"})
assert.Equal(t, actions_model.StatusBlocked, job2.Status) // job is blocked by 'needs'
arif.mockRunTaskAndFail()
job1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-1"})
assert.Equal(t, actions_model.StatusFailure, job1.Status)
job2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-2"})
assert.Equal(t, actions_model.StatusSkipped, job2.Status) // `if` defaults to success(), now gets skipped due to failure
arif.assertNoRunnableJobs()
}
t.Run("default", func(t *testing.T) {
test(t, func(arif *ActionsRunIfTester) int64 {
return arif.dispatchDependentJobDefaultIf().ID
})
})
t.Run("explicit success()", func(t *testing.T) {
test(t, func(arif *ActionsRunIfTester) int64 {
return arif.dispatchDependentJobIfSuccess().ID
})
})
})
t.Run("if failure() on successful dependency is skipped", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := arif.dispatchIfFailure().ID
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusWaiting, run.Status)
job1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-1"})
assert.Equal(t, actions_model.StatusWaiting, job1.Status)
job2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-2"})
assert.Equal(t, actions_model.StatusBlocked, job2.Status) // job is blocked by 'needs'
arif.mockRunTask()
job1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-1"})
assert.Equal(t, actions_model.StatusSuccess, job1.Status)
job2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-2"})
assert.Equal(t, actions_model.StatusSkipped, job2.Status) // skipped because dependent job didn't fail
arif.assertNoRunnableJobs()
})
t.Run("if failure() on failed dependency is executed", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := arif.dispatchIfFailure().ID
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusWaiting, run.Status)
job1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-1"})
assert.Equal(t, actions_model.StatusWaiting, job1.Status)
job2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-2"})
assert.Equal(t, actions_model.StatusBlocked, job2.Status) // job is blocked by 'needs'
arif.mockRunTaskAndFail()
job1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-1"})
assert.Equal(t, actions_model.StatusFailure, job1.Status)
job2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-2"})
assert.Equal(t, actions_model.StatusWaiting, job2.Status) // waiting, dependent job failed
arif.mockRunTask()
job2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-2"})
assert.Equal(t, actions_model.StatusSuccess, job2.Status)
})
t.Run("if always()", func(t *testing.T) {
test := func(t *testing.T, runTask func(arif *ActionsRunIfTester)) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := arif.dispatchIfAlways().ID
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusWaiting, run.Status)
job1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-1"})
assert.Equal(t, actions_model.StatusWaiting, job1.Status)
job2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-2"})
assert.Equal(t, actions_model.StatusBlocked, job2.Status) // job is blocked by 'needs'
runTask(arif)
job2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-2"})
assert.Equal(t, actions_model.StatusWaiting, job2.Status) // job can now be run regardless of status of test-1
arif.mockRunTask()
}
t.Run("runs on success", func(t *testing.T) {
test(t, func(arif *ActionsRunIfTester) {
arif.mockRunTask()
})
})
t.Run("runs on failure", func(t *testing.T) {
test(t, func(arif *ActionsRunIfTester) {
arif.mockRunTaskAndFail()
})
})
})
t.Run("if references env", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := arif.dispatchSingleJob("${{ env.abc == 'def' }}").ID
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusWaiting, run.Status)
job1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test"})
assert.Equal(t, actions_model.StatusWaiting, job1.Status) // accessing env can't be evaluated server-side, so is set to waiting
})
t.Run("if references secrets", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := arif.dispatchSingleJob("${{ secrets.abc == 'def' }}").ID
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusWaiting, run.Status)
job1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test"})
assert.Equal(t, actions_model.StatusWaiting, job1.Status) // accessing secrets isn't evaluated server-side, so is set to waiting
})
t.Run("jobs that need other jobs have their if clauses evaluated if unblocked", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := arif.dispatchIfFalseChain().ID
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusSkipped, run.Status)
job1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-1"})
assert.Equal(t, actions_model.StatusSkipped, job1.Status)
job2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-2"})
assert.Equal(t, actions_model.StatusSkipped, job2.Status)
job3 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-3"})
assert.Equal(t, actions_model.StatusSkipped, job3.Status)
})
t.Run("access var during evaluation", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := arif.dispatchForgejoTesting().ID
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusWaiting.String(), run.Status.String())
// Theoretically these are the exact right order for these to be executed, but it's possible they get
// inserted into the database with identical creation times and therefore could have indeterminate sorting.
// So the test case here is order-flexible.
expectedJobs := []string{"backend-checks", "frontend-checks", "test-unit", "test-pgsql", "test-sqlite", "security-check", "semgrep"}
for len(expectedJobs) > 0 {
task := arif.mockRunTask()
dbTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.Id})
require.NoError(t, dbTask.LoadJob(t.Context()))
idx := slices.Index(expectedJobs, dbTask.Job.Name)
require.NotEqual(t, -1, idx, "could not find job %s in expectedJobs", dbTask.Job.Name)
expectedJobs = append(expectedJobs[:idx], expectedJobs[idx+1:]...)
}
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusSuccess.String(), run.Status.String())
})
t.Run("access input during evaluation", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := arif.dispatchInputConditional().ID
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusWaiting.String(), run.Status.String())
for _, expected := range []string{"test-1", "test-2"} {
task := arif.mockRunTask()
dbTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.Id})
require.NoError(t, dbTask.LoadJob(t.Context()))
assert.Equal(t, expected, dbTask.Job.Name)
}
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusSuccess.String(), run.Status.String())
})
t.Run("access forgejo context during workflow_dispatch evaluation", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := arif.dispatchForgejoConditional().ID
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusWaiting.String(), run.Status.String())
for _, expected := range []string{"test-1", "test-2"} {
task := arif.mockRunTask()
dbTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.Id})
require.NoError(t, dbTask.LoadJob(t.Context()))
assert.Equal(t, expected, dbTask.Job.Name)
}
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusSuccess.String(), run.Status.String())
})
t.Run("access forgejo context during scheduled evaluation", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := arif.scheduleForgejoConditional().ID
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusWaiting.String(), run.Status.String())
for _, expected := range []string{"test-1", "test-2"} {
task := arif.mockRunTask()
dbTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.Id})
require.NoError(t, dbTask.LoadJob(t.Context()))
assert.Equal(t, expected, dbTask.Job.Name)
}
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusSuccess.String(), run.Status.String())
})
t.Run("access forgejo context during event evaluation", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := arif.eventForgejoConditional().ID
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusWaiting.String(), run.Status.String())
for _, expected := range []string{"test-1", "test-2"} {
task := arif.mockRunTask()
dbTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.Id})
require.NoError(t, dbTask.LoadJob(t.Context()))
assert.Equal(t, expected, dbTask.Job.Name)
}
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusSuccess.String(), run.Status.String())
})
t.Run("matrix requires skipped job", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := arif.dispatchIncompleteMatrix().ID
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusSkipped.String(), run.Status.String())
})
t.Run("runs-on requires skipped job", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := arif.dispatchIncompleteRunsOn().ID
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusSkipped.String(), run.Status.String())
})
t.Run("with requires skipped job", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := arif.dispatchIncompleteWith().ID
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusSkipped.String(), run.Status.String())
})
// If a `strategy.matrix` is used to expand a dependant job into multiple jobs, and, that expansion also
// requires an input from another job, and, that other job has a server-side evaluated `if` that returns
// false...
t.Run("matrix expansion and incomplete job simultaneously job", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := arif.dispatchIncompleteRunsOnWithMatrix().ID
// Provide three values to the 'dim1' output which will become a matrix dimension.
task := arif.mockRunTaskWithOutputs(map[string]string{"dim1": "[\"abc\",\"def\",\"ghj\"]"})
dbTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.Id})
require.NoError(t, dbTask.LoadJob(t.Context()))
assert.Equal(t, "matrix-output", dbTask.Job.Name)
// runs-on-output will be server-side skipped, and dependent-job will have a cascade skip because the
// 'runs-on' value refers to a skipped job.
arif.assertNoRunnableJobs()
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusSuccess.String(), run.Status.String())
jobs, err := actions_model.GetRunJobsByRunID(t.Context(), run.ID)
require.NoError(t, err)
// Currently this behaviour doesn't expand the jobs, it goes straight to skipping them. That's reasonable
// behaviour at the moment. In the future, it would also be reasonable for it to expand-then-skip -- both
// choices have the same functional outputs. The expand-then-skip option may become the behaviour if we add
// `matrix` context access to the `if` block in the future, since the evaluation order would have to change
// a bit.
expected := []string{"matrix-output", "runs-on-output", "dependent-job (incomplete matrix)"}
for _, j := range jobs {
if assert.Contains(t, expected, j.Name) {
expected = slices.DeleteFunc(expected, func(s string) bool { return s == j.Name })
}
}
})
})
}
type ActionsRunIfTester struct {
t *testing.T
user *user_model.User
session *TestSession
apiToken string
apiRepo *api.Repository
runner *mockRunner
}
func newActionsRunIfTester(t *testing.T) *ActionsRunIfTester {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user2.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
testRepository := createActionsTestRepo(t, token, fmt.Sprintf("actions-runs-on-vars-%s", gouuid.New().String()), false)
MakeRequest(t,
NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/repos/%s/%s/actions/variables/ROLE", user2.Name, testRepository.Name),
api.CreateVariableOption{Value: "forgejo-coding"}).AddTokenAuth(token),
http.StatusNoContent)
runner := newMockRunner()
runner.registerAsRepoRunner(t, user2.Name, testRepository.Name, "ubuntu-runner", []string{"ubuntu"})
return &ActionsRunIfTester{
t: t,
user: user2,
session: session,
apiToken: token,
apiRepo: testRepository,
runner: runner,
}
}
func (tester *ActionsRunIfTester) addWorkflowFile(filename, workflow string) *api.FileResponse {
options := getWorkflowCreateFileOptions(tester.user, tester.apiRepo.DefaultBranch, fmt.Sprintf("create %s", filename), workflow)
return createWorkflowFile(tester.t, tester.apiToken, tester.user.Name, tester.apiRepo.Name, filename, options)
}
func (tester *ActionsRunIfTester) dispatch(workflow string) *api.DispatchWorkflowRun {
workflowPath := ".forgejo/workflows/serverside_if.yml"
tester.addWorkflowFile(workflowPath, workflow)
dispatchRequest := NewRequestWithJSON(tester.t, "POST",
fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/serverside_if.yml/dispatches", tester.user.Name, tester.apiRepo.Name),
&api.DispatchWorkflowOption{
Ref: tester.apiRepo.DefaultBranch,
ReturnRunInfo: true,
Inputs: map[string]string{
"input_1": "input_1 value",
},
}).
AddTokenAuth(tester.apiToken)
resp := MakeRequest(tester.t, dispatchRequest, http.StatusCreated)
run := &api.DispatchWorkflowRun{}
DecodeJSON(tester.t, resp, run)
return run
}
func (tester *ActionsRunIfTester) pushEvent(workflow string) *actions_model.ActionRun {
workflowPath := ".forgejo/workflows/serverside_if.yml"
resp := tester.addWorkflowFile(workflowPath, workflow)
return unittest.AssertExistsAndLoadBean(tester.t, &actions_model.ActionRun{CommitSHA: resp.Commit.SHA})
}
func (tester *ActionsRunIfTester) forceSchedule(workflow string) *actions_model.ActionRun {
workflowPath := ".forgejo/workflows/serverside_if.yml"
resp := tester.addWorkflowFile(workflowPath, workflow)
payload := &api.SchedulePayload{
Action: api.HookScheduleCreated,
}
p, err := json.Marshal(payload)
require.NoError(tester.t, err)
require.NoError(tester.t, actions_service.CreateScheduleTask(tester.t.Context(),
&actions_model.ActionSchedule{
RepoID: tester.apiRepo.ID,
OwnerID: tester.user.ID,
WorkflowID: "serverside_if.yml",
WorkflowDirectory: ".forgejo/workflows",
TriggerUserID: tester.user.ID,
Ref: "refs/heads/main",
CommitSHA: resp.Commit.SHA,
Event: webhook.HookEventSchedule,
EventPayload: string(p),
Content: []byte(workflow),
}))
return unittest.AssertExistsAndLoadBean(tester.t, &actions_model.ActionRun{CommitSHA: resp.Commit.SHA})
}
func (tester *ActionsRunIfTester) dispatchSingleJob(ifClause string) *api.DispatchWorkflowRun {
return tester.dispatch(fmt.Sprintf(`
on:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu
if: %s
steps:
- run: echo "Job contents go here."
`, ifClause))
}
func (tester *ActionsRunIfTester) dispatchMultipleJobs(ifClause1, ifClause2 string) *api.DispatchWorkflowRun {
return tester.dispatch(fmt.Sprintf(`
on:
workflow_dispatch:
jobs:
test-1:
runs-on: ubuntu
if: %s
steps:
- run: echo "Job contents go here."
test-2:
runs-on: ubuntu
if: %s
steps:
- run: echo "Job contents go here."
`, ifClause1, ifClause2))
}
func (tester *ActionsRunIfTester) dispatchDependentJob() *api.DispatchWorkflowRun {
return tester.dispatch(`
on:
workflow_dispatch:
jobs:
test-1:
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
test-2:
needs: [test-1]
runs-on: ubuntu
if: ${{ needs.test-1.result == 'failure' }}
steps:
- run: echo "Job contents go here."
`)
}
func (tester *ActionsRunIfTester) dispatchDependentJobDefaultIf() *api.DispatchWorkflowRun {
return tester.dispatch(`
on:
workflow_dispatch:
jobs:
test-1:
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
test-2:
needs: [test-1]
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
`)
}
func (tester *ActionsRunIfTester) dispatchDependentJobIfSuccess() *api.DispatchWorkflowRun {
return tester.dispatch(`
on:
workflow_dispatch:
jobs:
test-1:
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
test-2:
needs: [test-1]
if: success()
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
`)
}
func (tester *ActionsRunIfTester) dispatchIfFailure() *api.DispatchWorkflowRun {
return tester.dispatch(`
on:
workflow_dispatch:
jobs:
test-1:
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
test-2:
needs: [test-1]
if: failure()
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
`)
}
func (tester *ActionsRunIfTester) dispatchIfAlways() *api.DispatchWorkflowRun {
return tester.dispatch(`
on:
workflow_dispatch:
jobs:
test-1:
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
test-2:
needs: [test-1]
if: always()
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
`)
}
func (tester *ActionsRunIfTester) dispatchIfFalseChain() *api.DispatchWorkflowRun {
return tester.dispatch(`
on:
workflow_dispatch:
jobs:
test-1:
if: false
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
test-2:
needs: [test-1]
if: false
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
test-3:
needs: [test-2]
if: false
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
`)
}
func (tester *ActionsRunIfTester) dispatchForgejoTesting() *api.DispatchWorkflowRun {
// Trimmed down copy of `.forgejo/workflows/testing.yml` to create a more complex & realistic `needs` tree with `if`
// conditions on every job:
return tester.dispatch(`
name: testing
enable-email-notifications: true
on:
workflow_dispatch:
jobs:
backend-checks:
if: vars.ROLE == 'forgejo-coding' || vars.ROLE == 'forgejo-testing'
runs-on: ubuntu
steps:
- run: echo "backend-checks job"
frontend-checks:
if: vars.ROLE == 'forgejo-coding' || vars.ROLE == 'forgejo-testing'
runs-on: ubuntu
steps:
- run: echo "frontend-checks job"
test-unit:
if: vars.ROLE == 'forgejo-coding' || vars.ROLE == 'forgejo-testing'
runs-on: ubuntu
needs: [backend-checks, frontend-checks]
steps:
- run: echo "test-unit job"
test-pgsql:
if: vars.ROLE == 'forgejo-coding' || vars.ROLE == 'forgejo-testing'
runs-on: ubuntu
needs: [backend-checks, frontend-checks]
steps:
- run: echo "test-pgsql job"
test-sqlite:
if: vars.ROLE == 'forgejo-coding' || vars.ROLE == 'forgejo-testing'
runs-on: ubuntu
needs: [backend-checks, frontend-checks]
steps:
- run: echo "test-sqlite job"
security-check:
if: vars.ROLE == 'forgejo-coding' || vars.ROLE == 'forgejo-testing'
runs-on: ubuntu
needs:
- test-sqlite
- test-unit
steps:
- run: echo "security-check job"
semgrep:
if: vars.ROLE == 'forgejo-coding' || vars.ROLE == 'forgejo-testing'
runs-on: ubuntu
steps:
- run: echo "semgrep job"
`)
}
func (tester *ActionsRunIfTester) dispatchInputConditional() *api.DispatchWorkflowRun {
return tester.dispatch(`
on:
workflow_dispatch:
inputs:
input_1:
type: string
jobs:
test-1:
if: ${{ inputs.input_1 == 'input_1 value' }}
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
test-2:
needs: [test-1]
if: ${{ inputs.input_1 == 'input_1 value' }}
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
`)
}
func (tester *ActionsRunIfTester) dispatchForgejoConditional() *api.DispatchWorkflowRun {
return tester.dispatch(`
on:
workflow_dispatch:
jobs:
test-1:
if: ${{ forgejo.event_name == 'workflow_dispatch' }}
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
test-2:
needs: [test-1]
if: ${{ forgejo.event_name == 'workflow_dispatch' }}
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
`)
}
func (tester *ActionsRunIfTester) eventForgejoConditional() *actions_model.ActionRun {
return tester.pushEvent(`
on:
push:
jobs:
test-1:
if: ${{ forgejo.event_name == 'push' }}
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
test-2:
needs: [test-1]
if: ${{ forgejo.event_name == 'push' }}
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
`)
}
func (tester *ActionsRunIfTester) scheduleForgejoConditional() *actions_model.ActionRun {
return tester.forceSchedule(`
on:
schedule:
jobs:
test-1:
if: ${{ forgejo.repository_owner == 'user2' }}
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
test-2:
needs: [test-1]
if: ${{ forgejo.repository_owner == 'user2' }}
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
`)
}
func (tester *ActionsRunIfTester) dispatchIncompleteMatrix() *api.DispatchWorkflowRun {
return tester.dispatch(`
on:
workflow_dispatch:
jobs:
test-1:
if: ${{ '123' == 'abc' }}
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
test-2:
needs: [test-1]
strategy:
matrix:
dim1: ${{ needs.test-1.outputs.output-not-existing }}
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
`)
}
func (tester *ActionsRunIfTester) dispatchIncompleteRunsOn() *api.DispatchWorkflowRun {
return tester.dispatch(`
on:
workflow_dispatch:
jobs:
test-1:
if: ${{ '123' == 'abc' }}
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
test-2:
needs: [test-1]
runs-on: ${{ needs.test-1.outputs.output-not-existing }}
steps:
- run: echo "Job contents go here."
`)
}
func (tester *ActionsRunIfTester) dispatchIncompleteRunsOnWithMatrix() *api.DispatchWorkflowRun {
return tester.dispatch(`
on:
workflow_dispatch:
jobs:
# This job will provide an output...
matrix-output:
runs-on: ubuntu
steps:
- run: echo "Job contents go here." # (output would be here, but runner will be mocked)
# This job will be relied upon for an output, but will be skipped.
runs-on-output:
if: ${{ '123' == 'abc' }}
runs-on: ubuntu
steps:
- run: echo "Job contents go here." # (output would be here, but runner will be mocked)
dependent-job:
needs: [matrix-output, runs-on-output]
strategy:
matrix:
dim1: ${{ fromJSON(needs.matrix-output.outputs.dim1) }}
runs-on: ${{ needs.runs-on-output.outputs.output-not-existing }}
steps:
- run: echo "Job contents go here."
`)
}
func (tester *ActionsRunIfTester) dispatchIncompleteWith() *api.DispatchWorkflowRun {
tester.addWorkflowFile(".forgejo/workflows/reusable.yml", `
on:
workflow_call:
inputs:
my_input:
type: string
jobs:
inner-job:
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
`)
return tester.dispatch(`
on:
workflow_dispatch:
jobs:
test-1:
if: ${{ '123' == 'abc' }}
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
test-2:
needs: [test-1]
uses: ./.forgejo/workflows/reusable.yml
with:
my_input: ${{ needs.test-1.outputs.output-not-existing }}
`)
}
func (tester *ActionsRunIfTester) assertNoRunnableJobs() {
assert.Nil(tester.t, tester.runner.maybeFetchTask(tester.t))
}
func (tester *ActionsRunIfTester) mockRunTask() *runnerv1.Task {
return tester.mockRunTaskWithOutputs(nil)
}
func (tester *ActionsRunIfTester) mockRunTaskWithOutputs(outputs map[string]string) *runnerv1.Task {
task := tester.runner.fetchTask(tester.t)
tester.runner.execTask(tester.t, task, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
outputs: outputs,
})
return task
}
func (tester *ActionsRunIfTester) mockRunTaskAndFail() *runnerv1.Task {
task := tester.runner.fetchTask(tester.t)
tester.runner.execTask(tester.t, task, &mockTaskOutcome{
result: runnerv1.Result_RESULT_FAILURE,
})
return task
}