feat: releases from GitHub first, then git.kebcloud.no; releases sets others
Updates and install.sh try the GitHub mirror, then git.kebcloud.no, each with a short timeout, and download from the first server with the release, checked against its checksums.txt. The releases config option (and LAZYSECRETS_RELEASES for install.sh) lists other servers. The release workflow copies each release to GitHub when GH_RELEASE_TOKEN is set. The status pane's issues link points to GitHub. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c00f4b14c4
commit
b3c1911ba5
13 files changed
+329
-70
No files matched your search
+91
-30
@@ -3,6 +3,9 @@
|
||||
// against the release's checksums.txt, tried (--version), then renamed
|
||||
// over the binary. A running lazysecrets keeps its old binary until it
|
||||
// restarts.
|
||||
//
|
||||
// Releases are published in more than one place; each is tried in turn
|
||||
// (GitHub, then git.kebcloud.no), so one being down doesn't stop updates.
|
||||
package update
|
||||
|
||||
import (
|
||||
@@ -18,6 +21,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
@@ -30,12 +34,20 @@ import (
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
// Where releases are published.
|
||||
// DefaultRepo is lazysecrets' home: its source, docs and releases.
|
||||
// GitHubRepo mirrors it, with the issues and the releases too.
|
||||
const (
|
||||
DefaultAPI = "https://git.kebcloud.no/api/v1/repos/keb/lazysecrets"
|
||||
DefaultRepo = "https://git.kebcloud.no/keb/lazysecrets"
|
||||
GitHubRepo = "https://github.com/karleinarb/lazysecrets"
|
||||
)
|
||||
|
||||
// DefaultServers are where releases are looked for, in order.
|
||||
var DefaultServers = []string{GitHubRepo, DefaultRepo}
|
||||
|
||||
// latestTimeout bounds asking one server for the latest release, so a
|
||||
// server that's down leaves time for the next.
|
||||
const latestTimeout = 5 * time.Second
|
||||
|
||||
// maxDownload bounds what's read from the server.
|
||||
const maxDownload = 200 << 20
|
||||
|
||||
@@ -60,15 +72,20 @@ func PlainLinks(s string) string { return mdLink.ReplaceAllString(s, "$1") }
|
||||
// and a terminal shows them as their text.
|
||||
var mdLink = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^)\s]+)\)`)
|
||||
|
||||
// Client talks to the release server.
|
||||
// Client talks to the release servers.
|
||||
type Client struct {
|
||||
API string // the repository's API, DefaultAPI when empty
|
||||
Repo string // the repository's web address, DefaultRepo when empty
|
||||
HTTP *http.Client
|
||||
// Servers are the repositories' web addresses, on GitHub or a Gitea
|
||||
// or Forgejo server, tried in order; DefaultServers when empty.
|
||||
Servers []string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
func (c *Client) api() string { return cmpOr(c.API, DefaultAPI) }
|
||||
func (c *Client) repo() string { return cmpOr(c.Repo, DefaultRepo) }
|
||||
func (c *Client) servers() []string {
|
||||
if len(c.Servers) > 0 {
|
||||
return c.Servers
|
||||
}
|
||||
return DefaultServers
|
||||
}
|
||||
|
||||
func (c *Client) client() *http.Client {
|
||||
if c.HTTP != nil {
|
||||
@@ -77,25 +94,50 @@ func (c *Client) client() *http.Client {
|
||||
return &http.Client{Timeout: 2 * time.Minute}
|
||||
}
|
||||
|
||||
func cmpOr(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
// API is a repository's API: GitHub's for github.com, else Gitea's (and
|
||||
// Forgejo's).
|
||||
func API(repo string) (string, error) {
|
||||
u, err := url.Parse(strings.TrimSuffix(repo, "/"))
|
||||
if err != nil || u.Host == "" || strings.Count(u.Path, "/") != 2 {
|
||||
return "", fmt.Errorf("%q isn't a repository's address, like %s", repo, GitHubRepo)
|
||||
}
|
||||
return b
|
||||
if u.Host == "github.com" {
|
||||
return "https://api.github.com/repos" + u.Path, nil
|
||||
}
|
||||
return u.Scheme + "://" + u.Host + "/api/v1/repos" + u.Path, nil
|
||||
}
|
||||
|
||||
// Latest is the newest release (pre-releases aside).
|
||||
// Latest is the newest release (pre-releases aside), from the first
|
||||
// server that answers.
|
||||
func (c *Client) Latest(ctx context.Context) (Release, error) {
|
||||
b, err := c.get(ctx, c.api()+"/releases/latest", 1<<20)
|
||||
var errs []error
|
||||
for _, repo := range c.servers() {
|
||||
r, err := c.latest(ctx, repo)
|
||||
if err == nil {
|
||||
return r, nil
|
||||
}
|
||||
errs = append(errs, err)
|
||||
}
|
||||
return Release{}, errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (c *Client) latest(ctx context.Context, repo string) (Release, error) {
|
||||
api, err := API(repo)
|
||||
if err != nil {
|
||||
return Release{}, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, latestTimeout)
|
||||
defer cancel()
|
||||
b, err := c.get(ctx, api+"/releases/latest", 1<<20)
|
||||
if err != nil {
|
||||
return Release{}, err
|
||||
}
|
||||
var r Release
|
||||
if err := json.Unmarshal(b, &r); err != nil {
|
||||
return Release{}, fmt.Errorf("reading the latest release: %w", err)
|
||||
return Release{}, fmt.Errorf("reading the latest release from %s: %w", repo, err)
|
||||
}
|
||||
if !semver.IsValid(r.Tag) {
|
||||
return Release{}, fmt.Errorf("latest release %q isn't a version", r.Tag)
|
||||
return Release{}, fmt.Errorf("latest release %q on %s isn't a version", r.Tag, repo)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
@@ -147,23 +189,10 @@ func (c *Client) Install(ctx context.Context, r Release, exe string) error {
|
||||
}
|
||||
version := strings.TrimPrefix(r.Tag, "v")
|
||||
name := fmt.Sprintf("lazysecrets_%s_%s_%s", version, runtime.GOOS, runtime.GOARCH)
|
||||
base := c.repo() + "/releases/download/" + r.Tag + "/"
|
||||
|
||||
sums, err := c.get(ctx, base+"checksums.txt", 1<<20)
|
||||
archive, err := c.download(ctx, r.Tag, name+".tar.gz")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
want, err := checksum(sums, name+".tar.gz")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
archive, err := c.get(ctx, base+name+".tar.gz", maxDownload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if got := sha256.Sum256(archive); hex.EncodeToString(got[:]) != want {
|
||||
return fmt.Errorf("%s.tar.gz doesn't match checksums.txt: damaged, or not the release", name)
|
||||
}
|
||||
bin, err := extract(archive, path.Join(name, "lazysecrets"))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -198,6 +227,38 @@ func (c *Client) Install(ctx context.Context, r Release, exe string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// download fetches a release's file from the first server that has it,
|
||||
// checked against that server's checksums.txt. A file that doesn't match
|
||||
// stops there: it's damaged, or not the release.
|
||||
func (c *Client) download(ctx context.Context, tag, file string) ([]byte, error) {
|
||||
var errs []error
|
||||
for _, repo := range c.servers() {
|
||||
base := strings.TrimSuffix(repo, "/") + "/releases/download/" + tag + "/"
|
||||
sctx, cancel := context.WithTimeout(ctx, latestTimeout)
|
||||
sums, err := c.get(sctx, base+"checksums.txt", 1<<20)
|
||||
cancel()
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
want, err := checksum(sums, file)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("%s: %w", repo, err))
|
||||
continue
|
||||
}
|
||||
b, err := c.get(ctx, base+file, maxDownload)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
if got := sha256.Sum256(b); hex.EncodeToString(got[:]) != want {
|
||||
return nil, fmt.Errorf("%s from %s doesn't match checksums.txt: damaged, or not the release", file, repo)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
return nil, errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, url string, limit int64) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
|
||||
Reference in new issue
Block a user