A shepherd for your Appimages.
0

Configure Feed

Select the types of activity you want to include in your feed.

upgrade: add gitlab source

Aly Raffauf (Jun 17, 2026, 9:53 PM EDT) 6fc145ea f55101c7

+279 -17
+11
cmd/appherder/source.go
··· 125 125 tag: fields[3], 126 126 pattern: strings.TrimSuffix(fields[4], ".zsync"), 127 127 }, nil 128 + case "gl-releases-zsync", "gl-releases-direct": 129 + // gl-releases-zsync|host|project|tag|pattern.zsync (our convention) 130 + if len(fields) != 5 { 131 + return nil, fmt.Errorf("malformed GitLab update info %q", info) 132 + } 133 + return gitlabReleaseSource{ 134 + host: fields[1], 135 + project: fields[2], 136 + tag: fields[3], 137 + pattern: strings.TrimSuffix(fields[4], ".zsync"), 138 + }, nil 128 139 case "zsync": 129 140 // zsync|https://host/path/App-latest.AppImage.zsync 130 141 if len(fields) != 2 || fields[1] == "" {
+137
cmd/appherder/source_gitlab.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "net/http" 8 + "net/url" 9 + "os" 10 + "path" 11 + "sort" 12 + "strconv" 13 + ) 14 + 15 + // gitlabReleaseSource resolves updates from a project's GitLab releases. The 16 + // gl-releases-* update-info token is our own convention, and since GitLab's API 17 + // carries no asset digest, the checksum comes from the sibling .zsync asset. 18 + type gitlabReleaseSource struct { 19 + host, project, tag, pattern string 20 + api string // base URL; "" means https://{host} (overridable for tests) 21 + } 22 + 23 + type glLink struct { 24 + Name string `json:"name"` 25 + URL string `json:"url"` 26 + DirectAssetURL string `json:"direct_asset_url"` 27 + } 28 + 29 + type glRelease struct { 30 + TagName string `json:"tag_name"` 31 + Assets struct { 32 + Links []glLink `json:"links"` 33 + } `json:"assets"` 34 + } 35 + 36 + func (s gitlabReleaseSource) latest(ctx context.Context) (release, error) { 37 + base := s.api 38 + if base == "" { 39 + base = "https://" + s.host 40 + } 41 + // The project path goes in one segment, so its slashes must be encoded. 42 + endpoint := fmt.Sprintf("%s/api/v4/projects/%s/releases/", base, url.PathEscape(s.project)) 43 + if s.tag == "" || s.tag == "latest" { 44 + endpoint += "permalink/latest" 45 + } else { 46 + endpoint += url.PathEscape(s.tag) 47 + } 48 + 49 + ctx, cancel := context.WithTimeout(ctx, apiTimeout) 50 + defer cancel() 51 + 52 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) 53 + if err != nil { 54 + return release{}, err 55 + } 56 + if tok := gitlabToken(); tok != "" { 57 + req.Header.Set("PRIVATE-TOKEN", tok) 58 + } 59 + 60 + resp, err := httpClient.Do(req) 61 + if err != nil { 62 + return release{}, fmt.Errorf("query GitLab releases for %s: %w", s.project, err) 63 + } 64 + defer resp.Body.Close() 65 + if resp.StatusCode != http.StatusOK { 66 + return release{}, fmt.Errorf("query GitLab releases for %s: %s", s.project, resp.Status) 67 + } 68 + 69 + var rel glRelease 70 + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { 71 + return release{}, fmt.Errorf("decode GitLab release for %s: %w", s.project, err) 72 + } 73 + 74 + asset, err := matchLink(rel.Assets.Links, s.pattern) 75 + if err != nil { 76 + return release{}, err 77 + } 78 + out := release{version: rel.TagName, url: linkURL(asset)} 79 + 80 + // No digest from the API: take the checksum from the sibling .zsync asset 81 + // when present, else comparison falls back to size. 82 + if z, ok := findLink(rel.Assets.Links, asset.Name+".zsync"); ok { 83 + header, err := fetchZsyncHeader(ctx, linkURL(z)) 84 + if err != nil { 85 + return release{}, err 86 + } 87 + out.sha1 = header["sha-1"] 88 + if n, err := strconv.ParseInt(header["length"], 10, 64); err == nil { 89 + out.size = n 90 + } 91 + } 92 + return out, nil 93 + } 94 + 95 + // matchLink picks the release asset whose name matches pattern (a glob), taking 96 + // the first by name when several match, for determinism. 97 + func matchLink(links []glLink, pattern string) (glLink, error) { 98 + var matches []glLink 99 + for _, l := range links { 100 + if ok, _ := path.Match(pattern, l.Name); ok { 101 + matches = append(matches, l) 102 + } 103 + } 104 + if len(matches) == 0 { 105 + return glLink{}, fmt.Errorf("no GitLab release asset matches %q", pattern) 106 + } 107 + sort.Slice(matches, func(i, j int) bool { return matches[i].Name < matches[j].Name }) 108 + return matches[0], nil 109 + } 110 + 111 + func findLink(links []glLink, name string) (glLink, bool) { 112 + for _, l := range links { 113 + if l.Name == name { 114 + return l, true 115 + } 116 + } 117 + return glLink{}, false 118 + } 119 + 120 + // linkURL prefers the permalinked direct_asset_url, falling back to the raw url. 121 + func linkURL(l glLink) string { 122 + if l.DirectAssetURL != "" { 123 + return l.DirectAssetURL 124 + } 125 + return l.URL 126 + } 127 + 128 + // gitlabToken returns a token from the environment, used for private projects 129 + // and to raise rate limits. It is never sent to asset download URLs. 130 + func gitlabToken() string { 131 + for _, key := range []string{"GL_TOKEN", "GITLAB_TOKEN"} { 132 + if v := os.Getenv(key); v != "" { 133 + return v 134 + } 135 + } 136 + return "" 137 + }
+104
cmd/appherder/source_gitlab_test.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "net/http" 7 + "net/http/httptest" 8 + "strings" 9 + "testing" 10 + ) 11 + 12 + func TestParseUpdateInfoGitLabStripsZsyncSuffix(t *testing.T) { 13 + src, err := parseUpdateInfo("gl-releases-zsync|gitlab.com|mygroup/myapp|latest|MyApp-*-x86_64.AppImage.zsync") 14 + if err != nil { 15 + t.Fatal(err) 16 + } 17 + gl, ok := src.(gitlabReleaseSource) 18 + if !ok { 19 + t.Fatalf("got %T, want gitlabReleaseSource", src) 20 + } 21 + if gl.host != "gitlab.com" || gl.project != "mygroup/myapp" || gl.tag != "latest" { 22 + t.Fatalf("parsed = %+v", gl) 23 + } 24 + if gl.pattern != "MyApp-*-x86_64.AppImage" { 25 + t.Fatalf("pattern = %q, want .zsync stripped", gl.pattern) 26 + } 27 + } 28 + 29 + func TestParseUpdateInfoRejectsMalformedGitLab(t *testing.T) { 30 + if _, err := parseUpdateInfo("gl-releases-zsync|too|few"); err == nil { 31 + t.Fatal("expected error for malformed gl-releases info") 32 + } 33 + } 34 + 35 + func TestGitLabReleaseSourceLatest(t *testing.T) { 36 + var srv *httptest.Server 37 + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 38 + switch r.URL.Path { 39 + case "/dl/MyApp-x86_64.AppImage.zsync": 40 + fmt.Fprint(w, sampleZsync) 41 + default: // the releases API 42 + if !strings.Contains(r.URL.EscapedPath(), "mygroup%2Fmyapp") { 43 + t.Errorf("project not encoded in path: %s", r.URL.EscapedPath()) 44 + } 45 + fmt.Fprintf(w, `{ 46 + "tag_name": "v2.0.0", 47 + "assets": {"links": [ 48 + {"name": "MyApp-x86_64.AppImage", "url": "%[1]s/x", "direct_asset_url": "%[1]s/dl/MyApp-x86_64.AppImage"}, 49 + {"name": "MyApp-x86_64.AppImage.zsync", "url": "%[1]s/x.zsync", "direct_asset_url": "%[1]s/dl/MyApp-x86_64.AppImage.zsync"} 50 + ]} 51 + }`, srv.URL) 52 + } 53 + })) 54 + defer srv.Close() 55 + 56 + src := gitlabReleaseSource{host: "gitlab.com", project: "mygroup/myapp", tag: "latest", pattern: "MyApp-*.AppImage", api: srv.URL} 57 + rel, err := src.latest(context.Background()) 58 + if err != nil { 59 + t.Fatal(err) 60 + } 61 + if rel.version != "v2.0.0" { 62 + t.Fatalf("version = %q", rel.version) 63 + } 64 + if rel.url != srv.URL+"/dl/MyApp-x86_64.AppImage" { 65 + t.Fatalf("url = %q, want direct_asset_url", rel.url) 66 + } 67 + if rel.sha1 != "da39a3ee5e6b4b0d3255bfef95601890afd80709" { 68 + t.Fatalf("sha1 = %q, want value from sibling .zsync", rel.sha1) 69 + } 70 + if rel.size != 4096 { 71 + t.Fatalf("size = %d, want length from sibling .zsync", rel.size) 72 + } 73 + } 74 + 75 + func TestGitLabReleaseSourceUsesTagEndpoint(t *testing.T) { 76 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 77 + if !strings.HasSuffix(r.URL.Path, "/releases/v9") { 78 + t.Errorf("path = %s, want tag endpoint", r.URL.Path) 79 + } 80 + fmt.Fprint(w, `{"tag_name":"v9","assets":{"links":[{"name":"a.AppImage","url":"u"}]}}`) 81 + })) 82 + defer srv.Close() 83 + 84 + src := gitlabReleaseSource{host: "h", project: "o/r", tag: "v9", pattern: "*.AppImage", api: srv.URL} 85 + rel, err := src.latest(context.Background()) 86 + if err != nil { 87 + t.Fatal(err) 88 + } 89 + if rel.url != "u" { // no direct_asset_url, falls back to url 90 + t.Fatalf("url = %q", rel.url) 91 + } 92 + } 93 + 94 + func TestGitLabReleaseSourceErrorsOnNotFound(t *testing.T) { 95 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 96 + http.Error(w, "not found", http.StatusNotFound) 97 + })) 98 + defer srv.Close() 99 + 100 + src := gitlabReleaseSource{host: "h", project: "o/r", tag: "latest", pattern: "*.AppImage", api: srv.URL} 101 + if _, err := src.latest(context.Background()); err == nil { 102 + t.Fatal("expected error on 404") 103 + } 104 + }
+27 -17
cmd/appherder/source_zsync.go
··· 20 20 } 21 21 22 22 func (s zsyncURLSource) latest(ctx context.Context) (release, error) { 23 - ctx, cancel := context.WithTimeout(ctx, apiTimeout) 24 - defer cancel() 25 - 26 - req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.url, nil) 23 + header, err := fetchZsyncHeader(ctx, s.url) 27 24 if err != nil { 28 25 return release{}, err 29 26 } 30 - resp, err := httpClient.Do(req) 31 - if err != nil { 32 - return release{}, fmt.Errorf("fetch zsync control file %s: %w", s.url, err) 33 - } 34 - defer resp.Body.Close() 35 - if resp.StatusCode != http.StatusOK { 36 - return release{}, fmt.Errorf("fetch zsync control file %s: %s", s.url, resp.Status) 37 - } 38 - 39 - header, err := parseZsyncHeader(resp.Body) 40 - if err != nil { 41 - return release{}, fmt.Errorf("parse zsync control file %s: %w", s.url, err) 42 - } 43 27 44 28 target, err := resolveZsyncURL(s.url, header["url"]) 45 29 if err != nil { ··· 55 39 rel.size = n 56 40 } 57 41 return rel, nil 42 + } 43 + 44 + // fetchZsyncHeader downloads a .zsync control file and returns its parsed 45 + // header. Reused wherever a checksum can be read from a sibling .zsync asset. 46 + func fetchZsyncHeader(ctx context.Context, zsyncURL string) (map[string]string, error) { 47 + ctx, cancel := context.WithTimeout(ctx, apiTimeout) 48 + defer cancel() 49 + 50 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, zsyncURL, nil) 51 + if err != nil { 52 + return nil, err 53 + } 54 + resp, err := httpClient.Do(req) 55 + if err != nil { 56 + return nil, fmt.Errorf("fetch zsync control file %s: %w", zsyncURL, err) 57 + } 58 + defer resp.Body.Close() 59 + if resp.StatusCode != http.StatusOK { 60 + return nil, fmt.Errorf("fetch zsync control file %s: %s", zsyncURL, resp.Status) 61 + } 62 + 63 + header, err := parseZsyncHeader(resp.Body) 64 + if err != nil { 65 + return nil, fmt.Errorf("parse zsync control file %s: %w", zsyncURL, err) 66 + } 67 + return header, nil 58 68 } 59 69 60 70 // parseZsyncHeader reads a .zsync file's text header: "Key: Value" lines ending