mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-19 16:18:11 +00:00
enhance(actions): add OIDC workload identity support (#39052)
Adds OpenID Connect workload identity to Gitea Actions: jobs declaring `id-token: write` can request short-lived tokens to authenticate to cloud providers and other services without stored secrets. Tokens follow GitHub's format, so clients like `@actions/core` and existing trust policies carry over. - Issuer at `/api/actions/oidc` with discovery and JWKS, signed with a dedicated RS256 key - GitHub's claims, including `workflow_ref`, `job_workflow_ref` and the immutable `repo:owner@id/repo@id:ref:...` subject - `id-token: write` or `write-all` grant it, default permissions and fork pull requests never get it, owner and repository maximum permissions can disable it - Reusable workflows record the exact ref they were loaded from, and dispatch rejects refs that only resolve through a same-named tag - Generated signing keys are written atomically, and generated ES384 and ES512 OAuth2 keys use their own curves instead of P-256 - `github.ref_type` is `branch` for pull request runs, like on GitHub Fixes https://github.com/go-gitea/gitea/issues/26383 Fixes https://github.com/go-gitea/gitea/issues/33681 Docs: https://gitea.com/gitea/docs/pulls/526 Assisted-by: ChatGPT:gpt-5.6-sol, Claude Code:claude-opus-5-5 Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
@@ -438,6 +438,7 @@ func prepareMigrationTasks() []*migration {
|
||||
newMigration(359, "Add stage and number columns to action_task_step", v29.AddStageAndNumberToActionTaskStep),
|
||||
newMigration(360, "Move branch protection settings to JSON", v29.MoveBranchProtectionConfigToJSON),
|
||||
newMigration(361, "Drop legacy commit_id column from comment", v29.DropCommentCommitID),
|
||||
newMigration(362, "Add reusable_workflow_ref column to action_run_job", v29.AddReusableWorkflowRefToActionRunJob),
|
||||
}
|
||||
return preparedMigrations
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v29
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.dev/modelmigration/base"
|
||||
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func AddReusableWorkflowRefToActionRunJob(_ context.Context, x base.EngineMigration) error {
|
||||
type ActionRunJob struct {
|
||||
ReusableWorkflowRef string `xorm:"VARCHAR(512) NOT NULL DEFAULT ''"`
|
||||
}
|
||||
_, err := x.SyncWithOptions(xorm.SyncOptions{
|
||||
IgnoreConstrains: true,
|
||||
IgnoreDropIndices: true,
|
||||
}, new(ActionRunJob))
|
||||
return err
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func (cfg *OwnerActionsConfig) GetMaxTokenPermissions() repo_model.ActionsTokenP
|
||||
return *cfg.MaxTokenPermissions
|
||||
}
|
||||
// Default max is write for everything
|
||||
return repo_model.MakeActionsTokenPermissions(perm.AccessModeWrite)
|
||||
return repo_model.MakeWriteAllActionsTokenPermissions()
|
||||
}
|
||||
|
||||
// ClampPermissions ensures that the given permissions don't exceed the maximum
|
||||
|
||||
@@ -110,6 +110,8 @@ type ActionRunJob struct {
|
||||
// ReusableWorkflowContent is the content of the reusable workflow specified by "uses:".
|
||||
// Only set when IsReusableCaller is true.
|
||||
ReusableWorkflowContent []byte `xorm:"LONGBLOB"`
|
||||
// ReusableWorkflowRef is the fully qualified ref, or the commit, that "uses:" resolved to when the content was loaded.
|
||||
ReusableWorkflowRef string `xorm:"VARCHAR(512) NOT NULL DEFAULT ''"`
|
||||
// CallSecrets encodes the reusable workflow caller's "secrets:" section:
|
||||
// - "" : no "secrets:" section (children only see auto-generated tokens).
|
||||
// - "inherit" : the caller wrote "secrets: inherit".
|
||||
|
||||
@@ -29,6 +29,7 @@ func (ActionsTokenPermissionMode) EnumValues() []ActionsTokenPermissionMode {
|
||||
// ActionsTokenPermissions defines the permissions for different repository units
|
||||
type ActionsTokenPermissions struct {
|
||||
UnitAccessModes map[unit.Type]perm.AccessMode `json:"unit_access_modes,omitempty"`
|
||||
IDToken bool `json:"id_token,omitempty"` // default modes never grant it, like on GitHub
|
||||
}
|
||||
|
||||
var ActionsTokenUnitTypes = []unit.Type{
|
||||
@@ -50,12 +51,19 @@ func MakeActionsTokenPermissions(unitAccessMode perm.AccessMode) (ret ActionsTok
|
||||
return ret
|
||||
}
|
||||
|
||||
func MakeWriteAllActionsTokenPermissions() ActionsTokenPermissions {
|
||||
ret := MakeActionsTokenPermissions(perm.AccessModeWrite)
|
||||
ret.IDToken = true
|
||||
return ret
|
||||
}
|
||||
|
||||
// ClampActionsTokenPermissions ensures that the given permissions don't exceed the maximum
|
||||
func ClampActionsTokenPermissions(p1, p2 ActionsTokenPermissions) (ret ActionsTokenPermissions) {
|
||||
ret.UnitAccessModes = make(map[unit.Type]perm.AccessMode)
|
||||
for _, ut := range ActionsTokenUnitTypes {
|
||||
ret.UnitAccessModes[ut] = min(p1.UnitAccessModes[ut], p2.UnitAccessModes[ut])
|
||||
}
|
||||
ret.IDToken = p1.IDToken && p2.IDToken
|
||||
return ret
|
||||
}
|
||||
|
||||
@@ -156,7 +164,7 @@ func (cfg *ActionsConfig) GetMaxTokenPermissions() ActionsTokenPermissions {
|
||||
return *cfg.MaxTokenPermissions
|
||||
}
|
||||
// Default max is write for everything
|
||||
return MakeActionsTokenPermissions(perm.AccessModeWrite)
|
||||
return MakeWriteAllActionsTokenPermissions()
|
||||
}
|
||||
|
||||
// ClampPermissions ensures that the given permissions don't exceed the maximum
|
||||
|
||||
@@ -57,6 +57,8 @@ func TestActionsConfigTokenPermissions(t *testing.T) {
|
||||
assert.Equal(t, perm.AccessModeWrite, perms.UnitAccessModes[unit.TypeCode])
|
||||
assert.Equal(t, perm.AccessModeWrite, perms.UnitAccessModes[unit.TypeIssues])
|
||||
assert.Equal(t, perm.AccessModeWrite, perms.UnitAccessModes[unit.TypePackages])
|
||||
assert.False(t, perms.IDToken)
|
||||
assert.True(t, cfg.GetMaxTokenPermissions().IDToken)
|
||||
})
|
||||
|
||||
t.Run("Effective Permissions - Restricted Mode", func(t *testing.T) {
|
||||
@@ -92,6 +94,7 @@ func TestActionsConfigTokenPermissions(t *testing.T) {
|
||||
unit.TypeActions: perm.AccessModeRead, // Should be clamped to None
|
||||
unit.TypeWiki: perm.AccessModeRead, // Should stay Read
|
||||
},
|
||||
IDToken: true,
|
||||
}
|
||||
clamped := cfg.ClampPermissions(input)
|
||||
assert.Equal(t, perm.AccessModeRead, clamped.UnitAccessModes[unit.TypeCode])
|
||||
@@ -100,5 +103,6 @@ func TestActionsConfigTokenPermissions(t *testing.T) {
|
||||
assert.Equal(t, perm.AccessModeRead, clamped.UnitAccessModes[unit.TypePackages])
|
||||
assert.Equal(t, perm.AccessModeNone, clamped.UnitAccessModes[unit.TypeActions])
|
||||
assert.Equal(t, perm.AccessModeRead, clamped.UnitAccessModes[unit.TypeWiki])
|
||||
assert.False(t, clamped.IDToken)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4002,6 +4002,8 @@
|
||||
"actions.general.token_permissions.maximum.description": "Actions job's effective permissions will be limited by the maximum permissions.",
|
||||
"actions.general.token_permissions.fork_pr_note": "If a job is started by a pull request from a fork, its effective permissions won't exceed the read-only permissions.",
|
||||
"actions.general.token_permissions.customize_max_permissions": "Customize maximum permissions",
|
||||
"actions.general.token_permissions.id_token": "OpenID Connect token",
|
||||
"actions.general.token_permissions.id_token.description": "Allow jobs declaring \"id-token: write\" to request short-lived OpenID Connect tokens for authenticating to external services.",
|
||||
"actions.general.cross_repo": "Cross-Repository Access",
|
||||
"actions.general.cross_repo_desc": "Allow the selected repositories to be accessed (read-only) by all the repositories in this owner with GITEA_TOKEN when running Actions jobs.",
|
||||
"actions.general.cross_repo_selected": "Selected repositories",
|
||||
|
||||
@@ -13,6 +13,11 @@ import (
|
||||
|
||||
func Routes(prefix string) *web.Router {
|
||||
m := web.NewRouter()
|
||||
m.Group("/oidc", func() {
|
||||
m.Get("/.well-known/openid-configuration", oidcConfiguration)
|
||||
m.Get("/jwks", oidcKeys)
|
||||
m.Get("/token", oidcToken)
|
||||
})
|
||||
|
||||
path, handler := ping.NewPingServiceHandler()
|
||||
m.Post(path+"*", http.StripPrefix(prefix, handler).ServeHTTP)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"gitea.dev/modules/auth/httpauth"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/util"
|
||||
actions_service "gitea.dev/services/actions"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
func oidcConfiguration(resp http.ResponseWriter, req *http.Request) {
|
||||
issuer := actions_service.OIDCIssuer()
|
||||
context.NewBaseContext(resp, req).JSON(http.StatusOK, map[string]any{
|
||||
"issuer": issuer,
|
||||
"jwks_uri": issuer + "/jwks",
|
||||
"response_types_supported": []string{"id_token"},
|
||||
"subject_types_supported": []string{"public"},
|
||||
"id_token_signing_alg_values_supported": []string{actions_service.OIDCSigningAlgorithm},
|
||||
"claims_supported": actions_service.OIDCClaimsSupported(),
|
||||
"scopes_supported": []string{"openid"},
|
||||
})
|
||||
}
|
||||
|
||||
func oidcKeys(resp http.ResponseWriter, req *http.Request) {
|
||||
ctx := context.NewBaseContext(resp, req)
|
||||
key, err := actions_service.OIDCSigningKey()
|
||||
if err != nil {
|
||||
log.Error("Actions OIDC signing key: %v", err)
|
||||
ctx.HTTPError(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jwk, err := key.ToJWK()
|
||||
if err != nil {
|
||||
log.Error("Actions OIDC JWK: %v", err)
|
||||
ctx.HTTPError(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jwk["use"] = "sig"
|
||||
ctx.JSON(http.StatusOK, map[string]any{"keys": []map[string]string{jwk}})
|
||||
}
|
||||
|
||||
func oidcToken(resp http.ResponseWriter, req *http.Request) {
|
||||
ctx := context.NewBaseContext(resp, req)
|
||||
token := ""
|
||||
if auth, ok := httpauth.ParseAuthorizationHeader(req.Header.Get("Authorization")); ok && auth.BearerToken != nil {
|
||||
token = auth.BearerToken.Token
|
||||
}
|
||||
idToken, err := actions_service.CreateOIDCToken(ctx, token, ctx.FormString("audience"))
|
||||
if errors.Is(err, util.ErrPermissionDenied) {
|
||||
ctx.Resp.Header().Set("WWW-Authenticate", "Bearer")
|
||||
ctx.HTTPError(http.StatusUnauthorized)
|
||||
return
|
||||
} else if err != nil {
|
||||
log.Error("Actions OIDC token: %v", err)
|
||||
ctx.HTTPError(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
ctx.Resp.Header().Set("Cache-Control", "no-store")
|
||||
ctx.JSON(http.StatusOK, map[string]string{"value": idToken})
|
||||
}
|
||||
@@ -39,6 +39,7 @@ func ParseMaxTokenPermissions(ctx *context.Context) *repo_model.ActionsTokenPerm
|
||||
for _, ut := range repo_model.ActionsTokenUnitTypes {
|
||||
ret.UnitAccessModes[ut] = parseMaxPerm(ut)
|
||||
}
|
||||
ret.IDToken = ctx.FormString("max_id_token") == "write"
|
||||
return ret
|
||||
}
|
||||
|
||||
|
||||
+20
-16
@@ -17,6 +17,8 @@ import (
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
const idTokenRequestScope = "Actions.IDTokenRequest" // separate from the runtime token, which runners also send to cache servers
|
||||
|
||||
type actionsClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
Scp string `json:"scp"`
|
||||
@@ -39,8 +41,6 @@ const (
|
||||
)
|
||||
|
||||
func CreateAuthorizationToken(taskID, runID, jobID int64) (string, error) {
|
||||
now := time.Now()
|
||||
|
||||
ac, err := json.Marshal(&[]actionsCacheScope{
|
||||
{
|
||||
Scope: "",
|
||||
@@ -51,25 +51,22 @@ func CreateAuthorizationToken(taskID, runID, jobID int64) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
claims := actionsClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(1*time.Hour + setting.Actions.EndlessTaskTimeout)),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
},
|
||||
return signActionsClaims(actionsClaims{
|
||||
Scp: fmt.Sprintf("Actions.Results:%d:%d", runID, jobID),
|
||||
Ac: string(ac),
|
||||
TaskID: taskID,
|
||||
RunID: runID,
|
||||
JobID: jobID,
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
})
|
||||
}
|
||||
|
||||
tokenString, err := token.SignedString(setting.GetGeneralTokenSigningSecret())
|
||||
if err != nil {
|
||||
return "", err
|
||||
func signActionsClaims(claims actionsClaims) (string, error) {
|
||||
now := time.Now()
|
||||
claims.RegisteredClaims = jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(1*time.Hour + setting.Actions.EndlessTaskTimeout)),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
}
|
||||
|
||||
return tokenString, nil
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(setting.GetGeneralTokenSigningSecret())
|
||||
}
|
||||
|
||||
func ParseAuthorizationToken(req *http.Request) (int64, error) {
|
||||
@@ -89,6 +86,10 @@ func ParseAuthorizationToken(req *http.Request) (int64, error) {
|
||||
|
||||
// TokenToTaskID returns the TaskID associated with the provided JWT token
|
||||
func TokenToTaskID(token string) (int64, error) {
|
||||
return taskIDFromToken(token, false)
|
||||
}
|
||||
|
||||
func taskIDFromToken(token string, idTokenRequest bool) (int64, error) {
|
||||
parsedToken, err := jwt.ParseWithClaims(token, &actionsClaims{}, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
@@ -99,10 +100,13 @@ func TokenToTaskID(token string) (int64, error) {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
c, ok := parsedToken.Claims.(*actionsClaims)
|
||||
claims, ok := parsedToken.Claims.(*actionsClaims)
|
||||
if !parsedToken.Valid || !ok {
|
||||
return 0, errors.New("invalid token claim")
|
||||
}
|
||||
if (claims.Scp == idTokenRequestScope) != idTokenRequest {
|
||||
return 0, errors.New("unexpected token scope")
|
||||
}
|
||||
|
||||
return c.TaskID, nil
|
||||
return claims.TaskID, nil
|
||||
}
|
||||
|
||||
@@ -58,6 +58,10 @@ func GenerateGiteaContext(ctx context.Context, run *actions_model.ActionRun, att
|
||||
}
|
||||
|
||||
refName := git.RefName(ref)
|
||||
refType := refName.RefType()
|
||||
if refName.IsPull() {
|
||||
refType = git.RefTypeBranch // GitHub reports pull request refs as branches
|
||||
}
|
||||
refProtected, err := git_model.IsRefProtected(ctx, run.RepoID, refName)
|
||||
if err != nil {
|
||||
log.Error("GenerateGiteaContext: check protection for ref %q: %v", refName, err)
|
||||
@@ -84,7 +88,7 @@ func GenerateGiteaContext(ctx context.Context, run *actions_model.ActionRun, att
|
||||
"ref": ref, // string, The fully-formed ref of the branch or tag that triggered the workflow run. For workflows triggered by push, this is the branch or tag ref that was pushed. For workflows triggered by pull_request, this is the pull request merge branch. For workflows triggered by release, this is the release tag created. For other triggers, this is the branch or tag ref that triggered the workflow run. This is only set if a branch or tag is available for the event type. The ref given is fully-formed, meaning that for branches the format is refs/heads/<branch_name>, for pull requests it is refs/pull/<pr_number>/merge, and for tags it is refs/tags/<tag_name>. For example, refs/heads/feature-branch-1.
|
||||
"ref_name": refName.ShortName(), // string, The short ref name of the branch or tag that triggered the workflow run. This value matches the branch or tag name shown on GitHub. For example, feature-branch-1.
|
||||
"ref_protected": refProtected, // boolean, true if protection rules are configured for the ref that triggered the workflow run.
|
||||
"ref_type": string(refName.RefType()), // string, The type of ref that triggered the workflow run. Valid values are branch or tag.
|
||||
"ref_type": string(refType), // string, The type of ref that triggered the workflow run. Valid values are branch or tag.
|
||||
"path": "", // string, Path on the runner to the file that sets system PATH variables from workflow commands. This file is unique to the current step and is a different file for each step in a job. For more information, see "Workflow commands for GitHub Actions."
|
||||
"repository": run.Repo.OwnerName + "/" + run.Repo.Name, // string, The owner and repository name. For example, Codertocat/Hello-World.
|
||||
"repository_owner": run.Repo.OwnerName, // string, The repository owner's name. For example, Codertocat.
|
||||
|
||||
@@ -385,7 +385,7 @@ func TestFindTaskNeeds(t *testing.T) {
|
||||
assert.Equal(t, "bbb", ret["job1"].Outputs["output_b"])
|
||||
}
|
||||
|
||||
func TestGenerateGiteaContextPullRequestTarget(t *testing.T) {
|
||||
func TestGenerateGiteaContextPullRequestRefs(t *testing.T) {
|
||||
payload := api.PullRequestPayload{
|
||||
PullRequest: &api.PullRequest{
|
||||
Base: &api.PRBranchInfo{
|
||||
@@ -415,6 +415,9 @@ func TestGenerateGiteaContextPullRequestTarget(t *testing.T) {
|
||||
|
||||
assert.Equal(t, "refs/heads/main", giteaCtx["ref"])
|
||||
assert.Equal(t, "main", giteaCtx["ref_name"])
|
||||
|
||||
run.TriggerEvent, run.Ref = string(actions_module.GithubEventPullRequest), "refs/pull/1/head"
|
||||
assert.Equal(t, "branch", GenerateGiteaContext(t.Context(), run, nil, nil)["ref_type"])
|
||||
}
|
||||
|
||||
func TestGenerateGiteaContextRefProtected(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"uuid"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
actions_module "gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/services/oauth2_provider"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
const OIDCSigningAlgorithm = "RS256"
|
||||
|
||||
var oidcSigningKey struct {
|
||||
sync.Mutex
|
||||
key oauth2_provider.JWTSigningKey
|
||||
}
|
||||
|
||||
// OIDCSigningKey retries after errors, unlike sync.OnceValues, so a transient failure doesn't last until restart
|
||||
func OIDCSigningKey() (oauth2_provider.JWTSigningKey, error) {
|
||||
oidcSigningKey.Lock()
|
||||
defer oidcSigningKey.Unlock()
|
||||
if oidcSigningKey.key == nil {
|
||||
key, err := oauth2_provider.LoadOrCreateAsymmetricKey(filepath.Join(setting.AppDataPath, "jwt", "actions_oidc.pem"), OIDCSigningAlgorithm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if oidcSigningKey.key, err = oauth2_provider.CreateJWTSigningKey(OIDCSigningAlgorithm, key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return oidcSigningKey.key, nil
|
||||
}
|
||||
|
||||
type oidcClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
Audience string `json:"aud"` // a single string like GitHub, jwt.ClaimStrings encodes an array
|
||||
Actor string `json:"actor"`
|
||||
ActorID string `json:"actor_id"`
|
||||
Repository string `json:"repository"`
|
||||
RepositoryID string `json:"repository_id"`
|
||||
RepositoryOwner string `json:"repository_owner"`
|
||||
RepositoryOwnerID string `json:"repository_owner_id"`
|
||||
RepositoryVisibility string `json:"repository_visibility"`
|
||||
RunID string `json:"run_id"`
|
||||
RunNumber string `json:"run_number"`
|
||||
RunAttempt string `json:"run_attempt"`
|
||||
EventName string `json:"event_name"`
|
||||
Ref string `json:"ref"`
|
||||
RefType string `json:"ref_type"`
|
||||
RefProtected string `json:"ref_protected"`
|
||||
SHA string `json:"sha"`
|
||||
BaseRef string `json:"base_ref"`
|
||||
HeadRef string `json:"head_ref"`
|
||||
Workflow string `json:"workflow"`
|
||||
WorkflowRef string `json:"workflow_ref"`
|
||||
WorkflowSHA string `json:"workflow_sha"`
|
||||
JobWorkflowRef string `json:"job_workflow_ref"`
|
||||
JobWorkflowSHA string `json:"job_workflow_sha"`
|
||||
RunnerEnvironment string `json:"runner_environment"`
|
||||
}
|
||||
|
||||
type workflowFile struct {
|
||||
repo, path, ref, sha string
|
||||
}
|
||||
|
||||
func (w workflowFile) String() string {
|
||||
return w.repo + "/" + w.path + "@" + w.ref
|
||||
}
|
||||
|
||||
func OIDCClaimsSupported() []string {
|
||||
claims := []string{"exp", "iat", "iss", "jti", "nbf", "sub"}
|
||||
for field := range reflect.TypeFor[oidcClaims]().Fields() {
|
||||
if name, _, _ := strings.Cut(field.Tag.Get("json"), ","); name != "" {
|
||||
claims = append(claims, name)
|
||||
}
|
||||
}
|
||||
return claims
|
||||
}
|
||||
|
||||
func OIDCIssuer() string {
|
||||
return setting.AppURL + "api/actions/oidc"
|
||||
}
|
||||
|
||||
func taskAllowsOIDCToken(ctx context.Context, task *actions_model.ActionTask) (bool, error) {
|
||||
if task.Job.TokenPermissions == nil || !task.Job.TokenPermissions.IDToken {
|
||||
return false, nil
|
||||
}
|
||||
task.Job.Repo = task.Job.Run.Repo
|
||||
perms, err := actions_model.ComputeTaskTokenPermissions(ctx, task, task.Job.Repo)
|
||||
return perms.IDToken, err
|
||||
}
|
||||
|
||||
func CreateOIDCToken(ctx context.Context, requestToken, audience string) (string, error) {
|
||||
taskID, err := taskIDFromToken(requestToken, true)
|
||||
if err != nil {
|
||||
return "", util.NewPermissionDeniedErrorf("invalid ID token request token")
|
||||
}
|
||||
task, err := actions_model.GetTaskByID(ctx, taskID)
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
return "", util.NewPermissionDeniedErrorf("task %d does not exist", taskID)
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if task.Status != actions_model.StatusRunning && task.Status != actions_model.StatusCancelling { // cleanup steps of cancelled jobs may still need one
|
||||
return "", util.NewPermissionDeniedErrorf("task %d is not active", taskID)
|
||||
}
|
||||
if err := task.LoadJob(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := task.Job.LoadAttributes(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if allowed, err := taskAllowsOIDCToken(ctx, task); err != nil {
|
||||
return "", err
|
||||
} else if !allowed {
|
||||
return "", util.NewPermissionDeniedErrorf("task %d lacks the id-token permission", taskID)
|
||||
}
|
||||
|
||||
claims, err := createOIDCClaims(ctx, task.Job, audience, time.Now())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
key, err := OIDCSigningKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := jwt.NewWithClaims(key.SigningMethod(), claims)
|
||||
key.PreProcessToken(token)
|
||||
return token.SignedString(key.SignKey())
|
||||
}
|
||||
|
||||
func createOIDCClaims(ctx context.Context, job *actions_model.ActionRunJob, audience string, now time.Time) (*oidcClaims, error) {
|
||||
run := job.Run
|
||||
gitCtx := GenerateGiteaContext(ctx, run, nil, job)
|
||||
contextString := func(key string) string {
|
||||
value, _ := gitCtx[key].(string)
|
||||
return value
|
||||
}
|
||||
ref := contextString("ref")
|
||||
|
||||
rootWorkflow, workflowName, err := runWorkflowFile(ctx, run, ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jobWorkflow, err := jobWorkflowFile(ctx, job, rootWorkflow)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// GitHub's immutable format, the IDs keep a recreated owner or repo name from matching
|
||||
subject := fmt.Sprintf("repo:%s@%d/%s@%d", run.Repo.OwnerName, run.Repo.OwnerID, run.Repo.Name, run.RepoID)
|
||||
if run.TriggerEvent == actions_module.GithubEventPullRequest {
|
||||
subject += ":pull_request"
|
||||
} else {
|
||||
subject += ":ref:" + ref
|
||||
}
|
||||
if audience == "" {
|
||||
audience = setting.AppURL + url.PathEscape(run.Repo.OwnerName)
|
||||
}
|
||||
visibility := "internal"
|
||||
if run.Repo.IsPrivate {
|
||||
visibility = "private"
|
||||
} else if run.Repo.Owner.Visibility.IsPublic() {
|
||||
visibility = "public"
|
||||
}
|
||||
|
||||
return &oidcClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: OIDCIssuer(),
|
||||
Subject: subject,
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(5 * time.Minute)),
|
||||
NotBefore: jwt.NewNumericDate(now.Add(-5 * time.Minute)), // backdated like GitHub for relying parties with skewed clocks
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ID: uuid.New().String(),
|
||||
},
|
||||
Audience: audience,
|
||||
Actor: contextString("actor"),
|
||||
ActorID: contextString("actor_id"),
|
||||
Repository: contextString("repository"),
|
||||
RepositoryID: contextString("repository_id"),
|
||||
RepositoryOwner: contextString("repository_owner"),
|
||||
RepositoryOwnerID: contextString("repository_owner_id"),
|
||||
RepositoryVisibility: visibility,
|
||||
RunID: contextString("run_id"),
|
||||
RunNumber: contextString("run_number"),
|
||||
RunAttempt: contextString("run_attempt"),
|
||||
EventName: contextString("event_name"),
|
||||
Ref: ref,
|
||||
RefType: contextString("ref_type"),
|
||||
RefProtected: strconv.FormatBool(gitCtx["ref_protected"] == true),
|
||||
SHA: contextString("sha"),
|
||||
BaseRef: contextString("base_ref"),
|
||||
HeadRef: contextString("head_ref"),
|
||||
Workflow: workflowName,
|
||||
WorkflowRef: rootWorkflow.String(),
|
||||
WorkflowSHA: rootWorkflow.sha,
|
||||
JobWorkflowRef: jobWorkflow.String(),
|
||||
JobWorkflowSHA: jobWorkflow.sha,
|
||||
RunnerEnvironment: "self-hosted",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// runWorkflowFile repeats the directory choice workflow detection made at the recorded commit, and names the workflow like GitHub
|
||||
func runWorkflowFile(ctx context.Context, run *actions_model.ActionRun, ref string) (file workflowFile, name string, err error) {
|
||||
repo, err := repo_model.GetRepositoryByID(ctx, run.WorkflowRepoID)
|
||||
if err != nil {
|
||||
return file, "", err
|
||||
}
|
||||
gitRepo, err := git.OpenRepository(ctx, repo)
|
||||
if err != nil {
|
||||
return file, "", err
|
||||
}
|
||||
defer gitRepo.Close()
|
||||
commit, err := gitRepo.GetCommit(ctx, run.WorkflowCommitSHA)
|
||||
if err != nil {
|
||||
return file, "", err
|
||||
}
|
||||
|
||||
listWorkflows := actions_module.ListWorkflows
|
||||
if run.IsScopedRun {
|
||||
listWorkflows = actions_module.ListScopedWorkflows
|
||||
ref = run.WorkflowCommitSHA // the source repo's branch is not recorded
|
||||
}
|
||||
dir, _, err := listWorkflows(ctx, gitRepo, commit)
|
||||
if err != nil {
|
||||
return file, "", err
|
||||
}
|
||||
file = workflowFile{repo: repo.FullName(), path: path.Join(dir, run.WorkflowID), ref: ref, sha: run.WorkflowCommitSHA}
|
||||
content, err := commit.GetFileContent(ctx, gitRepo, file.path, 1024*1024)
|
||||
if err != nil {
|
||||
return file, "", err
|
||||
}
|
||||
if workflow, err := jobparser.ReadWorkflow([]byte(content)); err == nil && strings.TrimSpace(workflow.Name) != "" {
|
||||
return file, workflow.Name, nil
|
||||
}
|
||||
return file, file.path, nil
|
||||
}
|
||||
|
||||
// jobWorkflowFile lets local reusable workflow calls inherit their caller's ref, like on GitHub
|
||||
func jobWorkflowFile(ctx context.Context, job *actions_model.ActionRunJob, root workflowFile) (workflowFile, error) {
|
||||
if job.ParentJobID == 0 {
|
||||
return root, nil
|
||||
}
|
||||
caller, err := actions_model.GetRunJobByRunAndID(ctx, job.RunID, job.ParentJobID)
|
||||
if err != nil {
|
||||
return workflowFile{}, err
|
||||
}
|
||||
uses, err := ResolveUses(ctx, caller.CallUses)
|
||||
if err != nil {
|
||||
return workflowFile{}, err
|
||||
}
|
||||
if uses.IsLocal() {
|
||||
file, err := jobWorkflowFile(ctx, caller, root)
|
||||
file.path, file.sha = uses.Path, job.WorkflowSourceCommitSHA
|
||||
return file, err
|
||||
}
|
||||
repo, err := repo_model.GetRepositoryByID(ctx, job.WorkflowSourceRepoID)
|
||||
if err != nil {
|
||||
return workflowFile{}, err
|
||||
}
|
||||
ref := cmp.Or(caller.ReusableWorkflowRef, job.WorkflowSourceCommitSHA) // callers expanded before the ref was recorded
|
||||
return workflowFile{repo: repo.FullName(), path: uses.Path, ref: ref, sha: job.WorkflowSourceCommitSHA}, nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/unittest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestJobWorkflowFile_LocalCallsInheritCallerRefs(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
chain := buildCallerChain(t, "./.gitea/workflows/a.yml", "user2/repo1/.gitea/workflows/b.yml@master", "./.gitea/workflows/c.yml")
|
||||
chain[1].ReusableWorkflowRef = "refs/heads/master"
|
||||
for _, job := range chain[1:] {
|
||||
job.WorkflowSourceRepoID, job.WorkflowSourceCommitSHA = 1, "lib-sha"
|
||||
_, err := actions_model.UpdateRunJob(t.Context(), job, nil, "workflow_source_repo_id", "workflow_source_commit_sha", "reusable_workflow_ref")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
root := workflowFile{repo: "user2/repo1", ref: "refs/heads/main"}
|
||||
|
||||
file, err := jobWorkflowFile(t.Context(), chain[1], root)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "user2/repo1/.gitea/workflows/a.yml@refs/heads/main", file.String())
|
||||
|
||||
file, err = jobWorkflowFile(t.Context(), &actions_model.ActionRunJob{RunID: chain[2].RunID, ParentJobID: chain[2].ID, WorkflowSourceRepoID: 1, WorkflowSourceCommitSHA: "lib-sha"}, root)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, workflowFile{repo: "user2/repo1", path: ".gitea/workflows/c.yml", ref: "refs/heads/master", sha: "lib-sha"}, file)
|
||||
}
|
||||
@@ -59,7 +59,7 @@ func parseRawPermissionsExplicit(rawPerms *yaml.Node) *repo_model.ActionsTokenPe
|
||||
case "read-all":
|
||||
return new(repo_model.MakeActionsTokenPermissions(perm.AccessModeRead))
|
||||
case "write-all":
|
||||
return new(repo_model.MakeActionsTokenPermissions(perm.AccessModeWrite))
|
||||
return new(repo_model.MakeWriteAllActionsTokenPermissions())
|
||||
default:
|
||||
// Explicit but unrecognized scalar: return all-none permissions.
|
||||
return new(repo_model.MakeActionsTokenPermissions(perm.AccessModeNone))
|
||||
@@ -113,10 +113,12 @@ func parseRawPermissionsExplicit(rawPerms *yaml.Node) *repo_model.ActionsTokenPe
|
||||
result.UnitAccessModes[unit.TypeReleases] = mode
|
||||
case "projects":
|
||||
result.UnitAccessModes[unit.TypeProjects] = mode
|
||||
case "id-token":
|
||||
result.IDToken = mode == perm.AccessModeWrite
|
||||
// Scopes github supports but gitea does not, see url for details
|
||||
// https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax
|
||||
case "artifact-metadata", "attestations", "checks", "deployments",
|
||||
"id-token", "models", "discussions", "pages", "security-events", "statuses":
|
||||
"models", "discussions", "pages", "security-events", "statuses":
|
||||
// not supported
|
||||
default:
|
||||
setting.PanicInDevOrTesting("Unrecognized permission scope: %s", scope)
|
||||
|
||||
@@ -78,6 +78,7 @@ func TestParseRawPermissions_WriteAll(t *testing.T) {
|
||||
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeActions])
|
||||
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeWiki])
|
||||
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeProjects])
|
||||
assert.True(t, result.IDToken)
|
||||
}
|
||||
|
||||
func TestParseRawPermissions_IndividualScopes(t *testing.T) {
|
||||
@@ -89,6 +90,7 @@ packages: write
|
||||
actions: read
|
||||
wiki: write
|
||||
projects: none
|
||||
id-token: write
|
||||
`
|
||||
var rawPerms yaml.Node
|
||||
err := yaml.Unmarshal([]byte(yamlContent), &rawPerms)
|
||||
@@ -104,6 +106,7 @@ projects: none
|
||||
assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeActions])
|
||||
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeWiki])
|
||||
assert.Equal(t, perm.AccessModeNone, result.UnitAccessModes[unit.TypeProjects])
|
||||
assert.True(t, result.IDToken)
|
||||
}
|
||||
|
||||
func TestParseRawPermissions_Priority(t *testing.T) {
|
||||
|
||||
@@ -561,6 +561,7 @@ func cloneRunJobForAttempt(templateJob *actions_model.ActionRunJob, attempt *act
|
||||
IsReusableCaller: templateJob.IsReusableCaller,
|
||||
CallUses: templateJob.CallUses,
|
||||
ReusableWorkflowContent: slices.Clone(templateJob.ReusableWorkflowContent),
|
||||
ReusableWorkflowRef: templateJob.ReusableWorkflowRef,
|
||||
CallSecrets: templateJob.CallSecrets,
|
||||
CallPayload: templateJob.CallPayload,
|
||||
IsExpanded: templateJob.IsExpanded,
|
||||
|
||||
@@ -51,11 +51,17 @@ func checkRunJobLimit(ctx context.Context, runID, attemptID int64, adding int) e
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadReusableWorkflowSource resolves the workflow file referenced by a caller's `uses:` and returns its raw bytes,
|
||||
// along with the (repo_id, commit_sha) the file was loaded from.
|
||||
func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRun, caller *actions_model.ActionRunJob, ref *model.ReusableWorkflowUses) (content []byte, sourceRepoID int64, sourceCommitSHA string, err error) {
|
||||
type reusableWorkflowSource struct {
|
||||
content []byte
|
||||
repoID int64
|
||||
commitSHA string
|
||||
ref string // fully qualified, or the commit when `uses:` named no exact branch or tag
|
||||
}
|
||||
|
||||
// loadReusableWorkflowSource resolves the workflow file referenced by a caller's `uses:`.
|
||||
func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRun, caller *actions_model.ActionRunJob, ref *model.ReusableWorkflowUses) (*reusableWorkflowSource, error) {
|
||||
if err := run.LoadAttributes(ctx); err != nil {
|
||||
return nil, 0, "", err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch {
|
||||
@@ -63,39 +69,31 @@ func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRu
|
||||
// `./` and `$/` are resolved against the workflow file containing the `uses:` - i.e. the caller's own source repo + commit.
|
||||
callerRepo, err := repo_model.GetRepositoryByID(ctx, caller.WorkflowSourceRepoID)
|
||||
if err != nil {
|
||||
return nil, 0, "", fmt.Errorf("look up caller source repo %d: %w", caller.WorkflowSourceRepoID, err)
|
||||
return nil, fmt.Errorf("look up caller source repo %d: %w", caller.WorkflowSourceRepoID, err)
|
||||
}
|
||||
sourceCommitSHA := resolveSameRepoWorkflowSourceCommit(run, caller)
|
||||
if sourceCommitSHA != caller.WorkflowSourceCommitSHA {
|
||||
log.Warn("run %d (pull_request_target) records workflow source commit %s, resolving %q at base commit %s instead", run.ID, caller.WorkflowSourceCommitSHA, ref.Path, sourceCommitSHA)
|
||||
}
|
||||
bytes, resolvedSHA, err := readWorkflowFromRepo(ctx, callerRepo, sourceCommitSHA, ref.Path)
|
||||
if err != nil {
|
||||
return nil, 0, "", err
|
||||
}
|
||||
return bytes, callerRepo.ID, resolvedSHA, nil
|
||||
return readWorkflowFromRepo(ctx, callerRepo, sourceCommitSHA, ref.Path)
|
||||
|
||||
default:
|
||||
unavailable := fmt.Errorf("reusable workflow repository %s/%s does not exist or is not readable", ref.Owner, ref.Repo) // the same for both, so a run cannot tell whether a private one exists
|
||||
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, ref.Owner, ref.Repo)
|
||||
if repo_model.IsErrRepoNotExist(err) {
|
||||
return nil, 0, "", unavailable
|
||||
return nil, unavailable
|
||||
}
|
||||
if err != nil {
|
||||
return nil, 0, "", fmt.Errorf("look up cross-repo workflow source %q: %w", ref.Owner+"/"+ref.Repo, err)
|
||||
return nil, fmt.Errorf("look up cross-repo workflow source %q: %w", ref.Owner+"/"+ref.Repo, err)
|
||||
}
|
||||
ok, err := access_model.CanReadWorkflowCrossRepo(ctx, repo, run)
|
||||
if err != nil {
|
||||
return nil, 0, "", err
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, 0, "", unavailable
|
||||
return nil, unavailable
|
||||
}
|
||||
bytes, resolvedSHA, err := readWorkflowFromRepo(ctx, repo, ref.Ref, ref.Path)
|
||||
if err != nil {
|
||||
return nil, 0, "", err
|
||||
}
|
||||
return bytes, repo.ID, resolvedSHA, nil
|
||||
return readWorkflowFromRepo(ctx, repo, ref.Ref, ref.Path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,12 +121,12 @@ func validateCalledWorkflows(ctx context.Context, run *actions_model.ActionRun,
|
||||
if err != nil {
|
||||
return fmt.Errorf("job %s: %w", id, err)
|
||||
}
|
||||
called, repoID, commitSHA, err := loadReusableWorkflowSource(ctx, run, source, ref)
|
||||
called, err := loadReusableWorkflowSource(ctx, run, source, ref)
|
||||
if err != nil {
|
||||
return fmt.Errorf("job %s: %w", id, err)
|
||||
}
|
||||
if _, err = jobparser.ValidateWorkflowStatic(called); err == nil {
|
||||
err = validate(called, &actions_model.ActionRunJob{WorkflowSourceRepoID: repoID, WorkflowSourceCommitSHA: commitSHA}, level+1)
|
||||
if _, err = jobparser.ValidateWorkflowStatic(called.content); err == nil {
|
||||
err = validate(called.content, &actions_model.ActionRunJob{WorkflowSourceRepoID: called.repoID, WorkflowSourceCommitSHA: called.commitSHA}, level+1)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("job %s: Error from called workflow %s: %w", id, uses, err)
|
||||
@@ -156,23 +154,42 @@ func resolveSameRepoWorkflowSourceCommit(run *actions_model.ActionRun, caller *a
|
||||
return caller.WorkflowSourceCommitSHA
|
||||
}
|
||||
|
||||
// readWorkflowFromRepo loads a workflow file from `repo` at `refOrSHA` and returns its content plus the resolved commit SHA.
|
||||
func readWorkflowFromRepo(ctx context.Context, repo *repo_model.Repository, refOrSHA, path string) ([]byte, string, error) {
|
||||
// readWorkflowFromRepo loads a workflow file from `repo` at `refOrSHA`.
|
||||
func readWorkflowFromRepo(ctx context.Context, repo *repo_model.Repository, refOrSHA, path string) (*reusableWorkflowSource, error) {
|
||||
gitRepo, err := git.OpenRepository(ctx, repo)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("open repo %s: %w", repo.FullName(), err)
|
||||
return nil, fmt.Errorf("open repo %s: %w", repo.FullName(), err)
|
||||
}
|
||||
defer gitRepo.Close()
|
||||
|
||||
commit, err := gitRepo.GetCommit(ctx, refOrSHA)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("get commit %q in %s: %w", refOrSHA, repo.FullName(), err)
|
||||
return nil, fmt.Errorf("get commit %q in %s: %w", refOrSHA, repo.FullName(), err)
|
||||
}
|
||||
str, err := commit.GetFileContent(ctx, gitRepo, path, 1024*1024)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("read %s@%s:%s: %w", repo.FullName(), refOrSHA, path, err)
|
||||
return nil, fmt.Errorf("read %s@%s:%s: %w", repo.FullName(), refOrSHA, path, err)
|
||||
}
|
||||
return []byte(str), commit.ID.String(), nil
|
||||
sha := commit.ID.String()
|
||||
return &reusableWorkflowSource{content: []byte(str), repoID: repo.ID, commitSHA: sha, ref: qualifyRef(ctx, repo, gitRepo, refOrSHA, sha)}, nil
|
||||
}
|
||||
|
||||
// qualifyRef names the exact ref `uses:` resolved to, tags first like Git, so a same-named tag can't pose as a branch
|
||||
func qualifyRef(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, ref, sha string) string {
|
||||
if ref == sha {
|
||||
return sha
|
||||
}
|
||||
names := []string{git.TagPrefix + ref, git.BranchPrefix + ref}
|
||||
if strings.HasPrefix(ref, "refs/") {
|
||||
names = []string{ref}
|
||||
}
|
||||
for _, name := range names {
|
||||
if git.IsReferenceExist(ctx, repo, name) { // GetCommit alone would also resolve a tag named like a missing branch
|
||||
commit, err := gitRepo.GetCommit(ctx, name)
|
||||
return util.Iif(err == nil && commit.ID.String() == sha, name, sha)
|
||||
}
|
||||
}
|
||||
return sha
|
||||
}
|
||||
|
||||
// checkCallerChain walks `caller`'s ancestor chain (via ParentJobID) and:
|
||||
@@ -264,19 +281,19 @@ func expandReusableWorkflowCaller(ctx context.Context, run *actions_model.Action
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve uses %q: %w", parsedJob.Uses, err)
|
||||
}
|
||||
content, contentSourceRepoID, contentSourceCommitSHA, err := loadReusableWorkflowSource(ctx, run, caller, ref)
|
||||
source, err := loadReusableWorkflowSource(ctx, run, caller, ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkResolvedCallerCycle(ctx, caller, contentSourceRepoID, contentSourceCommitSHA, ref.Path); err != nil {
|
||||
if err := checkResolvedCallerCycle(ctx, caller, source.repoID, source.commitSHA, ref.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := jobparser.ValidateWorkflowStatic(content); err != nil {
|
||||
if _, err := jobparser.ValidateWorkflowStatic(source.content); err != nil {
|
||||
return fmt.Errorf("invalid called workflow: %w", err)
|
||||
}
|
||||
|
||||
// 4. Parse the called workflow's spec (used by both secret validation and input evaluation).
|
||||
wcSpec, err := jobparser.ParseWorkflowCallConfig(content)
|
||||
wcSpec, err := jobparser.ParseWorkflowCallConfig(source.content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse called workflow spec: %w", err)
|
||||
}
|
||||
@@ -304,7 +321,8 @@ func expandReusableWorkflowCaller(ctx context.Context, run *actions_model.Action
|
||||
}
|
||||
caller.CallSecrets = string(mapBytes)
|
||||
}
|
||||
caller.ReusableWorkflowContent = content
|
||||
caller.ReusableWorkflowContent = source.content
|
||||
caller.ReusableWorkflowRef = source.ref
|
||||
|
||||
// 6. Evaluate caller's `with:`, then match against the callee schema.
|
||||
workflowCallInputs := map[string]any{}
|
||||
@@ -358,14 +376,14 @@ func expandReusableWorkflowCaller(ctx context.Context, run *actions_model.Action
|
||||
}
|
||||
|
||||
// 9. We own the expansion: insert the direct children.
|
||||
if err := insertCallerChildren(ctx, run, attempt, caller, content, contentSourceRepoID, contentSourceCommitSHA, vars, jobInputs); err != nil {
|
||||
if err := insertCallerChildren(ctx, run, attempt, caller, source.content, source.repoID, source.commitSHA, vars, jobInputs); err != nil {
|
||||
// On failure, undo the partial expansion so an error return always leaves the caller unexpanded and childless.
|
||||
return errors.Join(err, undoExpansion(ctx, caller))
|
||||
}
|
||||
|
||||
// 10. Persist the remaining caller metadata (the row is already ours via the claim above).
|
||||
caller.CallPayload = string(callPayload)
|
||||
if _, err := actions_model.UpdateRunJob(ctx, caller, nil, "call_secrets", "reusable_workflow_content", "call_payload"); err != nil {
|
||||
if _, err := actions_model.UpdateRunJob(ctx, caller, nil, "call_secrets", "reusable_workflow_content", "reusable_workflow_ref", "call_payload"); err != nil {
|
||||
return errors.Join(fmt.Errorf("persist caller %d expansion metadata: %w", caller.ID, err), undoExpansion(ctx, caller))
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -10,8 +10,10 @@ import (
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
actions_module "gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
@@ -265,7 +267,7 @@ func TestLoadReusableWorkflowSourceFailsAlikeForMissingAndPrivateRepo(t *testing
|
||||
|
||||
run := &actions_model.ActionRun{RepoID: 4, TriggerUserID: 1}
|
||||
for _, repoName := range []string{"missing", "repo3"} {
|
||||
_, _, _, err := loadReusableWorkflowSource(t.Context(), run, nil, &model.ReusableWorkflowUses{Owner: "org3", Repo: repoName, Path: ".gitea/workflows/build.yml", Ref: "main"})
|
||||
_, err := loadReusableWorkflowSource(t.Context(), run, nil, &model.ReusableWorkflowUses{Owner: "org3", Repo: repoName, Path: ".gitea/workflows/build.yml", Ref: "main"})
|
||||
assert.EqualError(t, err, "reusable workflow repository org3/"+repoName+" does not exist or is not readable")
|
||||
}
|
||||
}
|
||||
@@ -412,3 +414,18 @@ func TestResolveSameRepoWorkflowSourceCommit(t *testing.T) {
|
||||
assert.Equal(t, "tag-v1-sha", got)
|
||||
})
|
||||
}
|
||||
|
||||
func TestQualifyRef(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
gitRepo, err := git.OpenRepository(t.Context(), repo)
|
||||
require.NoError(t, err)
|
||||
defer gitRepo.Close()
|
||||
masterSHA := "65f1bf27bc3bf70f64657658635e66094edbcb4d"
|
||||
assert.Equal(t, "refs/heads/master", qualifyRef(t.Context(), repo, gitRepo, "master", masterSHA))
|
||||
assert.Equal(t, "refs/heads/master", qualifyRef(t.Context(), repo, gitRepo, "refs/heads/master", masterSHA))
|
||||
assert.Equal(t, "refs/tags/v1.1", qualifyRef(t.Context(), repo, gitRepo, "v1.1", masterSHA))
|
||||
assert.Equal(t, masterSHA, qualifyRef(t.Context(), repo, gitRepo, masterSHA, masterSHA))
|
||||
assert.Equal(t, masterSHA, qualifyRef(t.Context(), repo, gitRepo, "refs/heads/master~0", masterSHA))
|
||||
assert.Equal(t, "other-sha", qualifyRef(t.Context(), repo, gitRepo, "master", "other-sha"))
|
||||
}
|
||||
|
||||
@@ -200,6 +200,16 @@ func generateTaskContext(ctx context.Context, t *actions_model.ActionTask) (*str
|
||||
}
|
||||
gitCtx["token"] = t.Token
|
||||
gitCtx["gitea_runtime_token"] = giteaRuntimeToken
|
||||
if allowed, err := taskAllowsOIDCToken(ctx, t); err != nil {
|
||||
return nil, err
|
||||
} else if allowed {
|
||||
requestToken, err := signActionsClaims(actionsClaims{Scp: idTokenRequestScope, TaskID: t.ID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gitCtx["actions_id_token_request_url"] = OIDCIssuer() + "/token?" // actions/core appends "&audience=..."
|
||||
gitCtx["actions_id_token_request_token"] = requestToken
|
||||
}
|
||||
|
||||
return structpb.NewStruct(gitCtx)
|
||||
}
|
||||
|
||||
@@ -108,6 +108,9 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
|
||||
refName = git.RefNameFromBranch(ref)
|
||||
runTargetCommit, err = gitRepo.GetBranchCommit(ctx, ref)
|
||||
}
|
||||
if err == nil && !git.IsReferenceExist(ctx, repo, refName.String()) { // the lookups also resolve a tag named like a missing branch
|
||||
err = util.ErrNotExist
|
||||
}
|
||||
if err != nil {
|
||||
return 0, util.ErrorWrapTranslatable(
|
||||
util.NewNotExistErrorf("ref %q doesn't exist", ref),
|
||||
|
||||
@@ -12,13 +12,14 @@ import (
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
@@ -321,7 +322,7 @@ func InitSigningKey() error {
|
||||
case "ES512":
|
||||
fallthrough
|
||||
case "EdDSA":
|
||||
key, err = loadOrCreateAsymmetricKey()
|
||||
key, err = LoadOrCreateAsymmetricKey(setting.OAuth2.JWTSigningPrivateKeyFile, setting.OAuth2.JWTSigningAlgorithm)
|
||||
default:
|
||||
return ErrInvalidAlgorithmType{setting.OAuth2.JWTSigningAlgorithm}
|
||||
}
|
||||
@@ -340,24 +341,24 @@ func InitSigningKey() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadOrCreateAsymmetricKey checks if the configured private key exists.
|
||||
// If it does not exist a new random key gets generated and saved on the configured path.
|
||||
func loadOrCreateAsymmetricKey() (any, error) {
|
||||
keyPath := setting.OAuth2.JWTSigningPrivateKeyFile
|
||||
|
||||
func LoadOrCreateAsymmetricKey(keyPath, algorithm string) (any, error) {
|
||||
isExist, err := util.IsExist(keyPath)
|
||||
if err != nil {
|
||||
log.Fatal("Unable to check if %s exists. Error: %v", keyPath, err)
|
||||
return nil, fmt.Errorf("unable to check if %s exists: %w", keyPath, err)
|
||||
}
|
||||
if !isExist {
|
||||
err := func() error {
|
||||
key, err := func() (any, error) {
|
||||
switch {
|
||||
case strings.HasPrefix(setting.OAuth2.JWTSigningAlgorithm, "RS"):
|
||||
case strings.HasPrefix(algorithm, "RS"):
|
||||
return rsa.GenerateKey(rand.Reader, 4096)
|
||||
case setting.OAuth2.JWTSigningAlgorithm == "EdDSA":
|
||||
case algorithm == "EdDSA":
|
||||
_, pk, err := ed25519.GenerateKey(rand.Reader)
|
||||
return pk, err
|
||||
case algorithm == "ES384":
|
||||
return ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
case algorithm == "ES512":
|
||||
return ecdsa.GenerateKey(elliptic.P521(), rand.Reader)
|
||||
default:
|
||||
return ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
}
|
||||
@@ -377,21 +378,22 @@ func loadOrCreateAsymmetricKey() (any, error) {
|
||||
return err
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(keyPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||
f, err := os.CreateTemp(filepath.Dir(keyPath), filepath.Base(keyPath)+".*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err = f.Close(); err != nil {
|
||||
log.Error("Close: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return pem.Encode(f, privateKeyPEM)
|
||||
defer func() { _ = util.RemoveWithRetry(f.Name()) }()
|
||||
if err := errors.Join(pem.Encode(f, privateKeyPEM), f.Close()); err != nil {
|
||||
return err
|
||||
}
|
||||
// publish complete keys only, without replacing one another instance sharing the data path created first
|
||||
if err := os.Link(f.Name(), keyPath); err != nil && !errors.Is(err, fs.ErrExist) {
|
||||
return util.RenameWithRetry(f.Name(), keyPath) // filesystems without hard links
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
if err != nil {
|
||||
log.Fatal("Error generating private key: %v", err)
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("error generating private key: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,9 @@ package oauth2_provider
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"math/big"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
@@ -18,19 +17,20 @@ import (
|
||||
|
||||
func TestECDSASigningKeyToJWK(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
curve elliptic.Curve
|
||||
signingMethod jwt.SigningMethod
|
||||
expectedAlg string
|
||||
expectedCrv string
|
||||
coordLen int
|
||||
}{
|
||||
{elliptic.P256(), jwt.SigningMethodES256, "ES256", "P-256", 32},
|
||||
{elliptic.P384(), jwt.SigningMethodES384, "ES384", "P-384", 48},
|
||||
{elliptic.P521(), jwt.SigningMethodES512, "ES512", "P-521", 66},
|
||||
{jwt.SigningMethodES256, "ES256", "P-256", 32},
|
||||
{jwt.SigningMethodES384, "ES384", "P-384", 48},
|
||||
{jwt.SigningMethodES512, "ES512", "P-521", 66},
|
||||
} {
|
||||
t.Run(tc.expectedCrv, func(t *testing.T) {
|
||||
privKey, err := ecdsa.GenerateKey(tc.curve, rand.Reader)
|
||||
key, err := LoadOrCreateAsymmetricKey(filepath.Join(t.TempDir(), "key.pem"), tc.expectedAlg)
|
||||
require.NoError(t, err)
|
||||
privKey, ok := key.(*ecdsa.PrivateKey)
|
||||
require.True(t, ok)
|
||||
|
||||
signingKey, err := newECDSASingingKey(tc.signingMethod, privKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -72,6 +72,25 @@
|
||||
"UnitDisplayDesc" (ctx.Locale.Tr "actions.unit.desc")
|
||||
"UnitAccessMode" (index $.MaxTokenPermissions.UnitAccessModes ctx.Consts.RepoUnitTypeActions)
|
||||
)}}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ctx.Locale.Tr "actions.general.token_permissions.id_token"}}</strong>
|
||||
<div class="help">{{ctx.Locale.Tr "actions.general.token_permissions.id_token.description"}}</div>
|
||||
</td>
|
||||
<td class="tw-text-center">
|
||||
<div class="ui radio checkbox">
|
||||
<input type="radio" name="max_id_token" value="none" {{if not $.MaxTokenPermissions.IDToken}}checked{{end}} title="{{ctx.Locale.Tr "org.teams.none_access"}}">
|
||||
<label></label>
|
||||
</div>
|
||||
</td>
|
||||
<td></td>
|
||||
<td class="tw-text-center">
|
||||
<div class="ui radio checkbox">
|
||||
<input type="radio" name="max_id_token" value="write" {{if $.MaxTokenPermissions.IDToken}}checked{{end}} title="{{ctx.Locale.Tr "org.teams.write_access"}}">
|
||||
<label></label>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -533,7 +533,7 @@ jobs:
|
||||
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, string(git.RefName(actionRun.Ref).RefType()), gtCtx["ref_type"].GetStringValue())
|
||||
assert.Equal(t, "branch", 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())
|
||||
@@ -625,7 +625,7 @@ jobs:
|
||||
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, string(git.RefName(actionRun.Ref).RefType()), gtCtx["ref_type"].GetStringValue())
|
||||
assert.Equal(t, "branch", 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())
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||
actions_model "gitea.dev/models/actions"
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
actions_service "gitea.dev/services/actions"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestActionsOIDC(t *testing.T) {
|
||||
onGiteaRun(t, func(t *testing.T, _ *url.URL) {
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
token := getTokenForLoggedInUser(t, loginUser(t, user2.Name), auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
|
||||
repo := createActionsTestRepo(t, token, "actions-oidc", false)
|
||||
runner := newMockRunner()
|
||||
runner.registerAsRepoRunner(t, user2.Name, repo.Name, "mock-runner", []string{"ubuntu-latest"}, false)
|
||||
|
||||
workflowPath := ".gitea/workflows/oidc.yml"
|
||||
createWorkflowFile(t, token, user2.Name, repo.Name, workflowPath, getWorkflowCreateFileOptions(user2, repo.DefaultBranch, "create "+workflowPath, `name: deploy
|
||||
on: push
|
||||
jobs:
|
||||
plain:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo
|
||||
oidc:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- run: echo
|
||||
`))
|
||||
tasks := map[string]*runnerv1.Task{}
|
||||
for range 2 {
|
||||
task := runner.fetchTask(t)
|
||||
tasks[task.Context.GetFields()["job"].GetStringValue()] = task
|
||||
}
|
||||
assert.NotContains(t, tasks["plain"].Context.GetFields(), "actions_id_token_request_token")
|
||||
taskContext := tasks["oidc"].Context.GetFields()
|
||||
requestToken := taskContext["actions_id_token_request_token"].GetStringValue()
|
||||
tokenURL, err := url.Parse(taskContext["actions_id_token_request_url"].GetStringValue() + "&audience=sts.example.com")
|
||||
require.NoError(t, err)
|
||||
requestIDToken := func(bearer string, status int) *httptest.ResponseRecorder {
|
||||
return MakeRequest(t, NewRequest(t, "GET", tokenURL.RequestURI()).AddTokenAuth(bearer), status)
|
||||
}
|
||||
|
||||
resp := MakeRequest(t, NewRequest(t, "GET", "/api/actions/oidc/.well-known/openid-configuration"), http.StatusOK)
|
||||
discovery := DecodeJSON(t, resp, &struct {
|
||||
JWKSURI string `json:"jwks_uri"`
|
||||
ClaimsSupported []string `json:"claims_supported"`
|
||||
}{})
|
||||
assert.Contains(t, discovery.ClaimsSupported, "job_workflow_ref")
|
||||
jwksURL, err := url.Parse(discovery.JWKSURI)
|
||||
require.NoError(t, err)
|
||||
resp = MakeRequest(t, NewRequest(t, "GET", jwksURL.RequestURI()), http.StatusOK)
|
||||
jwk := DecodeJSON(t, resp, &struct{ Keys []map[string]string }{}).Keys[0]
|
||||
require.Equal(t, "AQAB", jwk["e"])
|
||||
modulus, err := base64.RawURLEncoding.DecodeString(jwk["n"])
|
||||
require.NoError(t, err)
|
||||
|
||||
claims := jwt.MapClaims{}
|
||||
idToken := DecodeJSON(t, requestIDToken(requestToken, http.StatusOK), &struct{ Value string }{}).Value
|
||||
_, err = jwt.ParseWithClaims(idToken, claims, func(*jwt.Token) (any, error) {
|
||||
return &rsa.PublicKey{N: new(big.Int).SetBytes(modulus), E: 65537}, nil
|
||||
}, jwt.WithIssuer(actions_service.OIDCIssuer()), jwt.WithAudience("sts.example.com"))
|
||||
require.NoError(t, err)
|
||||
workflowRef := repo.FullName + "/" + workflowPath + "@refs/heads/" + repo.DefaultBranch
|
||||
assert.Equal(t, fmt.Sprintf("repo:%s@%d/%s@%d:ref:refs/heads/%s", user2.Name, user2.ID, repo.Name, repo.ID, repo.DefaultBranch), claims["sub"])
|
||||
assert.Equal(t, "sts.example.com", claims["aud"])
|
||||
assert.Equal(t, "deploy", claims["workflow"])
|
||||
assert.Equal(t, workflowRef, claims["workflow_ref"])
|
||||
assert.Equal(t, workflowRef, claims["job_workflow_ref"])
|
||||
assert.Equal(t, "public", claims["repository_visibility"])
|
||||
|
||||
requestIDToken(taskContext["gitea_runtime_token"].GetStringValue(), http.StatusUnauthorized)
|
||||
_, job, _ := getTaskAndJobAndRunByTaskID(t, tasks["oidc"].Id)
|
||||
job.TokenPermissions.IDToken = false
|
||||
_, err = actions_model.UpdateRunJob(t.Context(), job, nil, "token_permissions")
|
||||
require.NoError(t, err)
|
||||
requestIDToken(requestToken, http.StatusUnauthorized)
|
||||
job.TokenPermissions.IDToken = true
|
||||
_, err = actions_model.UpdateRunJob(t.Context(), job, nil, "token_permissions")
|
||||
require.NoError(t, err)
|
||||
runner.execTask(t, tasks["oidc"], &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
|
||||
requestIDToken(requestToken, http.StatusUnauthorized)
|
||||
})
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
actions_module "gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/commitstatus"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
@@ -992,6 +993,13 @@ jobs:
|
||||
CommitSHA: branch.CommitID,
|
||||
})
|
||||
assert.NotNil(t, run)
|
||||
|
||||
_, _, err = gitcmd.NewCommand("update-ref").AddDynamicArguments("refs/tags/refs/heads/spoof", branch.CommitID).WithRepo(repo).RunStdString(t.Context())
|
||||
require.NoError(t, err)
|
||||
values.Set("ref", "spoof")
|
||||
req = NewRequestWithURLValues(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/workflows/dispatch.yml/dispatches", repo.FullName()), values).
|
||||
AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user