// Copyright 2025 The Forgejo Authors. All rights reserved. // SPDX-License-Identifier: GPL-3.0-or-later package actions import ( "context" "fmt" "io" repo_model "forgejo.org/models/repo" user_model "forgejo.org/models/user" "forgejo.org/modules/git" "forgejo.org/modules/gitrepo" "code.forgejo.org/forgejo/runner/v12/act/jobparser" "code.forgejo.org/forgejo/runner/v12/act/model" ) type CleanupFunc func() // Evaluate whether we want to expand reusable workflows to their internal workflows. If the job has defined `runs-on` // labels, then we will not expand them -- this maintains the legacy behaviour from before reusable workflow expansion. // If `runs-on` is absent then we will attempt to expand the job. func expandForJob(job *jobparser.Job) bool { return len(job.RunsOn()) == 0 } // Provide a closure for `jobparser.ExpandLocalReusableWorkflows` which resolves reusable workflow references local to // the given commit. A reusable workflow reference is a job with a `uses: ./.forgejo/workflows/some-path.yaml`, and // resolving it involves reading the target file in the target commit and returning the file contents. // // See `expandForJob` for information about jobs that are exempt from expansion. var expandLocalReusableWorkflows = func(commit *git.Commit) jobparser.LocalWorkflowFetcher { return func(job *jobparser.Job, path string) ([]byte, error) { if !expandForJob(job) { return nil, jobparser.ErrUnsupportedReusableWorkflowFetch } blob, err := commit.GetBlobByPath(path) if err != nil { return nil, fmt.Errorf("expanding reusable workflow failed to access path %s: %w", path, err) } reader, err := blob.DataAsync() if err != nil { return nil, fmt.Errorf("expanding reusable workflow failed to read path %s: %w", path, err) } content, err := io.ReadAll(reader) if err != nil { return nil, fmt.Errorf("expanding reusable workflow failed to read path %s: %w", path, err) } return content, nil } } // Provide a closure for `jobparser.ExpandLocalReusableWorkflows` which resolves reusable workflow references local to // the given repo & commit SHA. This variation lazily opens the target git repo and reads the commit only when a local // reusable workflow is needed, and then caches the commit if multiple workflows need to be read. A cleanup function is // also returned that will close the open git repo, and should be `defer` executed. // // See `expandForJob` for information about jobs that are exempt from expansion. var lazyRepoExpandLocalReusableWorkflow = func(ctx context.Context, repoID int64, commitSHA string) (jobparser.LocalWorkflowFetcher, CleanupFunc) { // In the event that local reusable workflows (eg. `uses: ./.forgejo/workflows/reusable.yml`) are present, we'll // need to read the commit of the repo to resolve that reference. But most workflows don't do this, so save the // effort of opening the git repo and fetching the schedule's `CommitSHA` commit if it's not necessary by wrapping // that logic in a caching closure, `getGitCommit`. var innerFetcher jobparser.LocalWorkflowFetcher var gitRepo *git.Repository cleanupFunc := func() { if gitRepo != nil { gitRepo.Close() } } fetcher := func(job *jobparser.Job, path string) ([]byte, error) { if !expandForJob(job) { return nil, jobparser.ErrUnsupportedReusableWorkflowFetch } if innerFetcher != nil { content, err := innerFetcher(job, path) return content, err } repo, err := repo_model.GetRepositoryByID(ctx, repoID) if err != nil { return nil, fmt.Errorf("expanding reusable workflow failed to get repo: %w", err) } gitRepo, err = gitrepo.OpenRepository(ctx, repo) // ensure this keeps reference to the outer closure's `gitRepo`, not a local definition if err != nil { return nil, fmt.Errorf("expanding reusable workflow failed to open repo: %w", err) } commit, err := gitRepo.GetCommit(commitSHA) if err != nil { return nil, fmt.Errorf("expanding reusable workflow failed to open commit %q on repo %s: %w", commitSHA, repo.FullName(), err) } innerFetcher = expandLocalReusableWorkflows(commit) content, err := innerFetcher(job, path) return content, err } return fetcher, cleanupFunc } // Standard function for `jobparser.ExpandInstanceReusableWorkflows` which resolves reusable workflow references on the // same Forgejo instance, but in a specific repo. For example, `uses: // some-org/some-repo/.forgejo/workflows/some-path.yaml@ref`. Resolving it involves reading the target file in the // target repo & commit and returning the file contents. // // See `expandForJob` for information about jobs that are exempt from expansion. var expandInstanceReusableWorkflows = func(ctx context.Context) jobparser.InstanceWorkflowFetcher { return func(job *jobparser.Job, ref *model.NonLocalReusableWorkflowReference) ([]byte, error) { if !expandForJob(job) { return nil, jobparser.ErrUnsupportedReusableWorkflowFetch } owner, err := user_model.GetUserByName(ctx, ref.Org) // Reusable workflows don't currently support access to any private repos -- that's implemented here as well, // although in the future it might be possible to use context information about the executing workflow to // broaden access (eg. repos within an org could access other repos within the same org, perhaps). if (err != nil && user_model.IsErrUserNotExist(err)) || !owner.Visibility.IsPublic() { // Same error message is returned for non-existing & non-visible to avoid information leak return nil, fmt.Errorf("expanding reusable workflow failed to access user %s: user does not exist", ref.Org) } else if err != nil { return nil, fmt.Errorf("expanding reusable workflow failed to access user %s: %w", ref.Org, err) } repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, ref.Repo) if (err != nil && repo_model.IsErrRepoNotExist(err)) || repo.IsPrivate { // Same error message is returned for non-existing & non-visible to avoid information leak return nil, fmt.Errorf("expanding reusable workflow failed to access repo %s: repo does not exist", ref.Repo) } else if err != nil { return nil, fmt.Errorf("expanding reusable workflow failed to access repo %s: %w", ref.Repo, err) } gitRepo, err := gitrepo.OpenRepository(ctx, repo) if err != nil { return nil, fmt.Errorf("expanding reusable workflow failed to open repo %s: %w", repo.FullName(), err) } defer gitRepo.Close() commitID, err := gitRepo.GetRefCommitID(ref.Ref) if err != nil { return nil, fmt.Errorf("expanding reusable workflow failed to resolve reference %q on repo %s: %w", ref.Ref, repo.FullName(), err) } commit, err := gitRepo.GetCommit(commitID) if err != nil { return nil, fmt.Errorf("expanding reusable workflow failed to open commit %q on repo %s: %w", commitID, repo.FullName(), err) } data, err := expandLocalReusableWorkflows(commit)(job, ref.FilePath()) return data, err } }