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
@@ -4,8 +4,9 @@ name: Release
|
|||||||
# as a pre-release) tests lazysecrets, then GoReleaser (.goreleaser.yaml)
|
# as a pre-release) tests lazysecrets, then GoReleaser (.goreleaser.yaml)
|
||||||
# builds it for macOS and Linux, arm64 and amd64, and publishes a Gitea
|
# 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
|
# release with the archives, checksums and this release's section of the
|
||||||
# changelog (git-cliff, cliff.toml). Pushing the same tag again (after
|
# changelog (git-cliff, cliff.toml), then copies it to the GitHub
|
||||||
# deleting it) replaces the release.
|
# 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
|
# 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
|
args: release --clean --release-notes ${{ runner.temp }}/notes.md
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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 }}
|
||||||
@@ -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
|
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)
|
It installs the latest release, from GitHub or else
|
||||||
into `~/.local/bin` after checking it against the release's checksums.
|
[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`
|
`sh -s -- v0.2.0` installs a given version, `LAZYSECRETS_INSTALL_DIR`
|
||||||
somewhere else.
|
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
|
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
|
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
|
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
|
call of its own is a daily check for a new release, on GitHub or else
|
||||||
`updateCheck: false` turns off.
|
git.kebcloud.no, which `updateCheck: false` turns off.
|
||||||
|
|
||||||
## Repositories
|
## Repositories
|
||||||
|
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ Flags:
|
|||||||
case len(args) > 0 && args[0] == "launch":
|
case len(args) > 0 && args[0] == "launch":
|
||||||
return launch(*configPath, args[1:])
|
return launch(*configPath, args[1:])
|
||||||
case len(args) > 0 && args[0] == "update":
|
case len(args) > 0 && args[0] == "update":
|
||||||
return updateCmd(args[1:])
|
return updateCmd(*configPath, args[1:])
|
||||||
case len(args) == 0:
|
case len(args) == 0:
|
||||||
case args[0] == "open" && len(args) == 2:
|
case args[0] == "open" && len(args) == 2:
|
||||||
r, err := ref.Parse(args[1])
|
r, err := ref.Parse(args[1])
|
||||||
@@ -364,7 +364,7 @@ Flags:
|
|||||||
ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour,
|
ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour,
|
||||||
HideOnBlur: config.On(cfg.HideOnBlur),
|
HideOnBlur: config.On(cfg.HideOnBlur),
|
||||||
HandlerSetup: setup,
|
HandlerSetup: setup,
|
||||||
Updater: updater(),
|
Updater: updater(cfg.Releases),
|
||||||
UpdateCheck: config.On(cfg.UpdateCheck),
|
UpdateCheck: config.On(cfg.UpdateCheck),
|
||||||
Keys: &keymap,
|
Keys: &keymap,
|
||||||
Commands: cfg.CustomCommands,
|
Commands: cfg.CustomCommands,
|
||||||
|
|||||||
@@ -7,14 +7,16 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/config"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/app"
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/app"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/update"
|
"git.kebcloud.no/keb/lazysecrets/internal/update"
|
||||||
)
|
)
|
||||||
|
|
||||||
// updater is the app's way to find and install releases, replacing the
|
// updater is the app's way to find and install releases, replacing the
|
||||||
// binary that's running.
|
// binary that's running, from the servers (config releases) or the
|
||||||
func updater() *app.Updater {
|
// default ones.
|
||||||
c := &update.Client{}
|
func updater(servers []string) *app.Updater {
|
||||||
|
c := &update.Client{Servers: servers}
|
||||||
return &app.Updater{
|
return &app.Updater{
|
||||||
Latest: c.Latest,
|
Latest: c.Latest,
|
||||||
Install: func(ctx context.Context, r update.Release) error {
|
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
|
// updateCmd is lazysecrets update [--check]: the newer release's
|
||||||
// changelog, then (unless --check) install it.
|
// 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"
|
check := len(args) == 1 && args[0] == "--check"
|
||||||
if len(args) > 0 && !check {
|
if len(args) > 0 && !check {
|
||||||
return errors.New("usage: lazysecrets update [--check]")
|
return errors.New("usage: lazysecrets update [--check]")
|
||||||
}
|
}
|
||||||
|
cfg, err := config.LoadAll(strings.Split(configPath, ","))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
current := versionString()
|
current := versionString()
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
c := &update.Client{}
|
c := &update.Client{Servers: cfg.Releases}
|
||||||
r, err := c.Latest(ctx)
|
r, err := c.Latest(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("looking for a newer lazysecrets: %w", err)
|
return fmt.Errorf("looking for a newer lazysecrets: %w", err)
|
||||||
|
|||||||
+11
-5
@@ -15,6 +15,9 @@ loadDelay: 150ms # how long the cursor rests on a row before it loads; 0s
|
|||||||
sourcesView: grouped # or flat; ` toggles it
|
sourcesView: grouped # or flat; ` toggles it
|
||||||
collapseGroups: true # start with the Sources pane's groups folded; enter or space unfolds one
|
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)
|
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)
|
history: true # count uses of items for Recent (R)
|
||||||
cache: true # keep what was loaded (never values) for the next start
|
cache: true # keep what was loaded (never values) for the next start
|
||||||
providers:
|
providers:
|
||||||
@@ -125,14 +128,17 @@ are read from disk anyway. Deleting the file is always safe, and
|
|||||||
|
|
||||||
## Updates
|
## Updates
|
||||||
|
|
||||||
Once a day (`updateCheck`), in the background, lazysecrets asks the
|
Once a day (`updateCheck`), in the background, lazysecrets asks for the
|
||||||
release server for the latest release; within the day it reuses the
|
latest release; within the day it reuses the answer from the state file.
|
||||||
answer from the state file. Offline, or with the server down, it says
|
Releases are published on GitHub and on git.kebcloud.no, and it asks
|
||||||
nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or
|
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.
|
`lazysecrets update --check`) shows its changelog.
|
||||||
|
|
||||||
Updating (`u` there, or `lazysecrets update`) downloads the release for
|
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
|
sure it runs, and only then replaces the binary, atomically: a
|
||||||
lazysecrets that's running keeps working, and the next start is the new
|
lazysecrets that's running keeps working, and the next start is the new
|
||||||
version. A binary a package manager owns (Homebrew, Nix, a distro
|
version. A binary a package manager owns (Homebrew, Nix, a distro
|
||||||
|
|||||||
+6
-2
@@ -56,8 +56,12 @@ from the pull request titles. The tag runs the release workflow
|
|||||||
(`.gitea/workflows/release.yml`): it tests, then
|
(`.gitea/workflows/release.yml`): it tests, then
|
||||||
[GoReleaser](https://goreleaser.com) (`.goreleaser.yaml`) builds the
|
[GoReleaser](https://goreleaser.com) (`.goreleaser.yaml`) builds the
|
||||||
archives and publishes a Gitea release with them and the release's
|
archives and publishes a Gitea release with them and the release's
|
||||||
changelog section. `goreleaser release --snapshot --clean` builds the same
|
changelog section. Then `scripts/github-release.mjs` copies that release,
|
||||||
archives locally, into `dist/`, without publishing.
|
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`
|
A suffix (`v0.2.0-rc.1`) makes a pre-release, which leaves `CHANGELOG.md`
|
||||||
alone.
|
alone.
|
||||||
+40
-14
@@ -7,10 +7,12 @@
|
|||||||
#
|
#
|
||||||
# LAZYSECRETS_INSTALL_DIR installs somewhere else. macOS and Linux, arm64
|
# LAZYSECRETS_INSTALL_DIR installs somewhere else. macOS and Linux, arm64
|
||||||
# and x86_64.
|
# 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
|
set -eu
|
||||||
|
|
||||||
repo=https://git.kebcloud.no/keb/lazysecrets
|
repos="${LAZYSECRETS_RELEASES:-https://github.com/karleinarb/lazysecrets https://git.kebcloud.no/keb/lazysecrets}"
|
||||||
api=https://git.kebcloud.no/api/v1/repos/keb/lazysecrets
|
|
||||||
dir="${LAZYSECRETS_INSTALL_DIR:-$HOME/.local/bin}"
|
dir="${LAZYSECRETS_INSTALL_DIR:-$HOME/.local/bin}"
|
||||||
|
|
||||||
fail() {
|
fail() {
|
||||||
@@ -18,12 +20,13 @@ fail() {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Short timeouts, so a server that's down leaves time for the next.
|
||||||
if command -v curl >/dev/null 2>&1; then
|
if command -v curl >/dev/null 2>&1; then
|
||||||
fetch() { curl -fsSL "$1"; }
|
fetch() { curl -fsSL --connect-timeout 5 --max-time 15 "$1"; }
|
||||||
download() { curl -fsSL -o "$2" "$1"; }
|
download() { curl -fsSL --connect-timeout 5 -o "$2" "$1"; }
|
||||||
elif command -v wget >/dev/null 2>&1; then
|
elif command -v wget >/dev/null 2>&1; then
|
||||||
fetch() { wget -qO- "$1"; }
|
fetch() { wget -qO- -T 15 "$1"; }
|
||||||
download() { wget -qO "$2" "$1"; }
|
download() { wget -qO "$2" -T 30 "$1"; }
|
||||||
else
|
else
|
||||||
fail "needs curl or wget"
|
fail "needs curl or wget"
|
||||||
fi
|
fi
|
||||||
@@ -39,11 +42,26 @@ arm64 | aarch64) arch=arm64 ;;
|
|||||||
*) fail "no release for $(uname -m)" ;;
|
*) fail "no release for $(uname -m)" ;;
|
||||||
esac
|
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:-}"
|
tag="${1:-}"
|
||||||
if [ -z "$tag" ]; then
|
if [ -z "$tag" ]; then
|
||||||
tag=$(fetch "$api/releases/latest" | sed -n 's/.*"tag_name":"\([^"]*\)".*/\1/p') ||
|
for repo in $repos; do
|
||||||
fail "couldn't look up the latest release"
|
tag=$(fetch "$(api "$repo")/releases/latest" 2>/dev/null |
|
||||||
[ -n "$tag" ] || fail "couldn't look up the latest release"
|
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
|
fi
|
||||||
case "$tag" in
|
case "$tag" in
|
||||||
v*) ;;
|
v*) ;;
|
||||||
@@ -55,11 +73,19 @@ name="lazysecrets_${version}_${os}_${arch}"
|
|||||||
tmp=$(mktemp -d)
|
tmp=$(mktemp -d)
|
||||||
trap 'rm -rf "$tmp"' EXIT
|
trap 'rm -rf "$tmp"' EXIT
|
||||||
|
|
||||||
echo "Downloading lazysecrets $tag for $os/$arch"
|
# The archive and its checksums.txt from the first server that has both.
|
||||||
download "$repo/releases/download/$tag/$name.tar.gz" "$tmp/$name.tar.gz" ||
|
from=
|
||||||
fail "couldn't download $tag for $os/$arch: see $repo/releases"
|
for repo in $repos; do
|
||||||
download "$repo/releases/download/$tag/checksums.txt" "$tmp/checksums.txt" ||
|
base="$repo/releases/download/$tag"
|
||||||
fail "couldn't download $tag's checksums.txt"
|
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")
|
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"
|
[ -n "$want" ] || fail "$name.tar.gz isn't in checksums.txt"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"go.yaml.in/yaml/v3"
|
"go.yaml.in/yaml/v3"
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/update"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -40,6 +41,10 @@ type Config struct {
|
|||||||
// background; defaults to true. U and lazysecrets update work either
|
// background; defaults to true. U and lazysecrets update work either
|
||||||
// way.
|
// way.
|
||||||
UpdateCheck *bool `yaml:"updateCheck"`
|
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
|
// Cache keeps what was loaded (never values) between runs, so lists
|
||||||
// show at once and refresh in the background; defaults to true.
|
// show at once and refresh in the background; defaults to true.
|
||||||
Cache *bool `yaml:"cache"`
|
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) {
|
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)
|
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 {
|
for i, v := range c.Providers.Azure.Vaults {
|
||||||
if !strings.HasPrefix(v, "https://") {
|
if !strings.HasPrefix(v, "https://") {
|
||||||
return fmt.Errorf("%s: providers.azure.vaults[%d]: %q is not an https:// vault URL", path, i, v)
|
return fmt.Errorf("%s: providers.azure.vaults[%d]: %q is not an https:// vault URL", path, i, v)
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ func TestLoadRejectsMistakes(t *testing.T) {
|
|||||||
"old layout": "azure:\n vaults: [https://kv.vault.azure.net/]\n",
|
"old layout": "azure:\n vaults: [https://kv.vault.azure.net/]\n",
|
||||||
"duration": "timeout: soon\n",
|
"duration": "timeout: soon\n",
|
||||||
"view": "sourcesView: tree\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 {
|
if _, err := Load(write(t, content)); err == nil {
|
||||||
t.Errorf("%s: no error", name)
|
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) {
|
func TestLoadDelay(t *testing.T) {
|
||||||
c, err := Load(write(t, "loadDelay: 300ms\n"))
|
c, err := Load(write(t, "loadDelay: 300ms\n"))
|
||||||
if err != nil || c.LoadDelay == nil || *c.LoadDelay != 300*time.Millisecond {
|
if err != nil || c.LoadDelay == nil || *c.LoadDelay != 300*time.Millisecond {
|
||||||
|
|||||||
@@ -122,7 +122,9 @@ func (m Model) statusFields() []panes.Field {
|
|||||||
repo := strings.TrimPrefix(update.DefaultRepo, "https://")
|
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: "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: "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: "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"})
|
add(panes.Field{Label: "changelog", Value: repo + "/…/CHANGELOG.md", URL: update.DefaultRepo + "/src/branch/main/CHANGELOG.md"})
|
||||||
return append(out, m.changelog...)
|
return append(out, m.changelog...)
|
||||||
|
|||||||
+91
-30
@@ -3,6 +3,9 @@
|
|||||||
// against the release's checksums.txt, tried (--version), then renamed
|
// against the release's checksums.txt, tried (--version), then renamed
|
||||||
// over the binary. A running lazysecrets keeps its old binary until it
|
// over the binary. A running lazysecrets keeps its old binary until it
|
||||||
// restarts.
|
// 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
|
package update
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -18,6 +21,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path"
|
"path"
|
||||||
@@ -30,12 +34,20 @@ import (
|
|||||||
"golang.org/x/mod/semver"
|
"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 (
|
const (
|
||||||
DefaultAPI = "https://git.kebcloud.no/api/v1/repos/keb/lazysecrets"
|
|
||||||
DefaultRepo = "https://git.kebcloud.no/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.
|
// maxDownload bounds what's read from the server.
|
||||||
const maxDownload = 200 << 20
|
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.
|
// and a terminal shows them as their text.
|
||||||
var mdLink = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^)\s]+)\)`)
|
var mdLink = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^)\s]+)\)`)
|
||||||
|
|
||||||
// Client talks to the release server.
|
// Client talks to the release servers.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
API string // the repository's API, DefaultAPI when empty
|
// Servers are the repositories' web addresses, on GitHub or a Gitea
|
||||||
Repo string // the repository's web address, DefaultRepo when empty
|
// or Forgejo server, tried in order; DefaultServers when empty.
|
||||||
HTTP *http.Client
|
Servers []string
|
||||||
|
HTTP *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) api() string { return cmpOr(c.API, DefaultAPI) }
|
func (c *Client) servers() []string {
|
||||||
func (c *Client) repo() string { return cmpOr(c.Repo, DefaultRepo) }
|
if len(c.Servers) > 0 {
|
||||||
|
return c.Servers
|
||||||
|
}
|
||||||
|
return DefaultServers
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) client() *http.Client {
|
func (c *Client) client() *http.Client {
|
||||||
if c.HTTP != nil {
|
if c.HTTP != nil {
|
||||||
@@ -77,25 +94,50 @@ func (c *Client) client() *http.Client {
|
|||||||
return &http.Client{Timeout: 2 * time.Minute}
|
return &http.Client{Timeout: 2 * time.Minute}
|
||||||
}
|
}
|
||||||
|
|
||||||
func cmpOr(a, b string) string {
|
// API is a repository's API: GitHub's for github.com, else Gitea's (and
|
||||||
if a != "" {
|
// Forgejo's).
|
||||||
return a
|
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) {
|
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 {
|
if err != nil {
|
||||||
return Release{}, err
|
return Release{}, err
|
||||||
}
|
}
|
||||||
var r Release
|
var r Release
|
||||||
if err := json.Unmarshal(b, &r); err != nil {
|
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) {
|
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
|
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")
|
version := strings.TrimPrefix(r.Tag, "v")
|
||||||
name := fmt.Sprintf("lazysecrets_%s_%s_%s", version, runtime.GOOS, runtime.GOARCH)
|
name := fmt.Sprintf("lazysecrets_%s_%s_%s", version, runtime.GOOS, runtime.GOARCH)
|
||||||
base := c.repo() + "/releases/download/" + r.Tag + "/"
|
archive, err := c.download(ctx, r.Tag, name+".tar.gz")
|
||||||
|
|
||||||
sums, err := c.get(ctx, base+"checksums.txt", 1<<20)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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"))
|
bin, err := extract(archive, path.Join(name, "lazysecrets"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -198,6 +227,38 @@ func (c *Client) Install(ctx context.Context, r Release, exe string) error {
|
|||||||
return nil
|
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) {
|
func (c *Client) get(ctx context.Context, url string, limit int64) ([]byte, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -70,13 +70,13 @@ func server(t *testing.T, tamper bool) *httptest.Server {
|
|||||||
sum[0] ^= 0xff
|
sum[0] ^= 0xff
|
||||||
}
|
}
|
||||||
mux := http.NewServeMux()
|
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"}`)
|
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)
|
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())
|
w.Write(archive.Bytes())
|
||||||
})
|
})
|
||||||
srv := httptest.NewServer(mux)
|
srv := httptest.NewServer(mux)
|
||||||
@@ -89,7 +89,7 @@ func TestInstall(t *testing.T) {
|
|||||||
t.Skip("releases are for macOS and Linux")
|
t.Skip("releases are for macOS and Linux")
|
||||||
}
|
}
|
||||||
srv := server(t, false)
|
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())
|
r, err := c.Latest(context.Background())
|
||||||
if err != nil || r.Tag != "v0.2.0" {
|
if err != nil || r.Tag != "v0.2.0" {
|
||||||
t.Fatalf("latest %+v %v", r, err)
|
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) {
|
func TestInstallRefusesATamperedArchive(t *testing.T) {
|
||||||
if runtime.GOOS != "darwin" && runtime.GOOS != "linux" {
|
if runtime.GOOS != "darwin" && runtime.GOOS != "linux" {
|
||||||
t.Skip("releases are for macOS and Linux")
|
t.Skip("releases are for macOS and Linux")
|
||||||
}
|
}
|
||||||
srv := server(t, true)
|
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")
|
exe := filepath.Join(t.TempDir(), "lazysecrets")
|
||||||
os.WriteFile(exe, []byte("old"), 0o755)
|
os.WriteFile(exe, []byte("old"), 0o755)
|
||||||
err := c.Install(context.Background(), Release{Tag: "v0.2.0"}, exe)
|
err := c.Install(context.Background(), Release{Tag: "v0.2.0"}, exe)
|
||||||
|
|||||||
@@ -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}`);
|
||||||
Reference in new issue
Block a user