Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ All notable changes to teploy-dash are recorded here.

## [Unreleased]

### Fixed
- **Git sources on self-hosted forges whose SSH and HTTP ports differ.**
Repository matching now compares host and path only, ignoring scheme
and port, so an app that pulls over `ssh://git@forge:2222/o/r` and a
forge credential or webhook source spelled `http://forge:3000/o/r`
name the same repository. Before, a push woke nothing ("no app is
connected to this repository yet") and the credential was refused as a
scope mismatch, leaving only the server's poll timer to deploy. The
forge API is still derived from the credential's HTTP(S) address,
never from an SSH port. Source IDs and webhook URLs are unchanged.

## [0.1.18] - 2026-10-20

### Added
Expand Down
3 changes: 1 addition & 2 deletions internal/server/gitsource.go
Original file line number Diff line number Diff line change
Expand Up @@ -719,8 +719,7 @@ func (s *Server) gitAppMatch(ctx context.Context, serverName, appName, repositor
if err != nil || !hasGit {
return false, true
}
canonical, err := source.CanonicalURL(configRepo)
if err != nil || canonical != repository {
if !source.SameRepository(configRepo, repository) {
return false, true
}
return configBranch == branch, false
Expand Down
7 changes: 2 additions & 5 deletions internal/source/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,7 @@ func (p *Providers) Policy(src *Source) (Provider, error) {
log.Printf("[source] provider %q has a \"preview\" policy; pull request previews were removed and the key is ignored", src.CredentialRef)
}
v := entry.Provider
repo, e := CanonicalURL(v.Repository)
want, we := CanonicalURL(src.CloneURL)
if e != nil || we != nil || repo != want || v.Forge != src.Forge {
if !SameRepository(v.Repository, src.CloneURL) || v.Forge != src.Forge {
return Provider{}, fmt.Errorf("source provider repository or forge scope mismatch")
}
u, e := url.Parse(v.Repository)
Expand Down Expand Up @@ -426,8 +424,7 @@ func (p *Providers) CredentialRefFor(rawCloneURL string) (string, Provider, bool
}
sort.Strings(names)
for _, name := range names {
repo, err := CanonicalURL(entries[name].Repository)
if err != nil || repo != want {
if !SameRepository(entries[name].Repository, want) {
continue
}
return name, entries[name].Provider, true
Expand Down
22 changes: 22 additions & 0 deletions internal/source/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,9 @@ func TestCredentialRefForFindsMatchingProvider(t *testing.T) {
if ref, _, ok := providers.CredentialRefFor("https://forge.example/other/repo/"); !ok || ref != "beta" {
t.Fatalf("lookup = %q %v", ref, ok)
}
if ref, _, ok := providers.CredentialRefFor("ssh://git@forge.example:2222/other/repo.git"); !ok || ref != "beta" {
t.Fatalf("ssh spelling on another port missed the HTTPS provider: %q %v", ref, ok)
}
if _, _, ok := providers.CredentialRefFor("https://github.com/nothing/repo"); ok {
t.Fatal("unrelated repository matched a provider")
}
Expand All @@ -541,3 +544,22 @@ func TestCredentialRefForFindsMatchingProvider(t *testing.T) {
t.Fatal("damaged provider file matched")
}
}

func TestProviderPolicyAcceptsSSHSourceOnAnotherPort(t *testing.T) {
path := filepath.Join(t.TempDir(), "providers.json")
os.WriteFile(path, []byte(`{"forge":{"forge":"forgejo","repository":"http://forge.lan:49152/tyler/app","allow_private":true,"token_env":"TOK"}}`), 0600)
providers := &Providers{Path: path}
src := &Source{Forge: ForgeForgejo, CloneURL: "https://forge.lan:2222/tyler/app", CredentialRef: "forge"}
policy, err := providers.Policy(src)
if err != nil {
t.Fatal(err)
}
// The API is reached on the HTTP port, never the SSH one.
if policy.APIURL != "http://forge.lan:49152/api/v1" {
t.Fatalf("api url = %q", policy.APIURL)
}
src.CloneURL = "https://forge.lan:2222/tyler/other"
if _, err := providers.Policy(src); err == nil {
t.Fatal("credential applied to another repository")
}
}
9 changes: 4 additions & 5 deletions internal/source/routing.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,19 +121,18 @@ func (r *Routing) Unlink(server, app string) error {
return r.persist()
}

// RoutesFor returns the apps cached against one repository (canonical URL
// match), oldest link first so relays wake the longest-connected app
// RoutesFor returns the apps cached against one repository (host and path,
// any transport), oldest link first so relays wake the longest-connected app
// first when several share a repository.
func (r *Routing) RoutesFor(repository string) []Route {
canonical, err := CanonicalURL(repository)
if err != nil {
if _, err := RepositoryKey(repository); err != nil {
return nil
}
r.mu.Lock()
defer r.mu.Unlock()
var out []Route
for _, route := range r.routes {
if route.Repository == canonical {
if SameRepository(route.Repository, repository) {
out = append(out, route)
}
}
Expand Down
18 changes: 18 additions & 0 deletions internal/source/routing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,21 @@ func TestRoutingRejectsInvalidInput(t *testing.T) {
t.Fatal("missing app accepted")
}
}

func TestRoutingMatchesAcrossTransports(t *testing.T) {
routing, err := OpenRouting(t.TempDir())
if err != nil {
t.Fatal(err)
}
// The app pulls over SSH on the forge's SSH port; the source was
// registered with the forge's HTTP clone URL on another port.
if err := routing.Link("infra-home", "web", "ssh://git@forge.lan:2222/tyler/app.git"); err != nil {
t.Fatal(err)
}
if got := routing.RoutesFor("http://forge.lan:49152/tyler/app"); len(got) != 1 || got[0].App != "web" {
t.Fatalf("routes = %+v", got)
}
if got := routing.RoutesFor("http://forge.lan:49152/tyler/other"); len(got) != 0 {
t.Fatalf("other repository woke %+v", got)
}
}
24 changes: 24 additions & 0 deletions internal/source/url.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,30 @@ func CanonicalURL(rawCloneURL string) (string, error) {
return strings.ToLower(normalized), nil
}

// RepositoryKey is the repository join key: case-folded host and path,
// without scheme or port. Transport is not identity, and a self-hosted
// forge usually serves SSH and HTTP on different ports, so
// ssh://git@h:2222/o/r and http://h:3000/o/r name one repository. Source
// IDs keep the full canonical URL (IdentityKey); only matching uses this.
func RepositoryKey(rawCloneURL string) (string, error) {
canonical, err := CanonicalURL(rawCloneURL)
if err != nil {
return "", err
}
u, err := url.Parse(canonical)
if err != nil {
return "", err
}
return u.Hostname() + u.Path, nil
}

// SameRepository reports whether two clone URLs name one repository.
func SameRepository(a, b string) bool {
ka, ea := RepositoryKey(a)
kb, eb := RepositoryKey(b)
return ea == nil && eb == nil && ka == kb
}

// DeriveID returns the stable source ID — the fleet-envelope convention
// (deterministic sha256 of the identity key, truncated to 16 hex chars).
func DeriveID(forge Forge, rawCloneURL string) (string, error) {
Expand Down
24 changes: 24 additions & 0 deletions internal/source/url_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,27 @@ func TestCanonicalURLJoinsAcrossCase(t *testing.T) {
t.Fatalf("canonical join key must fold case and transport: %q vs %q", a, b)
}
}

func TestSameRepositoryIgnoresTransport(t *testing.T) {
same := [][2]string{
{"ssh://git@forge.lan:2222/tyler/app.git", "http://forge.lan:49152/tyler/app"},
{"git@github.com:Owner/Repo.git", "https://github.com/owner/repo"},
{"https://forge.lan:8443/tyler/app/", "ssh://forge.lan/tyler/app"},
}
for _, pair := range same {
if !SameRepository(pair[0], pair[1]) {
t.Errorf("%q and %q should name one repository", pair[0], pair[1])
}
}
different := [][2]string{
{"ssh://git@forge.lan:2222/tyler/app", "http://forge.lan:49152/tyler/other"},
{"ssh://git@forge.lan:2222/tyler/app", "http://other.lan:49152/tyler/app"},
{"https://github.com/owner/repo", "https://github.com/owner/repo/sub"},
{"not a url", "not a url"},
}
for _, pair := range different {
if SameRepository(pair[0], pair[1]) {
t.Errorf("%q and %q must not match", pair[0], pair[1])
}
}
}
Loading