mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-11-06 23:29:30 +08:00
Each step refreshed its own clone of an action, so a composite using one action repository ten times fetched it ten times per job, and a single network error failed the job. Like actions/runner, a job now downloads each action repository and ref once, as an archive extracted into a directory per commit. Each action still gets its own copy in the job container. This restores the per-job dedupe of https://gitea.com/gitea/runner/pulls/1178, which only reached a since-deleted branch. How a download works: 1. The action URL is taken after `insteadOf` rewriting, as git would use it. 2. The archive comes from `<url>/archive/<ref>.tar.gz`, or from codeload.github.com for github.com, as for actions/runner. 3. A cached tag or branch is revalidated with its ETag, which GitHub and Gitea 28+ answer with a 304. A cached commit pin needs no request. 4. Transient failures (5xx, 408, 429 honouring `Retry-After`, dropped transfers) are retried three times with a 10 to 30 second backoff. 5. Git takes over, cloning and exporting the commit into the same directory layout, when the URL is not HTTP(S), the ref is a short commit, the host serves no usable archive, the host cannot be connected to, or the retries run out. Median of 3 runs, git (shallow clone, then fetch) against archives, on a cold and a warm cache: | Action | git cold | git warm | archive cold | archive warm | |---|---|---|---|---| | github.com actions/checkout@v4 | 937ms | 726ms | 286ms | 171ms | | github.com actions/cache@v4 | 1006ms | 801ms | 516ms | 161ms | | github.com actions/setup-node@v4 | 1046ms | 786ms | 463ms | 156ms | | gitea.com actions/checkout@v4 | 2007ms | 1165ms | 667ms | 222ms | The action cache layout changes, so existing caches download once more. *Written by Claude.* Fixes: https://gitea.com/gitea/runner/issues/1220 Reviewed-on: https://gitea.com/gitea/runner/pulls/1316 Reviewed-by: bircni <bircni@icloud.com> Co-authored-by: silverwind <me@silverwind.io>
244 lines
7.9 KiB
Go
244 lines
7.9 KiB
Go
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package runner
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"path"
|
|
"regexp"
|
|
|
|
"gitea.com/gitea/runner/act/common"
|
|
"gitea.com/gitea/runner/act/common/git"
|
|
|
|
"gitea.dev/actionslib/pkg/model"
|
|
)
|
|
|
|
func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
|
localWorkflow, err := model.ParseReusableWorkflowUses(rc.Run.Job().Uses)
|
|
if err != nil {
|
|
return common.NewErrorExecutor(err)
|
|
}
|
|
|
|
if !rc.Config.NoSkipCheckout {
|
|
// resolve the local workflow against the workspace root, not the process
|
|
// working directory, so it is found regardless of where the runner is invoked
|
|
return newReusableWorkflowExecutor(rc, rc.Config.Workdir, localWorkflow.Path, false)
|
|
}
|
|
|
|
uses := fmt.Sprintf("%s/%s@%s", rc.Config.PresetGitHubContext.Repository, localWorkflow.Path, rc.Config.PresetGitHubContext.Sha)
|
|
|
|
remoteReusableWorkflow, err := newRemoteReusableWorkflow(rc.Config.GitHubInstance, uses)
|
|
if err != nil {
|
|
return common.NewErrorExecutor(err)
|
|
}
|
|
|
|
workflowDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(uses))
|
|
|
|
return common.NewPipelineExecutor(
|
|
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir),
|
|
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.Path, true),
|
|
)
|
|
}
|
|
|
|
func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
|
remoteReusableWorkflow, err := newRemoteReusableWorkflow(rc.Config.GitHubInstance, rc.Run.Job().Uses)
|
|
if err != nil {
|
|
return common.NewErrorExecutor(err)
|
|
}
|
|
|
|
// uses with safe filename makes the target directory look something like this {owner}-{repo}-.github-workflows-{filename}@{ref}
|
|
// instead we will just use {owner}-{repo}@{ref} as our target directory. This should also improve performance when we are using
|
|
// multiple reusable workflows from the same repository and ref since for each workflow we won't have to clone it again
|
|
filename := fmt.Sprintf("%s/%s@%s", remoteReusableWorkflow.Owner, remoteReusableWorkflow.Repo, remoteReusableWorkflow.Ref)
|
|
workflowDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(filename))
|
|
|
|
return common.NewPipelineExecutor(
|
|
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir),
|
|
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.Path, true),
|
|
)
|
|
}
|
|
|
|
// cloneRemoteReusableWorkflow always invokes the clone executor — moving refs
|
|
// (branches, tags) must be re-resolved each run, matching GitHub Actions.
|
|
//
|
|
// Callers must not change remoteReusableWorkflow.URL, because:
|
|
// 1. Gitea doesn't support specifying GithubContext.ServerURL by the GITHUB_SERVER_URL env
|
|
// 2. Gitea has already full URL with rc.Config.GitHubInstance when calling newRemoteReusableWorkflow
|
|
//
|
|
// remoteReusableWorkflow.URL = rc.getGithubContext(ctx).ServerURL
|
|
func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory string) common.Executor {
|
|
return func(ctx context.Context) error {
|
|
interpolatedURL, err := rc.NewExpressionEvaluator(ctx).Interpolate(ctx, cloneURL)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to interpolate the workflow clone URL: %w", err)
|
|
}
|
|
return git.NewGitCloneExecutor(rc.Config.withInstanceAuth(git.NewGitCloneExecutorInput{
|
|
URL: interpolatedURL,
|
|
Ref: ref,
|
|
Dir: targetDirectory,
|
|
OfflineMode: rc.Config.ActionOfflineMode,
|
|
Depth: rc.Config.ActionCloneDepth,
|
|
}, rc.Config.GetToken()))(ctx)
|
|
}
|
|
}
|
|
|
|
func newReusableWorkflowExecutor(rc *RunContext, directory, workflow string, cached bool) common.Executor {
|
|
return func(ctx context.Context) error {
|
|
planner, err := func() (model.WorkflowPlanner, error) {
|
|
if cached {
|
|
unlock, err := git.AcquireCloneLock(ctx, directory)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer unlock()
|
|
}
|
|
return model.NewWorkflowPlanner(path.Join(directory, workflow), true)
|
|
}()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
plan, err := planner.PlanEvent("workflow_call")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
runner, err := newReusableWorkflowRunner(rc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return common.NewPipelineExecutor( // For Gitea
|
|
runner.NewPlanExecutor(plan),
|
|
setReusedWorkflowCallerResult(rc, runner),
|
|
)(ctx)
|
|
}
|
|
}
|
|
|
|
func newReusableWorkflowRunner(rc *RunContext) (*runnerImpl, error) {
|
|
runner := &runnerImpl{
|
|
config: rc.Config,
|
|
eventJSON: rc.EventJSON,
|
|
caller: &caller{
|
|
runContext: rc,
|
|
|
|
reusedWorkflowJobResults: map[string]string{}, // For Gitea
|
|
},
|
|
}
|
|
|
|
return runner.configure()
|
|
}
|
|
|
|
type remoteReusableWorkflow struct {
|
|
*model.ReusableWorkflowUses
|
|
URL string
|
|
}
|
|
|
|
func (r *remoteReusableWorkflow) CloneURL() string {
|
|
return fmt.Sprintf("%s/%s/%s", withHTTPS(r.URL), r.Owner, r.Repo)
|
|
}
|
|
|
|
var absoluteReusableWorkflowURLRegex = regexp.MustCompile(`^(https?://.*)/([^/]+/[^/]+/\.[^/]+/workflows/[^@]+@.*)$`)
|
|
|
|
// For Gitea
|
|
// newRemoteReusableWorkflow parses a remote `uses`, an absolute URL up to `{owner}/{repo}/.{git_platform}/workflows/` replaces baseURL
|
|
func newRemoteReusableWorkflow(baseURL, uses string) (*remoteReusableWorkflow, error) {
|
|
if matches := absoluteReusableWorkflowURLRegex.FindStringSubmatch(uses); matches != nil {
|
|
baseURL, uses = matches[1], matches[2]
|
|
}
|
|
parsed, err := model.ParseReusableWorkflowUses(uses)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if parsed.IsLocal() {
|
|
return nil, fmt.Errorf("expected a remote reusable workflow, got %q", uses)
|
|
}
|
|
return &remoteReusableWorkflow{ReusableWorkflowUses: parsed, URL: baseURL}, nil
|
|
}
|
|
|
|
// For Gitea
|
|
func setReusedWorkflowCallerResult(rc *RunContext, runner *runnerImpl) common.Executor {
|
|
return func(ctx context.Context) error {
|
|
caller := runner.caller
|
|
|
|
allJobDone := true
|
|
hasFailure := false
|
|
for _, result := range caller.reusedWorkflowJobResults {
|
|
if result == "pending" {
|
|
allJobDone = false
|
|
break
|
|
}
|
|
if result == "failure" {
|
|
hasFailure = true
|
|
}
|
|
}
|
|
|
|
if allJobDone {
|
|
reusedWorkflowJobResult := "success"
|
|
reusedWorkflowJobResultMessage := "succeeded"
|
|
if hasFailure {
|
|
reusedWorkflowJobResult = "failure"
|
|
reusedWorkflowJobResultMessage = "failed"
|
|
}
|
|
|
|
if rc.caller != nil {
|
|
rc.caller.setReusedWorkflowJobResult(rc.Run.JobID, reusedWorkflowJobResult)
|
|
} else {
|
|
// Serialize this shared Job.Result write against the other matrix combos
|
|
// and setJobResult (same lockJob key).
|
|
unlock := lockJob(rc.Run.Job())
|
|
rc.result(reusedWorkflowJobResult)
|
|
unlock()
|
|
common.Logger(ctx).With("jobResult", reusedWorkflowJobResult).Infof("Job %s", reusedWorkflowJobResultMessage)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// For Gitea
|
|
// withInstanceAuth passes instance credentials only to clones from this Gitea instance, and runner.insecure only to its registered address.
|
|
func (c Config) withInstanceAuth(input git.NewGitCloneExecutorInput, token string) git.NewGitCloneExecutorInput {
|
|
u, err := url.Parse(input.URL)
|
|
if err != nil || !onInstanceHost(u.Host, c.GitHubInstance, c.trustedActionInstance()) {
|
|
return input
|
|
}
|
|
input.ClientCertFile, input.ClientKeyFile = c.ClientCertFile, c.ClientKeyFile
|
|
if onInstanceHost(u.Host, c.GitHubInstance) {
|
|
input.InsecureSkipTLS, input.Transport = c.InsecureSkipTLS, c.InstanceTransport
|
|
}
|
|
if u.User == nil {
|
|
input.Token = token
|
|
}
|
|
return input
|
|
}
|
|
|
|
// For Gitea
|
|
// trustedActionInstance returns the self-hosted DEFAULT_ACTIONS_URL host that may carry the
|
|
// task token, or "" when actions resolve to github.com / a GithubMirror (never trusted).
|
|
func (c Config) trustedActionInstance() string {
|
|
if c.DefaultActionInstanceIsSelfHosted {
|
|
return c.DefaultActionInstance
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// For Gitea
|
|
// onInstanceHost reports whether host is that of one of the instance URLs, which may lack a scheme.
|
|
func onInstanceHost(host string, instanceURLs ...string) bool {
|
|
for _, candidate := range instanceURLs {
|
|
if candidate == "" {
|
|
continue
|
|
}
|
|
if u, err := url.Parse(withHTTPS(candidate)); err == nil && u.Host == host {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|