From 5f0636faaddb83fc3230a9ed567476e198f92e0d Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 17 Jun 2026 20:28:40 +0000 Subject: [PATCH] feat: Support `ssh://` action URLs (#1035) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `ssh://` to the list of recognized URL schemes in `newRemoteAction`, so a step can reference an action over SSH, e.g.: ```yaml uses: ssh://git@gitea.example.com/actions/checkout@v4 ``` Previously only `https://` / `http://` prefixes were parsed; an `ssh://` URL fell through to the bare `org/repo` parser and failed. ### How auth works SSH auth is delegated entirely to go-git's defaults — the runner configures no SSH-specific options: - **Which key?** go-git falls back to the host's **ssh-agent** (`$SSH_AUTH_SOCK`). There is no key-file fallback, so the agent must hold a usable key. The SSH **username** comes from the URL, so use `ssh://git@host/...` (a bare `ssh://host/...` authenticates as an empty user and most servers reject it). - **Host key trust?** Established out-of-band via the host's `known_hosts` (`$SSH_KNOWN_HOSTS`, `~/.ssh/known_hosts`, `/etc/ssh/ssh_known_hosts`). The runner host must already trust the remote; there is no accept-on-first-use. - **Host key changes?** The clone fails with a host-key-mismatch error and stays failed until `known_hosts` is updated on the host. Note `InsecureSkipTLS` does **not** apply to SSH. ### Caching The action cache path is derived from `{org}/{repo}` only (scheme/host are not part of the key), so an `ssh://` action shares cache storage with the same `org/repo` fetched over HTTP. This is unchanged by this PR and works in practice (fetches resolve by SHA), but is worth noting. ### Tests Adds `ssh://` cases to `Test_newRemoteAction` covering the scheme prefix, the `git@` username placement, and a malformed-URL rejection. The agent/known_hosts behavior lives in go-git and is not unit-tested here. Fixes #841 Reviewed-on: https://gitea.com/gitea/runner/pulls/1035 Reviewed-by: Lunny Xiao --- act/runner/step_action_remote.go | 2 +- act/runner/step_action_remote_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/act/runner/step_action_remote.go b/act/runner/step_action_remote.go index 2df7e808..416ed1f5 100644 --- a/act/runner/step_action_remote.go +++ b/act/runner/step_action_remote.go @@ -312,7 +312,7 @@ func (ra *remoteAction) IsCheckout() bool { func newRemoteAction(action string) *remoteAction { // support http(s)://host/owner/repo@v3 - for _, schema := range []string{"https://", "http://"} { + for _, schema := range []string{"https://", "http://", "ssh://"} { if after, ok := strings.CutPrefix(action, schema); ok { splits := strings.SplitN(after, "/", 2) if len(splits) != 2 { diff --git a/act/runner/step_action_remote_test.go b/act/runner/step_action_remote_test.go index 0531d0ea..56c92252 100644 --- a/act/runner/step_action_remote_test.go +++ b/act/runner/step_action_remote_test.go @@ -778,6 +778,32 @@ func Test_newRemoteAction(t *testing.T) { }, wantCloneURL: "http://gitea.com/actions/aws", }, + { + action: "ssh://git@gitea.com/actions/heroku@main", // it's invalid for GitHub, but gitea supports it + want: &remoteAction{ + URL: "ssh://git@gitea.com", + Org: "actions", + Repo: "heroku", + Path: "", + Ref: "main", + }, + wantCloneURL: "ssh://git@gitea.com/actions/heroku", + }, + { + action: "ssh://git@gitea.com/actions/aws/ec2@main", // the ssh user is kept as part of the host segment + want: &remoteAction{ + URL: "ssh://git@gitea.com", + Org: "actions", + Repo: "aws", + Path: "ec2", + Ref: "main", + }, + wantCloneURL: "ssh://git@gitea.com/actions/aws", + }, + { + action: "ssh://gitea.com/onlyonesegment@main", // missing org/repo after the host + want: nil, + }, } for _, tt := range tests { t.Run(tt.action, func(t *testing.T) {