diff --git a/.cloudbees/testing/action.yml b/.cloudbees/testing/action.yml index 7988510..af523fb 100644 --- a/.cloudbees/testing/action.yml +++ b/.cloudbees/testing/action.yml @@ -4,10 +4,14 @@ name: 'Checkout' description: 'Checkout a Git repository at a particular version' inputs: provider: - description: 'SCM provider that is hosting the repository. For example github, bitbucket, gitlab or custom' + description: | + SCM provider that is hosting the repository. For example github, bitbucket, gitlab or custom. + When specifying an SSH URL within the repository input, the provider must be set to 'custom'. default: "${{ cloudbees.scm.provider }}" repository: - description: 'Repository name with owner. For example, actions/checkout. Alternatively if provider is custom then this is the clone URL of the repository' + description: | + Repository URL or name with owner. + For example, cloudbees-io/checkout or https://github.com/cloudbees-io/checkout. default: "${{ cloudbees.scm.repository }}" ref: description: > diff --git a/.cloudbees/workflows/workflow.yml b/.cloudbees/workflows/workflow.yml index b4421db..7d0d64f 100644 --- a/.cloudbees/workflows/workflow.yml +++ b/.cloudbees/workflows/workflow.yml @@ -49,13 +49,17 @@ jobs: env: RUNNER_DEBUG: "1" - name: Verify that the repo was checked out - uses: docker://golang:1.24.6 + uses: docker://golang:1.24-alpine3.22 run: | set -x [ -d .git ] [ -f Dockerfile ] go build . - echo Repository URL = ${{ steps.runaction.outputs.repository-url }} - echo Commit ID = ${{ steps.runaction.outputs.commit }} - echo Commit URL = ${{ steps.runaction.outputs.commit-url }} - echo Ref = ${{ steps.runaction.outputs.ref }} + echo Action outputs: + echo ' Repository URL = ${{ steps.runaction.outputs.repository-url }}' + echo ' Commit = ${{ steps.runaction.outputs.commit }}' + echo ' Commit URL = ${{ steps.runaction.outputs.commit-url }}' + echo ' Ref = ${{ steps.runaction.outputs.ref }}' + [ "${{ steps.runaction.outputs.repository-url }}" = "https://github.com/cloudbees-io/checkout.git" ] + [ "${{ steps.runaction.outputs.commit }}" = "${{ cloudbees.scm.sha }}" ] + [ "${{ steps.runaction.outputs.commit-url }}" = "https://github.com/cloudbees-io/checkout/commit/${{ cloudbees.scm.sha }}" ] diff --git a/.gitignore b/.gitignore index 567609b..a4dc17c 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ build/ +vendor diff --git a/Makefile b/Makefile index f88a847..6c345ab 100644 --- a/Makefile +++ b/Makefile @@ -26,6 +26,7 @@ verify: format sync test ## Verifies that the committed code is formatted, all f echo "$(ANSI_BOLD)✅ Git workspace is clean$(ANSI_RESET)" ; \ else \ echo "$(ANSI_BOLD)❌ Git workspace is dirty$(ANSI_RESET)" ; \ + git status --porcelain ; \ exit 1 ; \ fi diff --git a/README.adoc b/README.adoc index 5ddc503..24e9ba2 100644 --- a/README.adoc +++ b/README.adoc @@ -64,6 +64,7 @@ Default is `1`. | No | The SCM provider hosting the repository. For example GitHub, BitBucket, or custom. The default is `${{ cloudbees.scm.provider }}`. +When specifying an SSH URL within the repository input, the provider must be set to 'custom'. | `ref` | String @@ -74,7 +75,9 @@ The action uses the default branch, except when checking out the triggering work | `repository` | String | No -| The repository name with owner. For example, `actions/checkout`. Default value is `${{ cloudbees.scm.repository }}`. +| Repository URL or name with owner. +For example, https://github.com/cloudbees-io/checkout or cloudbees-io/checkout. +Default value is `${{ cloudbees.scm.repository }}`. | `set-safe-directory` | Boolean diff --git a/action.yml b/action.yml index 2f3c456..6734b94 100644 --- a/action.yml +++ b/action.yml @@ -4,10 +4,14 @@ name: 'Checkout' description: 'Checkout a Git repository at a particular version' inputs: provider: - description: 'SCM provider that is hosting the repository. For example github, bitbucket, gitlab or custom' + description: | + SCM provider that is hosting the repository. For example github, bitbucket, gitlab or custom. + When specifying an SSH URL within the repository input, the provider must be set to 'custom'. default: "${{ cloudbees.scm.provider }}" repository: - description: 'Repository name with owner. For example, actions/checkout. Alternatively if provider is custom then this is the clone URL of the repository' + description: | + Repository URL or name with owner. + For example, cloudbees-io/checkout or https://github.com/cloudbees-io/checkout. default: "${{ cloudbees.scm.repository }}" ref: description: > diff --git a/internal/checkout/source_provider.go b/internal/checkout/source_provider.go index 308f3fb..c0173f2 100644 --- a/internal/checkout/source_provider.go +++ b/internal/checkout/source_provider.go @@ -23,6 +23,7 @@ import ( type Config struct { Provider string Repository string + repositoryCloneURL string Ref string CloudBeesApiToken string CloudBeesApiURL string @@ -85,12 +86,14 @@ func (cfg *Config) validate() error { core.Debug("providerURL = %s", cfg.providerURL) core.Debug("token auth type = %s", cfg.TokenAuthtype) - // Repository - if cfg.Provider != auth.CustomProvider { - splitRepository := strings.Split(cfg.Repository, "/") - if len(splitRepository) != 2 || splitRepository[0] == "" || splitRepository[1] == "" { - return fmt.Errorf("invalid repository '%s', expected format {owner}/{repo}", cfg.Repository) - } + err = cfg.initDefaultProviderURL() + if err != nil { + return err + } + + err = cfg.normalizeRepositoryURL() + if err != nil { + return err } // Repository Path @@ -172,7 +175,11 @@ func (cfg *Config) validate() error { cfg.githubWorkflowOrganizationId, _ = getStringFromMap(owner, "id") } - // Determine the provider URL that the repository is being hosted from + return nil +} + +// initDefaultProviderURL sets the default provider-specific serverURL if not specified +func (cfg *Config) initDefaultProviderURL() error { switch cfg.Provider { case auth.GitHubProvider: if cfg.GithubServerURL == "" { @@ -220,14 +227,69 @@ func (cfg *Config) validate() error { return nil } +func (cfg *Config) normalizeRepositoryURL() error { + cfg.repositoryCloneURL = cfg.Repository + + if isSSHURL(cfg.Repository) { + // Handle SSH URL + if cfg.SSHKey == "" { + return errors.New("must also specify the ssh-key input when specifying an SSH URL as repository input") + } + if cfg.Provider != auth.CustomProvider { + return errors.New("provider input must be set to 'custom' when specifying an SSH URL within the repository input") + } + } else { + // Handle HTTP URL + repoURL, err := url.Parse(cfg.Repository) + if err != nil { + return fmt.Errorf("invalid repository %q: %w", cfg.Repository, err) + } + + if repoURL.IsAbs() && repoURL.Host != "" { + serverURL := cfg.serverURL() + + if !strings.HasPrefix(cfg.Repository, serverURL+"/") { + return fmt.Errorf("repository url (%s) must start with the server URL (%s) of the provider (%s)", cfg.Repository, serverURL, cfg.Provider) + } + + // Add .git suffix to repository URL in case of a known SCM provider. + // This is to align with the old logic implemented in fetchURL(). + // We cannot add the .git suffix to every clone URL since some SCM providers don't support it (e.g. Azure DevOps). + switch cfg.Provider { + case auth.GitHubProvider, + auth.BitbucketProvider, + auth.BitbucketDatacenterProvider, + auth.GitLabProvider: + if !strings.HasSuffix(cfg.Repository, ".git") { + cfg.repositoryCloneURL = fmt.Sprintf("%s.git", cfg.Repository) + } + } + } else { + if cfg.Provider == auth.CustomProvider { + return errors.New("short form repository URL provided but an absolute URL is required when using a custom SCM provider") + } + + splitRepository := strings.Split(repoURL.Path, "/") + if len(splitRepository) != 2 || splitRepository[0] == "" || splitRepository[1] == "" { + return fmt.Errorf("invalid repository '%s', expected format {owner}/{repo} or {serverURL}/{repoPath}", cfg.Repository) + } + + // Absolutize the short form URL + cfg.repositoryCloneURL, err = cfg.fetchURL(cfg.SSHKey != "") + if err != nil { + return fmt.Errorf("absolutize repository url: %w", err) + } + } + } + + return nil +} + func (cfg *Config) writeActionOutputs(cli *git.GitCLI) error { //Output commit details outDir := os.Getenv("CLOUDBEES_OUTPUTS") - fullUrl, err := cfg.fetchURL(cfg.SSHKey != "") - if err != nil { - return err - } - err = os.WriteFile(filepath.Join(outDir, "repository-url"), []byte(fullUrl), 0640) + fullUrl := cfg.repositoryCloneURL + err := os.WriteFile(filepath.Join(outDir, "repository-url"), []byte(fullUrl), 0640) if err != nil { return err } @@ -260,9 +322,13 @@ func (cfg *Config) writeActionOutputs(cli *git.GitCLI) error { case auth.BitbucketProvider: fullCommitUrl = fullUrl + "/commits/" + commitId case auth.BitbucketDatacenterProvider: - name := strings.Split(cfg.Repository, "/") - if len(name) == 2 { - fullCommitUrl = cfg.providerURL + "projects/" + name[0] + "/repos/" + name[1] + "/commits/" + commitId + repo := cfg.Repository + if strings.HasSuffix(repo, ".git") { + repo = repo[:len(repo)-len(".git")] + } + name := strings.Split(repo, "/") + if len(name) >= 2 { + fullCommitUrl = cfg.BitbucketServerURL + "/projects/" + name[len(name)-2] + "/repos/" + name[len(name)-1] + "/commits/" + commitId } } } @@ -289,7 +355,7 @@ func (cfg *Config) ensureScmPathForBitbucketDatacenterUrl() error { p, err := url.Parse(cfg.BitbucketServerURL) if err != nil { - return err + return fmt.Errorf("bitbucket-server-url: %w", err) } if strings.HasSuffix(p.Path, "/scm") || strings.HasSuffix(p.Path, "/scm/") { @@ -327,10 +393,8 @@ func (cfg *Config) Run(ctx context.Context) (retErr error) { return err } - repositoryURL, err := cfg.fetchURL(useSSH) - if err != nil { - return err - } + repositoryURL := cfg.repositoryCloneURL + fmt.Printf("Syncing Repository: %s\n", repositoryURL) // Remove conflicting file path @@ -859,6 +923,7 @@ func (cfg *Config) isWorkflowRepository(eventContext map[string]interface{}) boo core.Debug("ctx.repository = %s", ctxRepository) core.Debug("cfg.provider = %s", cfg.Provider) core.Debug("cfg.repository = %s", cfg.Repository) + core.Debug("cfg.repositoryCloneURL = %s", cfg.repositoryCloneURL) return haveP && cfg.Provider == ctxProvider && haveR && cfg.Repository == ctxRepository } diff --git a/internal/checkout/source_provider_test.go b/internal/checkout/source_provider_test.go index 7f75a7e..95e053f 100644 --- a/internal/checkout/source_provider_test.go +++ b/internal/checkout/source_provider_test.go @@ -1,6 +1,7 @@ package checkout import ( + "os" "path/filepath" "testing" @@ -8,6 +9,256 @@ import ( "github.com/stretchr/testify/require" ) +func TestConfigValidate(t *testing.T) { + dir, err := os.MkdirTemp("", "checkout-action-test-") + require.NoError(t, err) + + t.Cleanup(func() { os.RemoveAll(dir) }) + + t.Setenv("CLOUDBEES_EVENT_PATH", filepath.Join("testdata", "event.json")) + t.Setenv("CLOUDBEES_WORKSPACE", dir) + + for _, tc := range []struct { + name string + input Config + want Config + wantErr string + }{ + { + name: "short form GitHub repository", + input: Config{ + Repository: "owner1/repo1", + Submodules: "false", + Token: "fake-token", + }, + want: Config{ + Repository: "owner1/repo1", + repositoryCloneURL: "https://github.com/owner1/repo1.git", + GithubServerURL: "https://github.com", + Provider: "github", + Submodules: "false", + Token: "fake-token", + Path: ".", + providerURL: "https://github.com", + }, + }, + { + name: "short form BitBucket repository", + input: Config{ + Repository: "owner1/repo1", + Provider: "bitbucket", + Submodules: "false", + Token: "fake-token", + }, + want: Config{ + Repository: "owner1/repo1", + repositoryCloneURL: "https://bitbucket.org/owner1/repo1.git", + BitbucketServerURL: "https://bitbucket.org", + Provider: "bitbucket", + Submodules: "false", + Token: "fake-token", + Path: ".", + providerURL: "https://github.com", // comes from the mounted scm event - very odd in this case + }, + }, + { + name: "short form GitLab repository", + input: Config{ + Repository: "owner1/repo1", + Provider: "gitlab", + Submodules: "false", + Token: "fake-token", + }, + want: Config{ + Repository: "owner1/repo1", + repositoryCloneURL: "https://gitlab.com/owner1/repo1.git", + GitlabServerURL: "https://gitlab.com", + Provider: "gitlab", + Submodules: "false", + Token: "fake-token", + Path: ".", + providerURL: "https://github.com", // comes from the mounted scm event - very odd in this case + }, + }, + { + name: "fail when short form repository url provided for custom provider", + input: Config{ + Repository: "owner1/repo1", + Provider: "custom", + Submodules: "false", + Token: "fake-token", + }, + wantErr: "short form repository URL provided but an absolute URL is required when using a custom SCM provider", + }, + { + name: "long form GitHub repository", + input: Config{ + Repository: "https://github.com/owner1/repo1", + Submodules: "false", + Token: "fake-token", + }, + want: Config{ + Repository: "https://github.com/owner1/repo1", + repositoryCloneURL: "https://github.com/owner1/repo1.git", + GithubServerURL: "https://github.com", + Provider: "github", + Submodules: "false", + Token: "fake-token", + Path: ".", + providerURL: "https://github.com", + }, + }, + { + name: "long form BitBucket repository", + input: Config{ + Repository: "https://bitbucket.org/owner1/repo1", + Provider: "bitbucket", + Submodules: "false", + Token: "fake-token", + }, + want: Config{ + Repository: "https://bitbucket.org/owner1/repo1", + repositoryCloneURL: "https://bitbucket.org/owner1/repo1.git", + BitbucketServerURL: "https://bitbucket.org", + Provider: "bitbucket", + Submodules: "false", + Token: "fake-token", + Path: ".", + providerURL: "https://github.com", // comes from the mounted scm event - very odd in this case + }, + }, + { + name: "long form GitLab repository", + input: Config{ + Repository: "https://gitlab.com/owner1/repo1", + Provider: "gitlab", + Submodules: "false", + Token: "fake-token", + }, + want: Config{ + Repository: "https://gitlab.com/owner1/repo1", + repositoryCloneURL: "https://gitlab.com/owner1/repo1.git", + GitlabServerURL: "https://gitlab.com", + Provider: "gitlab", + Submodules: "false", + Token: "fake-token", + Path: ".", + providerURL: "https://github.com", // comes from the mounted scm event - very odd in this case + }, + }, + { + name: "long form GitHub repository with .git extension", + input: Config{ + Repository: "https://github.com/owner1/repo1.git", + Submodules: "false", + Token: "fake-token", + }, + want: Config{ + Repository: "https://github.com/owner1/repo1.git", + repositoryCloneURL: "https://github.com/owner1/repo1.git", + GithubServerURL: "https://github.com", + Provider: "github", + Submodules: "false", + Token: "fake-token", + Path: ".", + providerURL: "https://github.com", + }, + }, + { + name: "long form BitBucket repository with .git extension", + input: Config{ + Repository: "https://bitbucket.org/owner1/repo1.git", + Provider: "bitbucket", + Submodules: "false", + Token: "fake-token", + }, + want: Config{ + Repository: "https://bitbucket.org/owner1/repo1.git", + repositoryCloneURL: "https://bitbucket.org/owner1/repo1.git", + BitbucketServerURL: "https://bitbucket.org", + Provider: "bitbucket", + Submodules: "false", + Token: "fake-token", + Path: ".", + providerURL: "https://github.com", // comes from the mounted scm event - very odd in this case + }, + }, + { + name: "long form GitLab repository with .git extension", + input: Config{ + Repository: "https://gitlab.com/owner1/repo1.git", + Provider: "gitlab", + Submodules: "false", + Token: "fake-token", + }, + want: Config{ + Repository: "https://gitlab.com/owner1/repo1.git", + repositoryCloneURL: "https://gitlab.com/owner1/repo1.git", + GitlabServerURL: "https://gitlab.com", + Provider: "gitlab", + Submodules: "false", + Token: "fake-token", + Path: ".", + providerURL: "https://github.com", // comes from the mounted scm event - very odd in this case + }, + }, + { + name: "SSH repository URL", + input: Config{ + Repository: "git@github.com:mgoltzsche/podman-static.git", + Provider: "custom", + Submodules: "false", + Token: "fake-token", + SSHKey: "fake-ssh-key", + }, + want: Config{ + Repository: "git@github.com:mgoltzsche/podman-static.git", + repositoryCloneURL: "git@github.com:mgoltzsche/podman-static.git", + Provider: "custom", + Submodules: "false", + Token: "fake-token", + SSHKey: "fake-ssh-key", + Path: ".", + providerURL: "https://github.com", // comes from the mounted scm event - very odd in this case + }, + }, + { + name: "fail on SSH repository URL without ssh-key", + input: Config{ + Repository: "git@github.com:mgoltzsche/podman-static.git", + Provider: "custom", + Token: "fake-token", + }, + wantErr: "must also specify the ssh-key input when specifying an SSH URL as repository input", + }, + { + name: "fail on SSH repository URL without custom provider being specified", + input: Config{ + Repository: "git@github.com:mgoltzsche/podman-static.git", + Provider: "github", + SSHKey: "fake-ssh-key", + }, + wantErr: "provider input must be set to 'custom' when specifying an SSH URL within the repository input", + }, + } { + t.Run(tc.name, func(t *testing.T) { + err := tc.input.validate() + if tc.wantErr == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, tc.wantErr) + return + } + + require.NotNil(t, tc.input.eventContext) + + tc.input.eventContext = nil + + require.Equal(t, tc.want, tc.input, "Config struct after validation (initialization really)") + }) + } +} + func Test_findEventContext(t *testing.T) { t.Setenv("CLOUDBEES_EVENT_PATH", filepath.Join("testdata", "event.json")) diff --git a/internal/checkout/testdata/event.json b/internal/checkout/testdata/event.json index 1f6bbb1..ad734cb 100644 --- a/internal/checkout/testdata/event.json +++ b/internal/checkout/testdata/event.json @@ -2,7 +2,7 @@ "id": "github.com|00ea9422-5ec9-11ee-9135-d356b0e26c1e", "type": "PUSH", "provider": "github", - "providerURL": "https://github.com/fakeorg/fakerepo", + "providerURL": "https://github.com", "repositoryUrl": "https://github.com/fakeorg/fakerepo.git", "repository": "fakeorg/fakerepo", "branch": "main", diff --git a/internal/checkout/urls.go b/internal/checkout/urls.go index 7bb4f4f..71060e3 100644 --- a/internal/checkout/urls.go +++ b/internal/checkout/urls.go @@ -3,10 +3,17 @@ package checkout import ( "fmt" "net/url" + "regexp" "github.com/cloudbees-io/checkout/internal/auth" ) +var sshURLRegex = regexp.MustCompile(`^(ssh://)?([a-zA-Z][-a-zA-Z0-9_]*@)?[a-z0-9][-a-z0-9_\.]*:(/?[\w_\-\.~]+)*$`) + +func isSSHURL(urlStr string) bool { + return sshURLRegex.MatchString(urlStr) +} + func (cfg *Config) serverURL() string { p := cfg.Provider switch p { diff --git a/internal/checkout/urls_test.go b/internal/checkout/urls_test.go new file mode 100644 index 0000000..33b5f7f --- /dev/null +++ b/internal/checkout/urls_test.go @@ -0,0 +1,86 @@ +package checkout + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIsSSHURL(t *testing.T) { + for _, tc := range []struct { + name string + input string + want bool + }{ + { + name: "GitHub URL simple", + input: "git@github.com:org1/repo1", + want: true, + }, + { + name: "GitHub URL with minus and underscore", + input: "git@github.com:some-org_x/some-repo_x", + want: true, + }, + { + name: "BitBucket URL with .git extension", + input: "git@bitbucket.org:some-org/some-repo.git", + want: true, + }, + { + name: "with port", + input: "git@example.com:1234/org1/repo1", + want: true, + }, + { + name: "with upper case repo name", + input: "git@example.com:org1/Repo1", + want: true, + }, + { + name: "with upper case user", + input: "GIT@example.com:org1/repo1", + want: true, + }, + { + name: "with protocol", + input: "ssh://git@example.com:1234/org1/repo1", + want: true, + }, + { + name: "with subdomain", + input: "git@git.my-repos_x.example.com:org1/repo1", + want: true, + }, + { + name: "with home dir", + input: "git@git.my-repos_x.example.com:~/repos/org1/repo1", + want: true, + }, + { + name: "gerrit url (without user)", + input: "ssh://gerrithost:29418/RecipeBook.git", + want: true, + }, + { + name: "all syntax features", + input: "ssh://git@git.my-repos_x.example.com:1234/some-org_x/some-repo_x.git", + want: true, + }, + { + name: "HTTP URL", + input: "https://github.com/org1/repo1", + want: false, + }, + { + name: "short form syntax", + input: "org1/repo1", + want: false, + }, + } { + t.Run(tc.name, func(t *testing.T) { + actual := isSSHURL(tc.input) + require.Equal(t, tc.want, actual) + }) + } +}