diff --git a/act/runner/reusable_workflow.go b/act/runner/reusable_workflow.go index dd4a6092..e231e39f 100644 --- a/act/runner/reusable_workflow.go +++ b/act/runner/reusable_workflow.go @@ -65,14 +65,24 @@ func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor { return common.NewErrorExecutor(fmt.Errorf("expected format {owner}/{repo}/.{git_platform}/workflows/{filename}@{ref}. Actual '%s' Input string was not in a correct format", url.Path)) } + // Host fallback: if 'uses' has no host specified, use the instance from configuration (rc.Config.GitHubInstance) + if remoteReusableWorkflow.URL == "" { + remoteReusableWorkflow.URL = rc.Config.GitHubInstance + } + // 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.Org, remoteReusableWorkflow.Repo, remoteReusableWorkflow.Ref) workflowDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(filename)) - // If the repository is private, we need a token to clone it - token := rc.Config.GetToken() + // Provide token only if the reusable workflow is from the same instance. + // This allows cloning private repositories from the current Forgejo instance, + // while avoiding token leakage to external instances. + token := "" + if isSameInstance(remoteReusableWorkflow.URL, rc.Config.GitHubInstance) { + token = rc.Config.GetToken() + } if rc.Config.ActionCache != nil { return newActionCacheReusableWorkflowExecutor(rc, filename, remoteReusableWorkflow) @@ -198,11 +208,12 @@ type remoteReusableWorkflow struct { } func (r *remoteReusableWorkflow) CloneURL() string { + base := strings.TrimSuffix(r.URL, "/") // In Gitea, r.URL always has the protocol prefix, we don't need to add extra prefix in this case. - if strings.HasPrefix(r.URL, "http://") || strings.HasPrefix(r.URL, "https://") { - return fmt.Sprintf("%s/%s/%s", r.URL, r.Org, r.Repo) + if strings.HasPrefix(base, "http://") || strings.HasPrefix(base, "https://") { + return fmt.Sprintf("%s/%s/%s", base, r.Org, r.Repo) } - return fmt.Sprintf("https://%s/%s/%s", r.URL, r.Org, r.Repo) + return fmt.Sprintf("https://%s/%s/%s", base, r.Org, r.Repo) } func (r *remoteReusableWorkflow) FilePath() string { @@ -229,3 +240,19 @@ func newRemoteReusableWorkflowWithPlat(url, uses string) *remoteReusableWorkflow URL: url, } } + +// isSameInstance checks if two URLs/hosts refer to the same instance. +// Normalizes protocol prefixes, trailing slashes, and case. +func isSameInstance(urlOrHost, instance string) bool { + normalize := func(s string) string { + s = strings.TrimSpace(s) + s = strings.TrimSuffix(s, "/") + if strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") { + if u, err := url.Parse(s); err == nil && u.Host != "" { + return strings.ToLower(u.Host) + } + } + return strings.ToLower(s) + } + return normalize(urlOrHost) == normalize(instance) +} diff --git a/act/runner/reusable_workflow_test.go b/act/runner/reusable_workflow_test.go index c433329d..7767087d 100644 --- a/act/runner/reusable_workflow_test.go +++ b/act/runner/reusable_workflow_test.go @@ -6,20 +6,20 @@ import ( "github.com/stretchr/testify/assert" ) -// TestConfig_GetToken verifies token priority: ACTIONS_TOKEN > GITEA_TOKEN > GITHUB_TOKEN +// TestConfig_GetToken verifies token priority: FORGEJO_ACTIONS_TOKEN > GITEA_TOKEN > GITHUB_TOKEN func TestConfig_GetToken(t *testing.T) { - t.Run("returns ACTIONS_TOKEN when all tokens present", func(t *testing.T) { + t.Run("returns FORGEJO_ACTIONS_TOKEN when all tokens present", func(t *testing.T) { c := &Config{ Secrets: map[string]string{ - "GITHUB_TOKEN": "github-token", - "GITEA_TOKEN": "gitea-token", - "ACTIONS_TOKEN": "actions-token", + "GITHUB_TOKEN": "github-token", + "GITEA_TOKEN": "gitea-token", + "FORGEJO_ACTIONS_TOKEN": "forgejo-actions-token", }, } - assert.Equal(t, "actions-token", c.GetToken()) + assert.Equal(t, "forgejo-actions-token", c.GetToken()) }) - t.Run("returns GITEA_TOKEN when ACTIONS_TOKEN absent", func(t *testing.T) { + t.Run("returns GITEA_TOKEN when FORGEJO_ACTIONS_TOKEN absent", func(t *testing.T) { c := &Config{ Secrets: map[string]string{ "GITHUB_TOKEN": "github-token", @@ -51,6 +51,96 @@ func TestConfig_GetToken(t *testing.T) { }) } +// TestIsSameInstance verifies same-instance detection for token security +func TestIsSameInstance(t *testing.T) { + tests := []struct { + name string + url string + instance string + expected bool + }{ + { + name: "exact match", + url: "code.forgejo.org", + instance: "code.forgejo.org", + expected: true, + }, + { + name: "https prefix in url", + url: "https://code.forgejo.org", + instance: "code.forgejo.org", + expected: true, + }, + { + name: "https prefix in instance", + url: "code.forgejo.org", + instance: "https://code.forgejo.org", + expected: true, + }, + { + name: "both with https prefix", + url: "https://code.forgejo.org", + instance: "https://code.forgejo.org", + expected: true, + }, + { + name: "trailing slash in url", + url: "code.forgejo.org/", + instance: "code.forgejo.org", + expected: true, + }, + { + name: "trailing slash in instance", + url: "code.forgejo.org", + instance: "code.forgejo.org/", + expected: true, + }, + { + name: "case insensitive", + url: "Code.Forgejo.Org", + instance: "code.forgejo.org", + expected: true, + }, + { + name: "different hosts", + url: "github.com", + instance: "code.forgejo.org", + expected: false, + }, + { + name: "http vs https same host", + url: "http://localhost:3000", + instance: "https://localhost:3000", + expected: true, + }, + { + name: "with path in url", + url: "https://code.forgejo.org/some/path", + instance: "code.forgejo.org", + expected: true, + }, + { + name: "empty strings", + url: "", + instance: "", + expected: true, + }, + { + name: "whitespace handling", + url: " code.forgejo.org ", + instance: "code.forgejo.org", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isSameInstance(tt.url, tt.instance) + assert.Equal(t, tt.expected, result) + }) + } +} + // TestRemoteReusableWorkflow_CloneURL verifies URL formatting for different instances func TestRemoteReusableWorkflow_CloneURL(t *testing.T) { t.Run("adds https prefix when missing", func(t *testing.T) { @@ -81,6 +171,42 @@ func TestRemoteReusableWorkflow_CloneURL(t *testing.T) { }) } +// TestRemoteReusableWorkflow_CloneURL_TrailingSlash_NoScheme verifies trimming the trailing slash and adding https scheme when base URL has no scheme +func TestRemoteReusableWorkflow_CloneURL_TrailingSlash_NoScheme(t *testing.T) { + t.Run("trims trailing slash when no scheme", func(t *testing.T) { + rw := &remoteReusableWorkflow{ + URL: "code.forgejo.org/", + Org: "owner", + Repo: "repo", + } + assert.Equal(t, "https://code.forgejo.org/owner/repo", rw.CloneURL()) + }) +} + +// TestRemoteReusableWorkflow_CloneURL_TrailingSlash verifies trimming the trailing slash when base URL already includes http/https to avoid double slashes +func TestRemoteReusableWorkflow_CloneURL_TrailingSlash(t *testing.T) { + t.Run("trims trailing slash in URL", func(t *testing.T) { + rw := &remoteReusableWorkflow{ + URL: "https://code.forgejo.org/", + Org: "owner", + Repo: "repo", + } + assert.Equal(t, "https://code.forgejo.org/owner/repo", rw.CloneURL()) + }) +} + +// TestRemoteReusableWorkflow_CloneURL_Ports verifies that a custom port in the base URL is preserved when constructing the clone URL +func TestRemoteReusableWorkflow_CloneURL_Ports(t *testing.T) { + t.Run("preserves custom port", func(t *testing.T) { + rw := &remoteReusableWorkflow{ + URL: "http://localhost:3000", + Org: "owner", + Repo: "repo", + } + assert.Equal(t, "http://localhost:3000/owner/repo", rw.CloneURL()) + }) +} + // TestRemoteReusableWorkflow_FilePath verifies workflow file path construction func TestRemoteReusableWorkflow_FilePath(t *testing.T) { tests := []struct { diff --git a/act/runner/runner.go b/act/runner/runner.go index afd4facd..73987de9 100644 --- a/act/runner/runner.go +++ b/act/runner/runner.go @@ -76,17 +76,18 @@ type Config struct { ContainerNetworkEnableIPv6 bool // create the network with IPv6 support enabled } +// GetToken: Adapt to Gitea/Forgejo // GetToken returns the authentication token for git operations. -// Priority: ACTIONS_TOKEN > GITEA_TOKEN > GITHUB_TOKEN +// Priority: FORGEJO_ACTIONS_TOKEN > GITEA_TOKEN > GITHUB_TOKEN func (c Config) GetToken() string { - token := c.Secrets["GITHUB_TOKEN"] - if c.Secrets["GITEA_TOKEN"] != "" { - token = c.Secrets["GITEA_TOKEN"] + // Explicit token for Forgejo Actions (reusable workflows) + if t := c.Secrets["FORGEJO_ACTIONS_TOKEN"]; t != "" { + return t } - if c.Secrets["ACTIONS_TOKEN"] != "" { - token = c.Secrets["ACTIONS_TOKEN"] + if t := c.Secrets["GITEA_TOKEN"]; t != "" { + return t } - return token + return c.Secrets["GITHUB_TOKEN"] } func (c *Config) GetContainerDaemonSocket() string {