diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 676d897..7619357 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -4,8 +4,9 @@ name: Release # as a pre-release) tests lazysecrets, then GoReleaser (.goreleaser.yaml) # builds it for macOS and Linux, arm64 and amd64, and publishes a Gitea # release with the archives, checksums and this release's section of the -# changelog (git-cliff, cliff.toml). Pushing the same tag again (after -# deleting it) replaces the release. +# changelog (git-cliff, cliff.toml), then copies it to the GitHub +# mirror. Pushing the same tag again (after deleting it) replaces the +# release in both places. # # scripts/release.sh v0.2.0 # updates CHANGELOG.md, tags and pushes @@ -81,3 +82,14 @@ jobs: args: release --clean --release-notes ${{ runner.temp }}/notes.md env: GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Copy the release to GitHub + # The same notes and files on the GitHub mirror, which lazysecrets + # updates from first (internal/update). GH_RELEASE_TOKEN is a + # fine-grained token that can write the mirror's contents; without + # it the step does nothing. + run: node scripts/github-release.mjs + env: + TAG: ${{ github.ref_name }} + GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }} + GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index c132687..1f6e02b 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,9 @@ On macOS or Linux (arm64 or x86_64): curl -fsSL https://git.kebcloud.no/keb/lazysecrets/raw/branch/main/install.sh | sh ``` -It installs the latest [release](https://git.kebcloud.no/keb/lazysecrets/releases) -into `~/.local/bin` after checking it against the release's checksums. +It installs the latest release, from GitHub or else +[git.kebcloud.no](https://git.kebcloud.no/keb/lazysecrets/releases), into +`~/.local/bin` after checking it against the release's checksums. `sh -s -- v0.2.0` installs a given version, `LAZYSECRETS_INSTALL_DIR` somewhere else. @@ -96,8 +97,8 @@ lazysecrets never stores, caches or sends your secret values. It keeps the names and metadata of what you've loaded in a cache file readable only by you, to show it at once next time (`cache: false` turns it off). It talks only to the services you use it with, and collects no usage data. The one -call of its own is a daily check for a new release on this server, which -`updateCheck: false` turns off. +call of its own is a daily check for a new release, on GitHub or else +git.kebcloud.no, which `updateCheck: false` turns off. ## Repositories diff --git a/cmd/lazysecrets/main.go b/cmd/lazysecrets/main.go index ff6dfe4..f680c19 100644 --- a/cmd/lazysecrets/main.go +++ b/cmd/lazysecrets/main.go @@ -155,7 +155,7 @@ Flags: case len(args) > 0 && args[0] == "launch": return launch(*configPath, args[1:]) case len(args) > 0 && args[0] == "update": - return updateCmd(args[1:]) + return updateCmd(*configPath, args[1:]) case len(args) == 0: case args[0] == "open" && len(args) == 2: r, err := ref.Parse(args[1]) @@ -364,7 +364,7 @@ Flags: ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour, HideOnBlur: config.On(cfg.HideOnBlur), HandlerSetup: setup, - Updater: updater(), + Updater: updater(cfg.Releases), UpdateCheck: config.On(cfg.UpdateCheck), Keys: &keymap, Commands: cfg.CustomCommands, diff --git a/cmd/lazysecrets/update.go b/cmd/lazysecrets/update.go index 4231642..a56111b 100644 --- a/cmd/lazysecrets/update.go +++ b/cmd/lazysecrets/update.go @@ -7,14 +7,16 @@ import ( "strings" "time" + "git.kebcloud.no/keb/lazysecrets/internal/config" "git.kebcloud.no/keb/lazysecrets/internal/ui/app" "git.kebcloud.no/keb/lazysecrets/internal/update" ) // updater is the app's way to find and install releases, replacing the -// binary that's running. -func updater() *app.Updater { - c := &update.Client{} +// binary that's running, from the servers (config releases) or the +// default ones. +func updater(servers []string) *app.Updater { + c := &update.Client{Servers: servers} return &app.Updater{ Latest: c.Latest, Install: func(ctx context.Context, r update.Release) error { @@ -29,15 +31,19 @@ func updater() *app.Updater { // updateCmd is lazysecrets update [--check]: the newer release's // changelog, then (unless --check) install it. -func updateCmd(args []string) error { +func updateCmd(configPath string, args []string) error { check := len(args) == 1 && args[0] == "--check" if len(args) > 0 && !check { return errors.New("usage: lazysecrets update [--check]") } + cfg, err := config.LoadAll(strings.Split(configPath, ",")) + if err != nil { + return err + } current := versionString() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() - c := &update.Client{} + c := &update.Client{Servers: cfg.Releases} r, err := c.Latest(ctx) if err != nil { return fmt.Errorf("looking for a newer lazysecrets: %w", err) diff --git a/docs/config.md b/docs/config.md index 487e8e1..f7a04e6 100644 --- a/docs/config.md +++ b/docs/config.md @@ -15,6 +15,9 @@ loadDelay: 150ms # how long the cursor rests on a row before it loads; 0s sourcesView: grouped # or flat; ` toggles it collapseGroups: true # start with the Sources pane's groups folded; enter or space unfolds one updateCheck: true # look for a newer release once a day (U and lazysecrets update work either way) +releases: # where updates come from, tried in order (GitHub, or a Gitea or Forgejo server) + - https://github.com/karleinarb/lazysecrets + - https://git.kebcloud.no/keb/lazysecrets history: true # count uses of items for Recent (R) cache: true # keep what was loaded (never values) for the next start providers: @@ -125,14 +128,17 @@ are read from disk anyway. Deleting the file is always safe, and ## Updates -Once a day (`updateCheck`), in the background, lazysecrets asks the -release server for the latest release; within the day it reuses the -answer from the state file. Offline, or with the server down, it says -nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or +Once a day (`updateCheck`), in the background, lazysecrets asks for the +latest release; within the day it reuses the answer from the state file. +Releases are published on GitHub and on git.kebcloud.no, and it asks +them in that order, the next when one doesn't answer within 5 seconds; +`releases` lists others, such as a mirror of your own. Offline, or with +every server down, it says nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or `lazysecrets update --check`) shows its changelog. Updating (`u` there, or `lazysecrets update`) downloads the release for -your OS and CPU, checks it against the release's `checksums.txt`, makes +your OS and CPU from the first server that has it, checks it against +that server's `checksums.txt`, makes sure it runs, and only then replaces the binary, atomically: a lazysecrets that's running keeps working, and the next start is the new version. A binary a package manager owns (Homebrew, Nix, a distro diff --git a/docs/development.md b/docs/development.md index ee9167e..40e1a24 100644 --- a/docs/development.md +++ b/docs/development.md @@ -56,8 +56,12 @@ from the pull request titles. The tag runs the release workflow (`.gitea/workflows/release.yml`): it tests, then [GoReleaser](https://goreleaser.com) (`.goreleaser.yaml`) builds the archives and publishes a Gitea release with them and the release's -changelog section. `goreleaser release --snapshot --clean` builds the same -archives locally, into `dist/`, without publishing. +changelog section. Then `scripts/github-release.mjs` copies that release, +notes and files, to the GitHub mirror, with the `GH_RELEASE_TOKEN` +secret (a fine-grained token that can write the mirror's contents); +without the secret it skips that. `goreleaser release --snapshot +--clean` builds the same archives locally, into `dist/`, without +publishing. A suffix (`v0.2.0-rc.1`) makes a pre-release, which leaves `CHANGELOG.md` alone. diff --git a/install.sh b/install.sh index 6618290..5063b9d 100755 --- a/install.sh +++ b/install.sh @@ -7,10 +7,12 @@ # # LAZYSECRETS_INSTALL_DIR installs somewhere else. macOS and Linux, arm64 # and x86_64. +# +# Releases are on GitHub and git.kebcloud.no, tried in that order; +# LAZYSECRETS_RELEASES (repository addresses, space-separated) uses others. set -eu -repo=https://git.kebcloud.no/keb/lazysecrets -api=https://git.kebcloud.no/api/v1/repos/keb/lazysecrets +repos="${LAZYSECRETS_RELEASES:-https://github.com/karleinarb/lazysecrets https://git.kebcloud.no/keb/lazysecrets}" dir="${LAZYSECRETS_INSTALL_DIR:-$HOME/.local/bin}" fail() { @@ -18,12 +20,13 @@ fail() { exit 1 } +# Short timeouts, so a server that's down leaves time for the next. if command -v curl >/dev/null 2>&1; then - fetch() { curl -fsSL "$1"; } - download() { curl -fsSL -o "$2" "$1"; } + fetch() { curl -fsSL --connect-timeout 5 --max-time 15 "$1"; } + download() { curl -fsSL --connect-timeout 5 -o "$2" "$1"; } elif command -v wget >/dev/null 2>&1; then - fetch() { wget -qO- "$1"; } - download() { wget -qO "$2" "$1"; } + fetch() { wget -qO- -T 15 "$1"; } + download() { wget -qO "$2" -T 30 "$1"; } else fail "needs curl or wget" fi @@ -39,11 +42,26 @@ arm64 | aarch64) arch=arm64 ;; *) fail "no release for $(uname -m)" ;; esac +# api is a repository's API: GitHub's, or Gitea's (and Forgejo's). +api() { + case "$1" in + https://github.com/*) echo "https://api.github.com/repos/${1#https://github.com/}" ;; + *) + host=${1#*://} + host=${host%%/*} + echo "${1%%://*}://$host/api/v1/repos/${1#*://"$host"/}" + ;; + esac +} + tag="${1:-}" if [ -z "$tag" ]; then - tag=$(fetch "$api/releases/latest" | sed -n 's/.*"tag_name":"\([^"]*\)".*/\1/p') || - fail "couldn't look up the latest release" - [ -n "$tag" ] || fail "couldn't look up the latest release" + for repo in $repos; do + tag=$(fetch "$(api "$repo")/releases/latest" 2>/dev/null | + sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n 1) || tag= + [ -n "$tag" ] && break + done + [ -n "$tag" ] || fail "couldn't look up the latest release on $repos" fi case "$tag" in v*) ;; @@ -55,11 +73,19 @@ name="lazysecrets_${version}_${os}_${arch}" tmp=$(mktemp -d) trap 'rm -rf "$tmp"' EXIT -echo "Downloading lazysecrets $tag for $os/$arch" -download "$repo/releases/download/$tag/$name.tar.gz" "$tmp/$name.tar.gz" || - fail "couldn't download $tag for $os/$arch: see $repo/releases" -download "$repo/releases/download/$tag/checksums.txt" "$tmp/checksums.txt" || - fail "couldn't download $tag's checksums.txt" +# The archive and its checksums.txt from the first server that has both. +from= +for repo in $repos; do + base="$repo/releases/download/$tag" + if download "$base/checksums.txt" "$tmp/checksums.txt" 2>/dev/null; then + echo "Downloading lazysecrets $tag for $os/$arch from $repo" + if download "$base/$name.tar.gz" "$tmp/$name.tar.gz"; then + from=$repo + break + fi + fi +done +[ -n "$from" ] || fail "couldn't download $tag for $os/$arch from $repos" want=$(awk -v f="$name.tar.gz" '$2 == f { print $1 }' "$tmp/checksums.txt") [ -n "$want" ] || fail "$name.tar.gz isn't in checksums.txt" diff --git a/internal/config/config.go b/internal/config/config.go index 713bca1..9226056 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,6 +14,7 @@ import ( "go.yaml.in/yaml/v3" "git.kebcloud.no/keb/lazysecrets/internal/commands" + "git.kebcloud.no/keb/lazysecrets/internal/update" ) type Config struct { @@ -40,6 +41,10 @@ type Config struct { // background; defaults to true. U and lazysecrets update work either // way. UpdateCheck *bool `yaml:"updateCheck"` + // Releases are the repositories updates come from, tried in order: + // on GitHub, or a Gitea or Forgejo server. Defaults to GitHub, then + // git.kebcloud.no (update.DefaultServers). + Releases []string `yaml:"releases"` // Cache keeps what was loaded (never values) between runs, so lists // show at once and refresh in the background; defaults to true. Cache *bool `yaml:"cache"` @@ -211,6 +216,11 @@ func (c Config) validate(path string) error { if d := c.LoadDelay; d != nil && (*d < 0 || *d > 5*time.Second) { return fmt.Errorf("%s: loadDelay: %s is not between 0s and 5s", path, *d) } + for i, r := range c.Releases { + if _, err := update.API(r); err != nil || !strings.HasPrefix(r, "https://") { + return fmt.Errorf("%s: releases[%d]: %q is not an https:// repository address, like %s", path, i, r, update.GitHubRepo) + } + } for i, v := range c.Providers.Azure.Vaults { if !strings.HasPrefix(v, "https://") { return fmt.Errorf("%s: providers.azure.vaults[%d]: %q is not an https:// vault URL", path, i, v) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d5516a5..27a285b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -70,6 +70,8 @@ func TestLoadRejectsMistakes(t *testing.T) { "old layout": "azure:\n vaults: [https://kv.vault.azure.net/]\n", "duration": "timeout: soon\n", "view": "sourcesView: tree\n", + "releases": "releases: [git.example.com/team/lazysecrets]\n", + "http": "releases: [http://git.example.com/team/lazysecrets]\n", } { if _, err := Load(write(t, content)); err == nil { t.Errorf("%s: no error", name) @@ -113,6 +115,13 @@ theme: } } +func TestReleases(t *testing.T) { + c, err := Load(write(t, "releases: [https://git.example.com/team/lazysecrets]\n")) + if err != nil || len(c.Releases) != 1 { + t.Errorf("releases = %v, %v", c.Releases, err) + } +} + func TestLoadDelay(t *testing.T) { c, err := Load(write(t, "loadDelay: 300ms\n")) if err != nil || c.LoadDelay == nil || *c.LoadDelay != 300*time.Millisecond { diff --git a/internal/ui/app/status.go b/internal/ui/app/status.go index 7bc4730..829c4c7 100644 --- a/internal/ui/app/status.go +++ b/internal/ui/app/status.go @@ -122,7 +122,9 @@ func (m Model) statusFields() []panes.Field { repo := strings.TrimPrefix(update.DefaultRepo, "https://") add(panes.Field{Label: "docs", Value: repo + "/…/docs", URL: update.DefaultRepo + "/src/branch/main/docs"}) add(panes.Field{Label: "source", Value: repo, URL: update.DefaultRepo}) - add(panes.Field{Label: "issues", Value: repo + "/issues", URL: update.DefaultRepo + "/issues"}) + // Issues are on the GitHub mirror. + github := strings.TrimPrefix(update.GitHubRepo, "https://") + add(panes.Field{Label: "issues", Value: github + "/issues", URL: update.GitHubRepo + "/issues"}) add(panes.Field{Label: "releases", Value: repo + "/releases", URL: update.DefaultRepo + "/releases"}) add(panes.Field{Label: "changelog", Value: repo + "/…/CHANGELOG.md", URL: update.DefaultRepo + "/src/branch/main/CHANGELOG.md"}) return append(out, m.changelog...) diff --git a/internal/update/update.go b/internal/update/update.go index 4d1a9e3..c7e08f8 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -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 { diff --git a/internal/update/update_test.go b/internal/update/update_test.go index 496e916..190f7f0 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -70,13 +70,13 @@ func server(t *testing.T, tamper bool) *httptest.Server { sum[0] ^= 0xff } mux := http.NewServeMux() - mux.HandleFunc("/api/releases/latest", func(w http.ResponseWriter, _ *http.Request) { + mux.HandleFunc("/api/v1/repos/keb/lazysecrets/releases/latest", func(w http.ResponseWriter, _ *http.Request) { fmt.Fprint(w, `{"tag_name":"v0.2.0","body":"### Added\n\n- x\n\n## Install\n","html_url":"https://example.com/r"}`) }) - mux.HandleFunc("/repo/releases/download/v0.2.0/checksums.txt", func(w http.ResponseWriter, _ *http.Request) { + mux.HandleFunc("/keb/lazysecrets/releases/download/v0.2.0/checksums.txt", func(w http.ResponseWriter, _ *http.Request) { fmt.Fprintf(w, "%s %s.tar.gz\n", hex.EncodeToString(sum[:]), name) }) - mux.HandleFunc("/repo/releases/download/v0.2.0/"+name+".tar.gz", func(w http.ResponseWriter, _ *http.Request) { + mux.HandleFunc("/keb/lazysecrets/releases/download/v0.2.0/"+name+".tar.gz", func(w http.ResponseWriter, _ *http.Request) { w.Write(archive.Bytes()) }) srv := httptest.NewServer(mux) @@ -89,7 +89,7 @@ func TestInstall(t *testing.T) { t.Skip("releases are for macOS and Linux") } srv := server(t, false) - c := &Client{API: srv.URL + "/api", Repo: srv.URL + "/repo"} + c := &Client{Servers: []string{srv.URL + "/keb/lazysecrets"}} r, err := c.Latest(context.Background()) if err != nil || r.Tag != "v0.2.0" { t.Fatalf("latest %+v %v", r, err) @@ -107,12 +107,59 @@ func TestInstall(t *testing.T) { } } +// A server that's down, or doesn't have the release, is skipped. +func TestFallsBackToTheNextServer(t *testing.T) { + if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { + t.Skip("releases are for macOS and Linux") + } + down := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "down", http.StatusBadGateway) + })) + t.Cleanup(down.Close) + srv := server(t, false) + c := &Client{Servers: []string{down.URL + "/keb/lazysecrets", srv.URL + "/keb/lazysecrets"}} + r, err := c.Latest(context.Background()) + if err != nil || r.Tag != "v0.2.0" { + t.Fatalf("latest %+v %v", r, err) + } + exe := filepath.Join(t.TempDir(), "lazysecrets") + os.WriteFile(exe, []byte("old"), 0o755) + if err := c.Install(context.Background(), r, exe); err != nil { + t.Fatal(err) + } + if b, _ := os.ReadFile(exe); !strings.Contains(string(b), "echo lazysecrets 0.2.0") { + t.Errorf("not replaced: %q", b) + } + + // Every server down: both say why. + c = &Client{Servers: []string{down.URL + "/a/b", down.URL + "/c/d"}} + if _, err := c.Latest(context.Background()); err == nil || strings.Count(err.Error(), "502") != 2 { + t.Errorf("all down: %v", err) + } +} + +func TestAPI(t *testing.T) { + for repo, want := range map[string]string{ + "https://github.com/karleinarb/lazysecrets": "https://api.github.com/repos/karleinarb/lazysecrets", + "https://git.kebcloud.no/keb/lazysecrets/": "https://git.kebcloud.no/api/v1/repos/keb/lazysecrets", + "https://codeberg.org/someone/lazysecrets": "https://codeberg.org/api/v1/repos/someone/lazysecrets", + "https://git.kebcloud.no/keb": "", + "git.kebcloud.no/keb/lazysecrets": "", + "https://github.com/karleinarb/lazysecrets/x": "", + } { + got, err := API(repo) + if got != want || (err != nil) != (want == "") { + t.Errorf("API(%q) = %q, %v", repo, got, err) + } + } +} + func TestInstallRefusesATamperedArchive(t *testing.T) { if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { t.Skip("releases are for macOS and Linux") } srv := server(t, true) - c := &Client{API: srv.URL + "/api", Repo: srv.URL + "/repo"} + c := &Client{Servers: []string{srv.URL + "/keb/lazysecrets"}} exe := filepath.Join(t.TempDir(), "lazysecrets") os.WriteFile(exe, []byte("old"), 0o755) err := c.Install(context.Background(), Release{Tag: "v0.2.0"}, exe) diff --git a/scripts/github-release.mjs b/scripts/github-release.mjs new file mode 100644 index 0000000..d1ca7dd --- /dev/null +++ b/scripts/github-release.mjs @@ -0,0 +1,75 @@ +// Copies a release GoReleaser published on Gitea to the GitHub mirror: +// the same notes and the same files (dist/), so lazysecrets can update +// from either (internal/update tries GitHub first). Run by the release +// workflow after GoReleaser; a release already on GitHub for the tag is +// replaced, like GoReleaser's mode: replace on Gitea. +// +// TAG=v0.2.0 GH_TOKEN=… GITEA_TOKEN=… node scripts/github-release.mjs +// +// GH_TOKEN is a fine-grained token that can write the mirror's contents; +// without it, this does nothing. No dependencies: Node 20's fetch. + +import { readdir, readFile } from "node:fs/promises"; + +const github = "karleinarb/lazysecrets"; +const { TAG: tag, GH_TOKEN: token, GITEA_TOKEN: giteaToken, GITHUB_SHA: sha } = process.env; +const gitea = `${process.env.GITHUB_SERVER_URL ?? "https://git.kebcloud.no"}/api/v1/repos/${process.env.GITHUB_REPOSITORY ?? "keb/lazysecrets"}`; + +if (!token) { + console.log("No GH_TOKEN: not copying the release to GitHub."); + process.exit(0); +} +if (!tag) throw new Error("TAG isn't set"); + +const gh = (path, init = {}) => + fetch(path.startsWith("https://") ? path : `https://api.github.com/repos/${github}${path}`, { + ...init, + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + ...init.headers, + }, + }); + +async function ok(res, what) { + if (!res.ok) throw new Error(`${what}: ${res.status} ${await res.text()}`); + return res; +} + +// The notes as GoReleaser wrote them on Gitea. +const rel = await ( + await ok(await fetch(`${gitea}/releases/tags/${tag}`, { headers: giteaToken ? { Authorization: `token ${giteaToken}` } : {} }), "reading the Gitea release") +).json(); + +// Replace what's there for the tag. +const old = await gh(`/releases/tags/${tag}`); +if (old.ok) { + const { id } = await old.json(); + await ok(await gh(`/releases/${id}`, { method: "DELETE" }), "deleting the old GitHub release"); + console.log(`Deleted the GitHub release for ${tag}.`); +} + +// The mirror may not have the tag's commit yet: it pushes on its own. +// Wait for it a while. +const body = JSON.stringify({ tag_name: tag, target_commitish: sha, name: rel.name || tag, body: rel.body, prerelease: rel.prerelease }); +let created; +for (let i = 0; ; i++) { + const res = await gh("/releases", { method: "POST", body }); + if (res.ok) { + created = await res.json(); + break; + } + if (res.status !== 422 || i === 9) await ok(res, "creating the GitHub release"); + console.log("GitHub doesn't have the commit yet; waiting for the mirror…"); + await new Promise((r) => setTimeout(r, 30_000)); +} + +const files = (await readdir("dist")).filter((f) => f.endsWith(".tar.gz") || f === "checksums.txt"); +if (files.length === 0) throw new Error("nothing to upload in dist/"); +for (const f of files) { + const url = `https://uploads.github.com/repos/${github}/releases/${created.id}/assets?name=${encodeURIComponent(f)}`; + await ok(await gh(url, { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: await readFile(`dist/${f}`) }), `uploading ${f}`); + console.log(`Uploaded ${f}.`); +} +console.log(`Published ${tag} on GitHub: ${created.html_url}`);