feat: releases from GitHub first, then git.kebcloud.no; releases sets others
CI / Checks (pull_request) Successful in 26s
PR title / Conventional title (pull_request) Successful in 2s

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:
kebandClaude Opus 5.5 committed 2026-10-12 14:01:51 +02:00
1 parent c00f4b14c4
commit b3c1911ba5
13 files changed
+328 -69

No files matched your search

+14 -2
View File
@@ -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 }}
+5 -4
View File
@@ -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
+2 -2
View File
@@ -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,
+11 -5
View File
@@ -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)
+11 -5
View File
@@ -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
+6 -2
View File
@@ -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.
+40 -14
View File
@@ -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"
+10
View File
@@ -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)
+9
View File
@@ -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 {
+3 -1
View File
@@ -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...)
+90 -29
View File
@@ -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
// 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 {
+52 -5
View File
@@ -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)
+75
View File
@@ -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}`);