feat: support jobs.<job_id>.secrets with reusable workflow expansion (#10627)

Follow-up to #10525; adds support for `jobs.<job_id>.secrets` to expanded reusable workflows (when no `runs-on` is specified in a job that `uses: ...` another workflow).

## Checklist

The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. 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

- I added test coverage for Go changes...
  - [x] in their respective `*_test.go` for unit tests.
  - [ ] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- 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)).
- **end-to-end testing**: [prepared, PR n](https://code.forgejo.org/forgejo/end-to-end/pulls/1351)

### Documentation

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

### Release notes

- [ ] I do not want this change to show in the release notes.
- [x] I want the title to show in the release notes with a link to this pull request.
- [ ] I want the content of the `release-notes/<pull request number>.md` to be be used for the release notes instead of the title.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10627
Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org>
Co-authored-by: Mathieu Fenniak <mathieu@fenniak.net>
Co-committed-by: Mathieu Fenniak <mathieu@fenniak.net>
This commit is contained in:
Mathieu Fenniak 2025-12-30 17:33:21 +01:00 committed by Mathieu Fenniak
parent d8a5ee81fb
commit 9b2f7c557b
11 changed files with 654 additions and 33 deletions

View file

@ -0,0 +1,94 @@
# Supporting data for Case 600
-
id: 900
title: "running"
owner_id: 2
repo_id: 63
workflow_id: "running.yaml"
index: 4
trigger_event: "push"
is_fork_pull_request: false
# Supporting data for Case 601
-
id: 901
title: "running"
owner_id: 2
repo_id: 63
workflow_id: "running.yaml"
index: 5
trigger_event: "pull_request_target"
is_fork_pull_request: false
# Supporting data for Case 602
-
id: 902
title: "running"
owner_id: 2
repo_id: 63
workflow_id: "running.yaml"
index: 6
trigger_event: "pull_request_target"
is_fork_pull_request: true
# Supporting data for Case 603
-
id: 903
title: "running"
owner_id: 2
repo_id: 63
workflow_id: "running.yaml"
index: 7
trigger_event: "pull_request"
is_fork_pull_request: false
# Supporting data for Case 604
-
id: 904
title: "running"
owner_id: 2
repo_id: 63
workflow_id: "running.yaml"
index: 8
trigger_event: "pull_request"
is_fork_pull_request: true
# Supporting data for Case 605
-
id: 905
title: "running"
owner_id: 2
repo_id: 63
workflow_id: "running.yaml"
index: 9
trigger_event: "pull_request"
is_fork_pull_request: false
# Supporting data for Case 607
-
id: 906
title: "running"
owner_id: 2
repo_id: 63
workflow_id: "running.yaml"
index: 10
trigger_event: "pull_request"
is_fork_pull_request: false
# Supporting data for Case 610
-
id: 907
title: "running"
owner_id: 2
repo_id: 63
ref: "refs/heads/main"
workflow_id: "running.yaml"
index: 11
trigger_event: "workflow_dispatch"
is_fork_pull_request: false
event_payload: |
{
"inputs": {
"some_wd_input": "some_wd_input_value"
}
}

View file

@ -0,0 +1,192 @@
# Case 600 -- on:push workflow with some secrets
-
id: 600
run_id: 900
workflow_payload: |
"on":
push:
jobs:
produce-artifacts:
name: produce-artifacts
runs-on: docker
steps:
- run: echo "OK!"
# Case 601 -- on: pull_request_target workflow, local PR (not fork)
-
id: 601
run_id: 901
workflow_payload: |
"on":
pull_request_target:
jobs:
produce-artifacts:
name: produce-artifacts
runs-on: docker
steps:
- run: echo "OK!"
# Case 602 -- on: pull_request_target workflow, fork PR
-
id: 602
run_id: 902
workflow_payload: |
"on":
pull_request_target:
jobs:
produce-artifacts:
name: produce-artifacts
runs-on: docker
steps:
- run: echo "OK!"
# Case 603 -- on: pull_request workflow, local PR (not fork)
-
id: 603
run_id: 903
workflow_payload: |
"on":
pull_request:
jobs:
produce-artifacts:
name: produce-artifacts
runs-on: docker
steps:
- run: echo "OK!"
# Case 604 -- on: pull_request workflow, fork PR
-
id: 604
run_id: 904
workflow_payload: |
"on":
pull_request:
jobs:
produce-artifacts:
name: produce-artifacts
runs-on: docker
steps:
- run: echo "OK!"
# Case 605 -- workflow call inner job, inherit secrets, 606 is the outer job
-
id: 605
run_id: 905
workflow_payload: |
"on":
pull_request_target:
jobs:
produce-artifacts:
name: produce-artifacts
runs-on: docker
steps:
- run: echo "OK!"
__metadata:
workflow_call_parent: b5a9f46f1f2513d7777fde50b169d323a6519e349cc175484c947ac315a209ed
-
id: 606
run_id: 905
workflow_payload: |
"on":
pull_request_target:
jobs:
invoke-reusable:
uses: ./.forgejo/workflows/produce.yml
secrets: inherit
__metadata:
workflow_call_id: b5a9f46f1f2513d7777fde50b169d323a6519e349cc175484c947ac315a209ed
# Case 607 -- workflow call two layer inner job, inherit secrets, 607->608->609
-
id: 607
run_id: 906
workflow_payload: |
"on":
workflow_call:
jobs:
produce-artifacts:
name: produce-artifacts
runs-on: docker
steps:
- run: echo "OK!"
__metadata:
workflow_call_parent: b5a9f46f1f2513d7777fde50b169d323a6519e349cc175484c947ac315a209ed
-
id: 608
run_id: 906
workflow_payload: |
"on":
workflow_call:
jobs:
invoke-reusable:
uses: ./.forgejo/workflows/produce-specific.yml
secrets: inherit
__metadata:
workflow_call_id: b5a9f46f1f2513d7777fde50b169d323a6519e349cc175484c947ac315a209ed
workflow_call_parent: 1976193ec4c48a92ba58816b34116272f5b3a612b91494956e5b53ee70b8714f
-
id: 609
run_id: 906
workflow_payload: |
"on":
pull_request:
jobs:
invoke-reusable:
uses: ./.forgejo/workflows/produce.yml
secrets:
secret_1: ${{ secrets.secret_1 }} -- but are you sure?
__metadata:
workflow_call_id: 1976193ec4c48a92ba58816b34116272f5b3a612b91494956e5b53ee70b8714f
# Case 610 -- workflow call specifically defined secrets, 611 is the outer job, 612 is another job in the same workflow
-
id: 610
run_id: 907
workflow_payload: |
"on":
workflow_call:
jobs:
produce-artifacts:
name: produce-artifacts
runs-on: docker
steps:
- run: echo "OK!"
__metadata:
workflow_call_parent: b5a9f46f1f2513d7777fde50b169d323a6519e349cc175484c947ac315a209ed
-
id: 611
run_id: 907
needs: '["provide-outputs"]'
workflow_payload: |
"on":
workflow_dispatch:
jobs:
invoke-reusable:
uses: ./.forgejo/workflows/produce-specific.yml
secrets:
forgejo: context forgejo = ${{ forgejo.ref }}
inputs: context inputs = ${{ inputs.some_wd_input }}
matrix: context matrix = ${{ matrix.some-dimension }}
needs: context needs = ${{ needs.provide-outputs.outputs.some-output }}
secrets: context secrets = ${{ secrets.secret_1 }}
strategy: context strategy = ${{ strategy.fail-fast }}
vars: context vars = ${{ vars.repo_var }}
strategy:
fail-fast: false
matrix:
some-dimension:
- some-dimension-value
__metadata:
workflow_call_id: b5a9f46f1f2513d7777fde50b169d323a6519e349cc175484c947ac315a209ed
-
id: 612
run_id: 907
job_id: provide-outputs
status: 1 # success
workflow_payload: |
"on":
workflow_dispatch:
jobs:
provide-outputs:
steps: []
task_id: 100

View file

@ -0,0 +1,6 @@
# Supporting data for Case 610
-
id: 100
task_id: 100
output_key: some-output
output_value: 'abcdefghijklmnopqrstuvwxyz'

View file

@ -0,0 +1,22 @@
# Case w/ action_run_job.id = 601
-
id: 1001
name: REPO_VAR
owner_id: 0
repo_id: 63
data: "this is a repo variable"
created_unix: 1737000000
-
id: 1002
name: ORG_VAR
owner_id: 2
repo_id: 0
data: "this is an org variable"
created_unix: 1737000000
-
id: 1003
name: GLOBAL_VAR
owner_id: 0
repo_id: 0
data: "this is a global variable"
created_unix: 1737000000

145
services/actions/secret.go Normal file
View file

@ -0,0 +1,145 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package actions
import (
"context"
"errors"
"fmt"
actions_model "forgejo.org/models/actions"
secret_model "forgejo.org/models/secret"
actions_module "forgejo.org/modules/actions"
"forgejo.org/modules/json"
"forgejo.org/modules/structs"
"code.forgejo.org/forgejo/runner/v12/act/jobparser"
)
func getSecretsOfTask(ctx context.Context, task *actions_model.ActionTask) (map[string]string, error) {
secrets, err := getSecretsOfJob(ctx, task.Job)
secrets["GITHUB_TOKEN"] = task.Token
secrets["GITEA_TOKEN"] = task.Token
secrets["FORGEJO_TOKEN"] = task.Token
return secrets, err
}
func getSecretsOfJob(ctx context.Context, job *actions_model.ActionRunJob) (map[string]string, error) {
isInnerWorkflowCall, err := job.IsWorkflowCallInnerJob()
if err != nil {
return nil, err
}
err = job.LoadRun(ctx)
if err != nil {
return nil, fmt.Errorf("failure to load job run: %w", err)
}
if isInnerWorkflowCall {
return getSecretsOfInnerWorkflowCall(ctx, job)
}
if job.Run.IsForkPullRequest && job.Run.TriggerEvent != actions_module.GithubEventPullRequestTarget {
// ignore secrets for fork pull request, except GITHUB_TOKEN, GITEA_TOKEN and FORGEJO_TOKEN which are automatically generated.
// for the tasks triggered by pull_request_target event, they could access the secrets because they will run in the context of the base branch
// see the documentation: https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target
return map[string]string{}, nil
}
err = job.Run.LoadRepo(ctx)
if err != nil {
return nil, err
}
jobSecrets, err := secret_model.FetchActionSecrets(ctx, job.Run.Repo.OwnerID, job.Run.RepoID)
if err != nil {
// Don't return error details, just in case they contain confidential details and error reaches a user;
// FetchActionSecrets logs all errors to the server log.
return nil, errors.New("failure to fetch secrets")
}
return jobSecrets, nil
}
func getSecretsOfInnerWorkflowCall(ctx context.Context, job *actions_model.ActionRunJob) (map[string]string, error) {
// Workflow calls can have two different behaviours -- they can either have `secrets: inherit` in which case we get
// the secrets of the caller and pass them in, or, they can have `secrets: { ... }` with key-values that need to be
// evaluated in the context of the parent (that is, `${{ secret.example_secret }}` would reference `example_secret`
// from the caller's secrets).
//
// In either case, we need the caller job's secrets, and we need the caller job's workflow definition to find out
// how they wanted secrets defined for this workflow call.
outerWorkflowCall, err := job.Run.FindOuterWorkflowCall(ctx, job)
if err != nil {
return nil, fmt.Errorf("failure to find outer workflow call: %w", err)
}
outerSecrets, err := getSecretsOfJob(ctx, outerWorkflowCall)
if err != nil {
return nil, err
}
outerWorkflowPayload, err := outerWorkflowCall.DecodeWorkflowPayload()
if err != nil {
return nil, err
}
_, outerJob := outerWorkflowPayload.Job()
if outerJob.InheritSecrets() {
return outerSecrets, nil
}
// Gather all the data that is needed to perform an expression evaluation of the parent job's secrets context:
err = outerWorkflowCall.LoadRun(ctx)
if err != nil {
return nil, fmt.Errorf("failure to load job's run: %w", err)
}
err = outerWorkflowCall.Run.LoadRepo(ctx)
if err != nil {
return nil, fmt.Errorf("failure to load run's repo: %w", err)
}
githubContext := generateGiteaContextForRun(outerWorkflowCall.Run)
taskNeeds, err := FindTaskNeeds(ctx, outerWorkflowCall)
if err != nil {
return nil, fmt.Errorf("failure evaluating 'needs' for job: %w", err)
}
needs := make([]string, 0, len(taskNeeds))
jobResults := make(map[string]string, len(taskNeeds))
jobOutputs := make(map[string]map[string]string, len(taskNeeds))
for jobID, n := range taskNeeds {
needs = append(needs, jobID)
jobResults[jobID] = n.Result.String()
jobOutputs[jobID] = n.Outputs
}
vars, err := actions_model.GetVariablesOfRun(ctx, job.Run)
if err != nil {
return nil, fmt.Errorf("failure evaluating 'vars' for run: %w", err)
}
var inputs map[string]any
if outerWorkflowCall.Run.TriggerEvent == actions_module.GithubEventWorkflowDispatch {
// workflow_dispatch inputs are stored in the event payload
var dispatchPayload *structs.WorkflowDispatchPayload
err := json.Unmarshal([]byte(outerWorkflowCall.Run.EventPayload), &dispatchPayload)
if err != nil {
return nil, fmt.Errorf("failure reading workflow dispatch payload: %w", err)
}
// transition from map[string]string to map[string]any...
inputs = make(map[string]any, len(dispatchPayload.Inputs))
for k, v := range dispatchPayload.Inputs {
inputs[k] = v
}
}
jobSecrets := jobparser.EvaluateWorkflowCallSecrets(&jobparser.EvaluateWorkflowCallSecretsArgs{
CallerWorkflow: outerWorkflowPayload,
CallerSecrets: outerSecrets,
GitCtx: githubContext,
Vars: vars,
Needs: needs,
JobResults: jobResults,
JobOutputs: jobOutputs,
JobInputs: inputs,
})
return jobSecrets, nil
}

View file

@ -0,0 +1,108 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package actions
import (
"testing"
actions_model "forgejo.org/models/actions"
secret_model "forgejo.org/models/secret"
"forgejo.org/models/unittest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetSecretsOfJob(t *testing.T) {
tests := []struct {
name string
runJobID int64
secrets map[string]string
}{
{
name: "push run",
runJobID: 600,
secrets: map[string]string{
"SECRET_1": "the sky is blue",
"SECRET_2": "the ocean is also blue",
},
},
{
name: "on: pull_request_target workflow, local PR (not fork)",
runJobID: 601,
secrets: map[string]string{
"SECRET_1": "the sky is blue",
"SECRET_2": "the ocean is also blue",
},
},
{
name: "on: pull_request_target workflow, fork PR",
runJobID: 602,
secrets: map[string]string{
"SECRET_1": "the sky is blue",
"SECRET_2": "the ocean is also blue",
},
},
{
name: "on: pull_request workflow, local PR (not fork)",
runJobID: 603,
secrets: map[string]string{
"SECRET_1": "the sky is blue",
"SECRET_2": "the ocean is also blue",
},
},
{
name: "on: pull_request workflow, fork PR",
runJobID: 604,
secrets: map[string]string{},
},
{
name: "workflow call inner job, inherit secrets",
runJobID: 605,
secrets: map[string]string{
"SECRET_1": "the sky is blue",
"SECRET_2": "the ocean is also blue",
},
},
{
name: "workflow call two layer inner job, inherit secrets",
runJobID: 607,
secrets: map[string]string{
// Even though we're 'inherit' in this case, we're inheriting from the parent call which is a subset
// (and modification) of the secrets -- so shouldn't see SECRET_2.
"SECRET_1": "the sky is blue -- but are you sure?",
},
},
{
name: "workflow call inner job, defined secrets",
runJobID: 610,
secrets: map[string]string{
"FORGEJO": "context forgejo = refs/heads/main",
"INPUTS": "context inputs = some_wd_input_value",
"MATRIX": "context matrix = some-dimension-value",
"NEEDS": "context needs = abcdefghijklmnopqrstuvwxyz",
"SECRETS": "context secrets = the sky is blue",
"STRATEGY": "context strategy = false",
"VARS": "context vars = this is a repo variable",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer unittest.OverrideFixtures("services/actions/TestGetSecretsOfJob")()
require.NoError(t, unittest.PrepareTestDatabase())
// Due to encryption, more maintainable to do this rather than create secrets in fixture data
_, err := secret_model.InsertEncryptedSecret(t.Context(), 2, 0, "secret_1", "the sky is blue")
require.NoError(t, err)
_, err = secret_model.InsertEncryptedSecret(t.Context(), 0, 63, "secret_2", "the ocean is also blue")
require.NoError(t, err)
runJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: tt.runJobID})
actualSecrets, err := getSecretsOfJob(t.Context(), runJob)
require.NoError(t, err)
assert.Equal(t, tt.secrets, actualSecrets)
})
}
}

View file

@ -10,7 +10,6 @@ import (
actions_model "forgejo.org/models/actions"
"forgejo.org/models/db"
secret_model "forgejo.org/models/secret"
"forgejo.org/modules/timeutil"
"forgejo.org/modules/util"
@ -39,7 +38,7 @@ func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv
}
job = t.Job
secrets, err := secret_model.GetSecretsOfTask(ctx, t)
secrets, err := getSecretsOfTask(ctx, t)
if err != nil {
return fmt.Errorf("GetSecretsOfTask: %w", err)
}