783 lines
33 KiB
Go
783 lines
33 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package integration
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"testing"
|
|
"time"
|
|
|
|
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
|
actions_model "gitea.dev/models/actions"
|
|
auth_model "gitea.dev/models/auth"
|
|
repo_model "gitea.dev/models/repo"
|
|
"gitea.dev/models/unittest"
|
|
user_model "gitea.dev/models/user"
|
|
"gitea.dev/modules/gitrepo"
|
|
"gitea.dev/modules/json"
|
|
api "gitea.dev/modules/structs"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestActionsReusableWorkflow(t *testing.T) {
|
|
onGiteaRun(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)
|
|
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
|
user4Session := loginUser(t, user4.Name)
|
|
user4Token := getTokenForLoggedInUser(t, user4Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
|
|
|
|
t.Run("Same-repo reusable workflow", func(t *testing.T) {
|
|
apiRepo := createActionsTestRepo(t, user2Token, "workflow-call-test", false)
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
|
|
|
|
defaultRunner := newMockRunner()
|
|
defaultRunner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-default-runner", []string{"ubuntu-latest"}, false)
|
|
customRunner := newMockRunner()
|
|
customRunner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-custom-runner", []string{"custom-os"}, false)
|
|
|
|
// add a variable for test
|
|
req := NewRequestWithJSON(t, "POST",
|
|
fmt.Sprintf("/api/v1/repos/%s/%s/actions/variables/myvar", repo.OwnerName, repo.Name), &api.CreateVariableOption{
|
|
Value: "abcdef",
|
|
}).
|
|
AddTokenAuth(user2Token)
|
|
MakeRequest(t, req, http.StatusCreated)
|
|
// add a secret for test
|
|
req = NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/actions/secrets/mysecret", repo.OwnerName, repo.Name), api.CreateOrUpdateSecretOption{
|
|
Data: "secRET-t0Ken",
|
|
}).AddTokenAuth(user2Token)
|
|
MakeRequest(t, req, http.StatusCreated)
|
|
|
|
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/reusable1.yaml",
|
|
`name: Reusable1
|
|
on:
|
|
workflow_call:
|
|
inputs:
|
|
str_input:
|
|
type: string
|
|
num_input:
|
|
type: number
|
|
bool_input:
|
|
type: boolean
|
|
parent_var:
|
|
type: string
|
|
needs_out:
|
|
type: string
|
|
secrets:
|
|
PARENT_TOKEN:
|
|
outputs:
|
|
r1_out:
|
|
value: ${{ jobs.reusable1_job2.outputs.r1j2_out }}
|
|
|
|
jobs:
|
|
reusable1_job1:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- run: echo 'reusable1_job1'
|
|
|
|
reusable1_job2:
|
|
needs: [reusable1_job1]
|
|
outputs:
|
|
r1j2_out: ${{ steps.gen_r1j2_output.outputs.out }}
|
|
runs-on: custom-os
|
|
steps:
|
|
- id: gen_r1j2_output
|
|
run: |
|
|
echo "out=r1j2_out_data" >> "$GITHUB_OUTPUT"
|
|
|
|
reusable1_job3:
|
|
needs: [reusable1_job2]
|
|
uses: ./.gitea/workflows/reusable2.yaml
|
|
with:
|
|
msg: ${{ inputs.str_input }}
|
|
`)
|
|
|
|
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/reusable2.yaml",
|
|
`name: Reusable2
|
|
on:
|
|
workflow_call:
|
|
inputs:
|
|
msg:
|
|
type: string
|
|
|
|
jobs:
|
|
reusable2_job1:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- run: echo ${{ inputs.msg }}
|
|
`)
|
|
|
|
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/caller.yaml",
|
|
`name: Caller
|
|
on:
|
|
push:
|
|
paths:
|
|
- '.gitea/workflows/caller.yaml'
|
|
jobs:
|
|
caller_job1:
|
|
runs-on: ubuntu-latest
|
|
outputs:
|
|
prepared: ${{ steps.gen_output.outputs.pd }}
|
|
steps:
|
|
- id: gen_output
|
|
run: |
|
|
echo "pd=prepared_data" >> "$GITHUB_OUTPUT"
|
|
|
|
caller_job2:
|
|
needs: [caller_job1]
|
|
uses: './.gitea/workflows/reusable1.yaml'
|
|
with:
|
|
str_input: 'from_caller_job2'
|
|
num_input: ${{ 2.3e2 }}
|
|
bool_input: ${{ gitea.event_name == 'push' }}
|
|
parent_var: ${{ vars.myvar }}
|
|
needs_out: ${{ needs.caller_job1.outputs.prepared }}
|
|
secrets:
|
|
PARENT_TOKEN: ${{ secrets.mysecret }}
|
|
|
|
caller_job3:
|
|
needs: [caller_job2]
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- run: |
|
|
echo ${{ needs.caller_job1.outputs.r1_out }}
|
|
`)
|
|
|
|
var (
|
|
runID int64
|
|
callerJob2ID, callerJob2AttemptJobID int64
|
|
callerJob3AttemptJobID int64
|
|
r1Job2ID, r1Job2AttemptJobID int64
|
|
r1Job3ID, r1Job3AttemptJobID int64
|
|
r2Job1AttemptJobID int64
|
|
)
|
|
|
|
t.Run("Check initialized jobs", func(t *testing.T) {
|
|
// run
|
|
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
|
|
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID})
|
|
runID = run.ID
|
|
|
|
// caller_job1
|
|
assert.Equal(t, 3, unittest.GetCount(t, &actions_model.ActionRunJob{RunID: runID}))
|
|
callerJob1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "caller_job1"})
|
|
assert.Equal(t, actions_model.StatusWaiting, callerJob1.Status)
|
|
assert.False(t, callerJob1.IsReusableCaller)
|
|
|
|
// caller_job2
|
|
callerJob2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "caller_job2"})
|
|
callerJob2ID = callerJob2.ID
|
|
callerJob2AttemptJobID = callerJob2.AttemptJobID
|
|
assert.Equal(t, actions_model.StatusBlocked, callerJob2.Status)
|
|
assert.True(t, callerJob2.IsReusableCaller)
|
|
|
|
// caller_job3
|
|
callerJob3 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "caller_job3"})
|
|
callerJob3AttemptJobID = callerJob3.AttemptJobID
|
|
assert.Equal(t, actions_model.StatusBlocked, callerJob3.Status)
|
|
assert.False(t, callerJob3.IsReusableCaller)
|
|
})
|
|
|
|
t.Run("First run", func(t *testing.T) {
|
|
callerJob1Task := defaultRunner.fetchTask(t) // for caller_job1
|
|
_, callerJob1, _ := getTaskAndJobAndRunByTaskID(t, callerJob1Task.Id)
|
|
assert.Equal(t, "caller_job1", callerJob1.JobID)
|
|
defaultRunner.fetchNoTask(t)
|
|
defaultRunner.execTask(t, callerJob1Task, &mockTaskOutcome{
|
|
result: runnerv1.Result_RESULT_SUCCESS,
|
|
outputs: map[string]string{
|
|
"prepared": "prepared_data",
|
|
},
|
|
})
|
|
|
|
r1Job1Task := defaultRunner.fetchTask(t) // for reusable1_job1
|
|
_, r1Job1, _ := getTaskAndJobAndRunByTaskID(t, r1Job1Task.Id)
|
|
assert.Equal(t, "reusable1_job1", r1Job1.JobID)
|
|
assert.Equal(t, callerJob2ID, r1Job1.ParentJobID)
|
|
payload := getWorkflowCallPayloadFromTask(t, r1Job1Task)
|
|
if assert.Len(t, payload.Inputs, 5) {
|
|
assert.Equal(t, "from_caller_job2", payload.Inputs["str_input"])
|
|
assert.EqualValues(t, 230, payload.Inputs["num_input"])
|
|
assert.Equal(t, true, payload.Inputs["bool_input"])
|
|
assert.Equal(t, "abcdef", payload.Inputs["parent_var"])
|
|
assert.Equal(t, "prepared_data", payload.Inputs["needs_out"])
|
|
}
|
|
if assert.Len(t, r1Job1Task.Secrets, 3) {
|
|
assert.Contains(t, r1Job1Task.Secrets, "GITEA_TOKEN")
|
|
assert.Contains(t, r1Job1Task.Secrets, "GITHUB_TOKEN")
|
|
assert.Equal(t, "secRET-t0Ken", r1Job1Task.Secrets["PARENT_TOKEN"])
|
|
}
|
|
customRunner.fetchNoTask(t)
|
|
defaultRunner.execTask(t, r1Job1Task, &mockTaskOutcome{
|
|
result: runnerv1.Result_RESULT_SUCCESS,
|
|
})
|
|
|
|
// reusable1_job3 (a nested caller) needs reusable1_job2, so it stays Blocked until r1j2 succeeds.
|
|
r1Job3Pre := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "reusable1_job3"})
|
|
assert.Equal(t, actions_model.StatusBlocked, r1Job3Pre.Status)
|
|
assert.False(t, r1Job3Pre.IsExpanded)
|
|
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRunJob{RunID: runID, JobID: "reusable2_job1"}))
|
|
|
|
r1Job2Task := customRunner.fetchTask(t) // for reusable1_job2
|
|
_, r1Job2, _ := getTaskAndJobAndRunByTaskID(t, r1Job2Task.Id)
|
|
assert.Equal(t, "reusable1_job2", r1Job2.JobID)
|
|
r1Job2ID = r1Job2.ID
|
|
r1Job2AttemptJobID = r1Job2.AttemptJobID
|
|
if assert.Len(t, r1Job2Task.Needs, 1) {
|
|
assert.Contains(t, r1Job2Task.Needs, "reusable1_job1")
|
|
assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, r1Job2Task.Needs["reusable1_job1"].Result)
|
|
}
|
|
customRunner.execTask(t, r1Job2Task, &mockTaskOutcome{
|
|
result: runnerv1.Result_RESULT_SUCCESS,
|
|
outputs: map[string]string{
|
|
"r1j2_out": "r1j2_out_data",
|
|
},
|
|
})
|
|
|
|
// Now reusable1_job3 expands and reusable2_job1 becomes runnable.
|
|
r1Job3 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "reusable1_job3"})
|
|
assert.True(t, r1Job3.IsReusableCaller)
|
|
assert.True(t, r1Job3.IsExpanded)
|
|
assert.Equal(t, callerJob2ID, r1Job3.ParentJobID)
|
|
r1Job3ID = r1Job3.ID
|
|
r1Job3AttemptJobID = r1Job3.AttemptJobID
|
|
r2Job1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "reusable2_job1"})
|
|
assert.Equal(t, r1Job3ID, r2Job1.ParentJobID)
|
|
r2Job1AttemptJobID = r2Job1.AttemptJobID
|
|
|
|
r2Job1Task := defaultRunner.fetchTask(t) // for reusable2_job1
|
|
_, fetchedR2Job1, _ := getTaskAndJobAndRunByTaskID(t, r2Job1Task.Id)
|
|
assert.Equal(t, "reusable2_job1", fetchedR2Job1.JobID)
|
|
assert.Equal(t, r1Job3ID, fetchedR2Job1.ParentJobID)
|
|
r2Job1Payload := getWorkflowCallPayloadFromTask(t, r2Job1Task)
|
|
if assert.Len(t, r2Job1Payload.Inputs, 1) {
|
|
assert.Equal(t, "from_caller_job2", r2Job1Payload.Inputs["msg"])
|
|
}
|
|
defaultRunner.execTask(t, r2Job1Task, &mockTaskOutcome{
|
|
result: runnerv1.Result_RESULT_SUCCESS,
|
|
})
|
|
|
|
callerJob2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: callerJob2ID})
|
|
assert.Equal(t, actions_model.StatusSuccess, callerJob2.Status)
|
|
|
|
callerJob3Task := defaultRunner.fetchTask(t) // for caller_job3
|
|
_, callerJob3, _ := getTaskAndJobAndRunByTaskID(t, callerJob3Task.Id)
|
|
assert.Equal(t, "caller_job3", callerJob3.JobID)
|
|
if assert.Len(t, callerJob3Task.Needs, 1) {
|
|
assert.Contains(t, callerJob3Task.Needs, "caller_job2")
|
|
assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, callerJob3Task.Needs["caller_job2"].Result)
|
|
if assert.Len(t, callerJob3Task.Needs["caller_job2"].Outputs, 1) {
|
|
assert.Equal(t, "r1j2_out_data", callerJob3Task.Needs["caller_job2"].Outputs["r1_out"])
|
|
}
|
|
}
|
|
defaultRunner.execTask(t, callerJob3Task, &mockTaskOutcome{
|
|
result: runnerv1.Result_RESULT_SUCCESS,
|
|
})
|
|
callerRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
|
|
assert.Equal(t, actions_model.StatusSuccess, callerRun.Status)
|
|
})
|
|
|
|
t.Run("Rerun 'reusable1_job2'", func(t *testing.T) {
|
|
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", repo.OwnerName, repo.Name, runID, r1Job2ID))
|
|
user2Session.MakeRequest(t, req, http.StatusOK)
|
|
|
|
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
|
|
assert.Equal(t, actions_model.StatusWaiting, run.Status)
|
|
attempt2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunAttempt{RunID: runID, Attempt: 2})
|
|
assert.Equal(t, actions_model.StatusWaiting, attempt2.Status)
|
|
callerJob2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, AttemptJobID: callerJob2AttemptJobID})
|
|
assert.Equal(t, actions_model.StatusWaiting, callerJob2.Status)
|
|
callerJob3 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, AttemptJobID: callerJob3AttemptJobID})
|
|
assert.Equal(t, actions_model.StatusBlocked, callerJob3.Status)
|
|
|
|
// reusable1_job3 needs reusable1_job2, so rerunning r1j2 pulls r1j3 (and its subtree) into the rerun set
|
|
r1Job3Attempt2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, AttemptJobID: r1Job3AttemptJobID})
|
|
assert.Equal(t, actions_model.StatusBlocked, r1Job3Attempt2.Status)
|
|
assert.True(t, r1Job3Attempt2.IsReusableCaller)
|
|
assert.False(t, r1Job3Attempt2.IsExpanded)
|
|
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, JobID: "reusable2_job1"}))
|
|
|
|
defaultRunner.fetchNoTask(t)
|
|
r1Job2Task := customRunner.fetchTask(t)
|
|
_, r1Job2, _ := getTaskAndJobAndRunByTaskID(t, r1Job2Task.Id)
|
|
assert.Equal(t, "reusable1_job2", r1Job2.JobID)
|
|
assert.Equal(t, callerJob2.ID, r1Job2.ParentJobID)
|
|
assert.Equal(t, r1Job2AttemptJobID, r1Job2.AttemptJobID)
|
|
assert.Equal(t, actions_model.StatusRunning, r1Job2.Status)
|
|
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
|
|
assert.Equal(t, actions_model.StatusRunning, run.Status)
|
|
customRunner.execTask(t, r1Job2Task, &mockTaskOutcome{
|
|
result: runnerv1.Result_RESULT_SUCCESS,
|
|
outputs: map[string]string{
|
|
"r1j2_out": "r1j2_out_data_updated",
|
|
},
|
|
})
|
|
|
|
// r1j3 expands again. Its child reuses the AttemptJobID from attempt 1
|
|
r1Job3Attempt2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, AttemptJobID: r1Job3AttemptJobID})
|
|
assert.True(t, r1Job3Attempt2.IsExpanded)
|
|
r2Job1Attempt2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, JobID: "reusable2_job1"})
|
|
assert.Equal(t, r2Job1AttemptJobID, r2Job1Attempt2.AttemptJobID)
|
|
assert.Equal(t, r1Job3Attempt2.ID, r2Job1Attempt2.ParentJobID)
|
|
|
|
r2Job1Task := defaultRunner.fetchTask(t)
|
|
_, fetchedR2Job1, _ := getTaskAndJobAndRunByTaskID(t, r2Job1Task.Id)
|
|
assert.Equal(t, "reusable2_job1", fetchedR2Job1.JobID)
|
|
defaultRunner.execTask(t, r2Job1Task, &mockTaskOutcome{
|
|
result: runnerv1.Result_RESULT_SUCCESS,
|
|
})
|
|
|
|
callerJob2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: callerJob2.ID})
|
|
assert.Equal(t, actions_model.StatusSuccess, callerJob2.Status)
|
|
|
|
callerJob3Task := defaultRunner.fetchTask(t)
|
|
_, callerJob3, _ = getTaskAndJobAndRunByTaskID(t, callerJob3Task.Id)
|
|
assert.Equal(t, "caller_job3", callerJob3.JobID)
|
|
if assert.Len(t, callerJob3Task.Needs, 1) {
|
|
assert.Contains(t, callerJob3Task.Needs, "caller_job2")
|
|
assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, callerJob3Task.Needs["caller_job2"].Result)
|
|
if assert.Len(t, callerJob3Task.Needs["caller_job2"].Outputs, 1) {
|
|
assert.Equal(t, "r1j2_out_data_updated", callerJob3Task.Needs["caller_job2"].Outputs["r1_out"])
|
|
}
|
|
}
|
|
defaultRunner.execTask(t, callerJob3Task, &mockTaskOutcome{
|
|
result: runnerv1.Result_RESULT_SUCCESS,
|
|
})
|
|
attempt2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunAttempt{RunID: runID, Attempt: 2})
|
|
assert.Equal(t, actions_model.StatusSuccess, attempt2.Status)
|
|
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
|
|
assert.Equal(t, actions_model.StatusSuccess, run.Status)
|
|
})
|
|
})
|
|
|
|
t.Run("Cross-repo reusable workflow with collaborative owner", func(t *testing.T) {
|
|
// libRepo: private, owned by user2.
|
|
libAPIRepo := createActionsTestRepo(t, user2Token, "reusable-lib-private", true)
|
|
libRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: libAPIRepo.ID})
|
|
createRepoWorkflowFile(t, user2, user2Token, libRepo, ".gitea/workflows/reusable_lib.yaml",
|
|
`name: ReusableLib
|
|
on:
|
|
workflow_call:
|
|
inputs:
|
|
from:
|
|
type: string
|
|
|
|
jobs:
|
|
lib_job:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- run: echo hello-${{ inputs.from }}
|
|
`)
|
|
|
|
// consumerRepo: private, owned by user4.
|
|
consumerAPIRepo := createActionsTestRepo(t, user4Token, "workflow-call-cross-repo", true)
|
|
consumerRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: consumerAPIRepo.ID})
|
|
|
|
runner := newMockRunner()
|
|
runner.registerAsRepoRunner(t, consumerRepo.OwnerName, consumerRepo.Name, "mock-cross-runner", []string{"ubuntu-latest"}, false)
|
|
|
|
createRepoWorkflowFile(t, user4, user4Token, consumerRepo, ".gitea/workflows/cross-caller.yaml",
|
|
`name: CrossCaller
|
|
on: push
|
|
jobs:
|
|
cross_job:
|
|
uses: user2/reusable-lib-private/.gitea/workflows/reusable_lib.yaml@main
|
|
with:
|
|
from: 'consumer'
|
|
`)
|
|
|
|
// Phase 1: no grant. The cross-repo read check fails, and NO ActionRun row gets persisted.
|
|
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumerRepo.ID}))
|
|
runner.fetchNoTask(t)
|
|
|
|
// Phase 2: user2 (libRepo owner) adds user4 (consumer owner) as a Collaborative Owner of libRepo.
|
|
addCollabReq := NewRequestWithValues(t, "POST",
|
|
fmt.Sprintf("/%s/%s/settings/actions/general/collaborative_owner/add", libRepo.OwnerName, libRepo.Name),
|
|
map[string]string{"collaborative_owner": user4.Name})
|
|
user2Session.MakeRequest(t, addCollabReq, http.StatusOK)
|
|
|
|
// Phase 3: trigger the workflow again
|
|
createRepoWorkflowFile(t, user4, user4Token, consumerRepo, "marker.txt", "trigger after grant")
|
|
|
|
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumerRepo.ID})
|
|
crossJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "cross_job"})
|
|
assert.True(t, crossJob.IsReusableCaller)
|
|
assert.True(t, crossJob.IsExpanded)
|
|
assert.Equal(t, actions_model.StatusWaiting, crossJob.Status)
|
|
|
|
libJobTask := runner.fetchTask(t)
|
|
_, fetchedLibJob, _ := getTaskAndJobAndRunByTaskID(t, libJobTask.Id)
|
|
assert.Equal(t, "lib_job", fetchedLibJob.JobID)
|
|
assert.Equal(t, crossJob.ID, fetchedLibJob.ParentJobID)
|
|
assert.Equal(t, consumerRepo.ID, fetchedLibJob.RepoID)
|
|
payload := getWorkflowCallPayloadFromTask(t, libJobTask)
|
|
if assert.Len(t, payload.Inputs, 1) {
|
|
assert.Equal(t, "consumer", payload.Inputs["from"])
|
|
}
|
|
crossJob = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: crossJob.ID})
|
|
assert.Equal(t, actions_model.StatusRunning, crossJob.Status)
|
|
runner.execTask(t, libJobTask, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
|
|
|
|
crossJob = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: crossJob.ID})
|
|
assert.Equal(t, actions_model.StatusSuccess, crossJob.Status)
|
|
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
|
|
assert.Equal(t, actions_model.StatusSuccess, run.Status)
|
|
})
|
|
|
|
t.Run("Public caller denied private target even with collaborative owner", func(t *testing.T) {
|
|
// Isolates the run.Repo.IsPrivate gate: a public caller must be denied a private target even with a
|
|
// collaborative-owner grant, since allowing it would expose private workflow content in a public run.
|
|
|
|
// libRepo: private, owned by user2.
|
|
libAPIRepo := createActionsTestRepo(t, user2Token, "reusable-lib-public-denied", true)
|
|
libRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: libAPIRepo.ID})
|
|
createRepoWorkflowFile(t, user2, user2Token, libRepo, ".gitea/workflows/reusable_lib.yaml",
|
|
`name: ReusableLib
|
|
on:
|
|
workflow_call:
|
|
|
|
jobs:
|
|
lib_job:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- run: echo hello
|
|
`)
|
|
|
|
// Grant first: user2 adds user4 as a collaborative owner of the private libRepo, so the grant is
|
|
// satisfied and the public-caller gate is the only thing that can deny access.
|
|
addCollabReq := NewRequestWithValues(t, "POST",
|
|
fmt.Sprintf("/%s/%s/settings/actions/general/collaborative_owner/add", libRepo.OwnerName, libRepo.Name),
|
|
map[string]string{"collaborative_owner": user4.Name})
|
|
user2Session.MakeRequest(t, addCollabReq, http.StatusOK)
|
|
|
|
// consumerRepo: public, owned by user4.
|
|
consumerAPIRepo := createActionsTestRepo(t, user4Token, "workflow-call-public-denied", false)
|
|
consumerRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: consumerAPIRepo.ID})
|
|
|
|
runner := newMockRunner()
|
|
runner.registerAsRepoRunner(t, consumerRepo.OwnerName, consumerRepo.Name, "mock-public-denied-runner", []string{"ubuntu-latest"}, false)
|
|
|
|
createRepoWorkflowFile(t, user4, user4Token, consumerRepo, ".gitea/workflows/cross-caller.yaml",
|
|
`name: CrossCaller
|
|
on: push
|
|
jobs:
|
|
cross_job:
|
|
uses: user2/reusable-lib-public-denied/.gitea/workflows/reusable_lib.yaml@main
|
|
`)
|
|
|
|
// Denied: the cross-repo read check fails for the public caller, so NO ActionRun is persisted and no task is dispatched.
|
|
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumerRepo.ID}))
|
|
runner.fetchNoTask(t)
|
|
})
|
|
|
|
t.Run("Cross-repo callee with same-repo nested uses", func(t *testing.T) {
|
|
// A same-repo `uses: ./...` inside a cross-repo reusable callee must resolve relative to the callee's own repo (matching GitHub's behavior), not the original triggering repo.
|
|
|
|
// Place a util.yaml with a distinguishable job name in BOTH repos to detect mis-resolution.
|
|
|
|
libAPIRepo := createActionsTestRepo(t, user2Token, "reusable-lib-nested", false)
|
|
libRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: libAPIRepo.ID})
|
|
createRepoWorkflowFile(t, user2, user2Token, libRepo, ".gitea/workflows/util.yaml",
|
|
`name: UtilLib
|
|
on:
|
|
workflow_call:
|
|
|
|
jobs:
|
|
util_lib_job:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- run: echo from-lib
|
|
`)
|
|
createRepoWorkflowFile(t, user2, user2Token, libRepo, ".gitea/workflows/lib.yaml",
|
|
`name: LibNested
|
|
on:
|
|
workflow_call:
|
|
|
|
jobs:
|
|
call_util_in_lib:
|
|
uses: ./.gitea/workflows/util.yaml
|
|
`)
|
|
|
|
consumerAPIRepo := createActionsTestRepo(t, user4Token, "consumer-nested-uses", false)
|
|
consumerRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: consumerAPIRepo.ID})
|
|
|
|
// A *different* util.yaml in the consumer repo: if `./` mis-resolves we'd see this job's name.
|
|
createRepoWorkflowFile(t, user4, user4Token, consumerRepo, ".gitea/workflows/util.yaml",
|
|
`name: UtilConsumer
|
|
on:
|
|
workflow_call:
|
|
|
|
jobs:
|
|
util_consumer_job:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- run: echo from-consumer
|
|
`)
|
|
|
|
runner := newMockRunner()
|
|
runner.registerAsRepoRunner(t, consumerRepo.OwnerName, consumerRepo.Name, "mock-nested-runner", []string{"ubuntu-latest"}, false)
|
|
|
|
createRepoWorkflowFile(t, user4, user4Token, consumerRepo, ".gitea/workflows/caller.yaml",
|
|
`name: NestedCaller
|
|
on: push
|
|
jobs:
|
|
cross_job:
|
|
uses: user2/reusable-lib-nested/.gitea/workflows/lib.yaml@main
|
|
`)
|
|
|
|
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumerRepo.ID})
|
|
crossJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "cross_job"})
|
|
assert.True(t, crossJob.IsReusableCaller)
|
|
assert.True(t, crossJob.IsExpanded)
|
|
|
|
// cross_job's children come from libRepo/lib.yaml - their source must be libRepo + libRepo's commit.
|
|
libHead, err := gitrepo.GetBranchCommitID(t.Context(), libRepo, libRepo.DefaultBranch)
|
|
require.NoError(t, err)
|
|
callUtilJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "call_util_in_lib", ParentJobID: crossJob.ID})
|
|
assert.True(t, callUtilJob.IsReusableCaller)
|
|
assert.Equal(t, libRepo.ID, callUtilJob.WorkflowSourceRepoID)
|
|
assert.Equal(t, libHead, callUtilJob.WorkflowSourceCommitSHA)
|
|
|
|
// call_util_in_lib has `uses: ./.gitea/workflows/util.yaml`, so its children should come from libRepo/util.yaml
|
|
assert.True(t, callUtilJob.IsExpanded)
|
|
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "util_lib_job", ParentJobID: callUtilJob.ID})
|
|
unittest.AssertNotExistsBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "util_consumer_job"})
|
|
})
|
|
|
|
t.Run("Missing callee file", func(t *testing.T) {
|
|
// A caller workflow references a callee path that does not exist in the repo.
|
|
|
|
apiRepo := createActionsTestRepo(t, user2Token, "caller-missing-callee", false)
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
|
|
|
|
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/caller.yaml",
|
|
`name: Caller
|
|
on: push
|
|
jobs:
|
|
plain_job:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- run: echo 'job'
|
|
call_missing:
|
|
uses: ./.gitea/workflows/does-not-exist.yml
|
|
`)
|
|
|
|
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
|
|
})
|
|
|
|
t.Run("Fork PR with secrets: inherit does not leak base repo secrets", func(t *testing.T) {
|
|
// user2 owns the base repo, configures a secret, and registers a reusable workflow that declares a required secret.
|
|
// The caller workflow uses `secrets: inherit`.
|
|
|
|
apiBaseRepo := createActionsTestRepo(t, user2Token, "fork-pr-inherit-test", false)
|
|
baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiBaseRepo.ID})
|
|
user2APICtx := NewAPITestContext(t, baseRepo.OwnerName, baseRepo.Name, auth_model.AccessTokenScopeWriteRepository)
|
|
defer doAPIDeleteRepository(user2APICtx)(t)
|
|
|
|
// Real secret that must never reach a fork PR task.
|
|
req := NewRequestWithJSON(t, "PUT",
|
|
fmt.Sprintf("/api/v1/repos/%s/%s/actions/secrets/leaked_secret", baseRepo.OwnerName, baseRepo.Name),
|
|
api.CreateOrUpdateSecretOption{Data: "MUST-NOT-LEAK"}).AddTokenAuth(user2Token)
|
|
MakeRequest(t, req, http.StatusCreated)
|
|
|
|
runner := newMockRunner()
|
|
runner.registerAsRepoRunner(t, baseRepo.OwnerName, baseRepo.Name, "mock-fork-runner", []string{"ubuntu-latest"}, false)
|
|
|
|
createRepoWorkflowFile(t, user2, user2Token, baseRepo, ".gitea/workflows/reusable.yaml",
|
|
`name: Reusable
|
|
on:
|
|
workflow_call:
|
|
secrets:
|
|
leaked_secret:
|
|
|
|
jobs:
|
|
callee:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- run: echo
|
|
`)
|
|
createRepoWorkflowFile(t, user2, user2Token, baseRepo, ".gitea/workflows/caller.yaml",
|
|
`name: Caller
|
|
on: pull_request
|
|
jobs:
|
|
call_reusable:
|
|
uses: ./.gitea/workflows/reusable.yaml
|
|
secrets: inherit
|
|
`)
|
|
|
|
// user4 forks
|
|
req = NewRequestWithJSON(t, "POST",
|
|
fmt.Sprintf("/api/v1/repos/%s/%s/forks", baseRepo.OwnerName, baseRepo.Name),
|
|
&api.CreateForkOption{Name: new("fork-pr-inherit-test-fork")}).AddTokenAuth(user4Token)
|
|
resp := MakeRequest(t, req, http.StatusAccepted)
|
|
apiForkRepo := DecodeJSON(t, resp, &api.Repository{})
|
|
forkRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiForkRepo.ID})
|
|
user4APICtx := NewAPITestContext(t, user4.Name, forkRepo.Name, auth_model.AccessTokenScopeWriteRepository)
|
|
defer doAPIDeleteRepository(user4APICtx)(t)
|
|
|
|
// user4 pushes a change on the fork and opens a PR to base
|
|
doAPICreateFile(user4APICtx, "user4-fix.txt", &api.CreateFileOptions{
|
|
FileOptions: api.FileOptions{
|
|
NewBranchName: "user4/branch",
|
|
Message: "create user4-fix.txt",
|
|
Author: api.Identity{Name: user4.Name, Email: user4.Email},
|
|
Committer: api.Identity{Name: user4.Name, Email: user4.Email},
|
|
Dates: api.CommitDateOptions{Author: time.Now(), Committer: time.Now()},
|
|
},
|
|
ContentBase64: base64.StdEncoding.EncodeToString([]byte("fix")),
|
|
})(t)
|
|
doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":user4/branch")(t)
|
|
|
|
// Approve the fork PR run.
|
|
forkRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, TriggerUserID: user4.ID})
|
|
assert.True(t, forkRun.IsForkPullRequest)
|
|
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/approve", baseRepo.OwnerName, baseRepo.Name, forkRun.ID))
|
|
user2Session.MakeRequest(t, req, http.StatusOK)
|
|
|
|
task := runner.fetchTask(t)
|
|
_, taskJob, taskRun := getTaskAndJobAndRunByTaskID(t, task.Id)
|
|
assert.Equal(t, "callee", taskJob.JobID)
|
|
assert.Equal(t, forkRun.ID, taskRun.ID)
|
|
|
|
// Only the auto-issued tokens should be present. The user-defined `leaked_secret` must not appear.
|
|
assert.Contains(t, task.Secrets, "GITEA_TOKEN")
|
|
assert.Contains(t, task.Secrets, "GITHUB_TOKEN")
|
|
assert.NotContains(t, task.Secrets, "leaked_secret")
|
|
for name, value := range task.Secrets {
|
|
assert.NotEqual(t, "MUST-NOT-LEAK", value, "secret %q leaked the base repo's secret value into a fork PR task", name)
|
|
}
|
|
|
|
runner.execTask(t, task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
|
|
})
|
|
|
|
t.Run("Caller alternates expanding across attempts", func(t *testing.T) {
|
|
apiRepo := createActionsTestRepo(t, user2Token, "caller-walkback-test", false)
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
|
|
|
|
runner := newMockRunner()
|
|
runner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-walkback-runner", []string{"ubuntu-latest"}, false)
|
|
|
|
// Scenario:
|
|
// attempt 1: gate succeeds -> caller expands -> inner runs (records inner.AttemptJobID = N)
|
|
// attempt 2: rerun gate, mock Failure -> caller is Skipped without expanding (no children inserted)
|
|
// attempt 3: rerun gate, mock Success -> caller expands again -> inner.AttemptJobID must equal N
|
|
|
|
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/lib.yaml",
|
|
`name: Lib
|
|
on:
|
|
workflow_call:
|
|
|
|
jobs:
|
|
inner:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- run: echo inner
|
|
`)
|
|
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/main.yaml",
|
|
`name: Main
|
|
on:
|
|
push:
|
|
paths:
|
|
- '.gitea/workflows/main.yaml'
|
|
jobs:
|
|
gate:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- run: echo gate
|
|
|
|
caller:
|
|
needs: [gate]
|
|
uses: ./.gitea/workflows/lib.yaml
|
|
`)
|
|
|
|
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID})
|
|
runID := run.ID
|
|
|
|
latestAttempt := func() *actions_model.ActionRunAttempt {
|
|
r := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
|
|
return unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunAttempt{ID: r.LatestAttemptID})
|
|
}
|
|
jobInLatest := func(jobID string) *actions_model.ActionRunJob {
|
|
a := latestAttempt()
|
|
return unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: a.ID, JobID: jobID})
|
|
}
|
|
|
|
// attempt 1: gate Success -> caller expands -> inner runs
|
|
gate1Task := runner.fetchTask(t)
|
|
_, gate1, _ := getTaskAndJobAndRunByTaskID(t, gate1Task.Id)
|
|
assert.Equal(t, "gate", gate1.JobID)
|
|
runner.execTask(t, gate1Task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
|
|
|
|
inner1Task := runner.fetchTask(t)
|
|
_, inner1, _ := getTaskAndJobAndRunByTaskID(t, inner1Task.Id)
|
|
assert.Equal(t, "inner", inner1.JobID)
|
|
innerAttemptJobID := inner1.AttemptJobID
|
|
callerAttempt1 := jobInLatest("caller")
|
|
assert.True(t, callerAttempt1.IsExpanded)
|
|
assert.Equal(t, callerAttempt1.ID, inner1.ParentJobID)
|
|
runner.execTask(t, inner1Task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
|
|
|
|
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
|
|
assert.Equal(t, actions_model.StatusSuccess, run.Status)
|
|
|
|
// attempt 2: rerun gate, mock Failure -> caller stays unexpanded (Skipped)
|
|
gateLatest := jobInLatest("gate")
|
|
req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", repo.OwnerName, repo.Name, runID, gateLatest.ID))
|
|
user2Session.MakeRequest(t, req, http.StatusOK)
|
|
|
|
gate2Task := runner.fetchTask(t)
|
|
_, gate2, _ := getTaskAndJobAndRunByTaskID(t, gate2Task.Id)
|
|
assert.Equal(t, "gate", gate2.JobID)
|
|
runner.execTask(t, gate2Task, &mockTaskOutcome{result: runnerv1.Result_RESULT_FAILURE})
|
|
|
|
runner.fetchNoTask(t) // no inner because caller did not expand
|
|
attempt2 := latestAttempt()
|
|
assert.Equal(t, actions_model.StatusFailure, attempt2.Status)
|
|
callerAttempt2 := jobInLatest("caller")
|
|
assert.Equal(t, actions_model.StatusSkipped, callerAttempt2.Status)
|
|
assert.False(t, callerAttempt2.IsExpanded)
|
|
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, JobID: "inner"}))
|
|
|
|
// attempt 3: rerun gate, mock Success -> caller expands and inner reuses attempt 1's AttemptJobID
|
|
gateLatest = jobInLatest("gate")
|
|
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", repo.OwnerName, repo.Name, runID, gateLatest.ID))
|
|
user2Session.MakeRequest(t, req, http.StatusOK)
|
|
|
|
gate3Task := runner.fetchTask(t)
|
|
runner.execTask(t, gate3Task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
|
|
|
|
inner3Task := runner.fetchTask(t)
|
|
_, inner3, _ := getTaskAndJobAndRunByTaskID(t, inner3Task.Id)
|
|
assert.Equal(t, "inner", inner3.JobID)
|
|
assert.Equal(t, innerAttemptJobID, inner3.AttemptJobID)
|
|
runner.execTask(t, inner3Task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
|
|
|
|
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
|
|
assert.Equal(t, actions_model.StatusSuccess, run.Status)
|
|
})
|
|
})
|
|
}
|
|
|
|
// token must belong to u (the commit identity) and have write access to repo. Reuse the caller's
|
|
// existing token rather than logging in per call, which would re-run bcrypt password verification each time.
|
|
func createRepoWorkflowFile(t *testing.T, u *user_model.User, token string, repo *repo_model.Repository, treePath, content string) {
|
|
opts := getWorkflowCreateFileOptions(u, repo.DefaultBranch, "create "+treePath, content)
|
|
createWorkflowFile(t, token, repo.OwnerName, repo.Name, treePath, opts)
|
|
}
|
|
|
|
func getWorkflowCallPayloadFromTask(t *testing.T, runnerTask *runnerv1.Task) *api.WorkflowCallPayload {
|
|
eventJSON, err := runnerTask.GetContext().Fields["event"].GetStructValue().MarshalJSON()
|
|
assert.NoError(t, err)
|
|
var payload api.WorkflowCallPayload
|
|
assert.NoError(t, json.Unmarshal(eventJSON, &payload))
|
|
return &payload
|
|
}
|