feat: Support ssh:// action URLs (#1035)

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 <xiaolunwen@gmail.com>
This commit is contained in:
Nicolas
2026-06-17 20:28:40 +00:00
parent 4997f33b5f
commit 5f0636faad
2 changed files with 27 additions and 1 deletions

View File

@@ -312,7 +312,7 @@ func (ra *remoteAction) IsCheckout() bool {
func newRemoteAction(action string) *remoteAction { func newRemoteAction(action string) *remoteAction {
// support http(s)://host/owner/repo@v3 // 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 { if after, ok := strings.CutPrefix(action, schema); ok {
splits := strings.SplitN(after, "/", 2) splits := strings.SplitN(after, "/", 2)
if len(splits) != 2 { if len(splits) != 2 {

View File

@@ -778,6 +778,32 @@ func Test_newRemoteAction(t *testing.T) {
}, },
wantCloneURL: "http://gitea.com/actions/aws", 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 { for _, tt := range tests {
t.Run(tt.action, func(t *testing.T) { t.Run(tt.action, func(t *testing.T) {