desktop dev update channel: every push to main builds dev bundles (<next patch>-dev.<UTC>), the VDS signs and publishes dev/latest.json (newest 5); app channel setting stable|dev, background updater at start / 15 min / resume / channel switch, deferred while in use, no downgrade, autostart rewritten on every start, card shows version and channel
This commit is contained in:
parent
4dff7d9b2e
commit
39b2f73ad7
25 changed files with 732 additions and 47 deletions
123
tools/desktop-dev.ts
Normal file
123
tools/desktop-dev.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
#!/usr/bin/env bun
|
||||
// Dev update channel of the desktop app (docs/desktop-release.md, "Dev channel"). Every push to main builds Windows
|
||||
// and Linux bundles stamped `<next patch>-dev.<UTC yyyymmddHHMMSS of the commit>` (ci.yml, linux / windows jobs), the
|
||||
// `dev` job attaches them to a Forgejo draft `dev-<version>` (no git tag), and the VDS (tools/vds/storebound-desktop-dev,
|
||||
// a root timer) signs them with the updater key that lives there and publishes storebound.dev/desktop/dev/latest.json.
|
||||
// Installs set to the dev channel (Settings > Updates) follow it; everyone else stays on stable (the npm releases).
|
||||
// bun tools/desktop-dev.ts stamp print the dev version of HEAD; refuses one not above the live dev
|
||||
// bun tools/desktop-dev.ts set stamp, then write it into every version copy (CI workspace only)
|
||||
// bun tools/desktop-dev.ts record --dir <d> [--yes] draft dev-<version> with the files of <d>, prune to the newest 5
|
||||
// bun tools/desktop-dev.ts wait <version> [--minutes 15] until storebound.dev serves it on the dev channel
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { setVersion, versions } from './desktop-version';
|
||||
|
||||
export const DEV_URL = 'https://storebound.dev/desktop/dev/latest.json';
|
||||
export const KEEP = 5;
|
||||
export const DEV_TAG = /^dev-(\d+\.\d+\.\d+-dev\.\d{14})$/;
|
||||
|
||||
/** The next patch of a release version: dev builds of 0.8.47's successor are 0.8.48-dev.*, below 0.8.48. */
|
||||
export function nextPatch(v: string): string {
|
||||
const m = /^(\d+)\.(\d+)\.(\d+)(?:-.*)?$/.exec(v);
|
||||
if (!m) throw new Error(`not a version: ${v}`);
|
||||
// a prerelease base (0.8.48-rc.1) already stands below 0.8.48: its dev builds keep that core
|
||||
return v.includes('-') ? `${m[1]}.${m[2]}.${m[3]}` : `${m[1]}.${m[2]}.${Number(m[3]) + 1}`;
|
||||
}
|
||||
|
||||
/** UTC yyyymmddHHMMSS. */
|
||||
export const utcStamp = (d: Date): string => d.toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
||||
|
||||
export function devVersion(base: string, at: Date): string {
|
||||
return `${nextPatch(base)}-dev.${utcStamp(at)}`;
|
||||
}
|
||||
|
||||
/** semver 2.0 precedence: -1, 0, 1 (numeric identifiers as numbers, a release above its prereleases). */
|
||||
export function compare(a: string, b: string): number {
|
||||
const p = (v: string) => { const [core, ...pre] = v.split('+')[0]!.split('-'); return { n: core!.split('.').map(Number), pre: pre.length ? pre.join('-').split('.') : [] }; };
|
||||
const x = p(a), y = p(b);
|
||||
for (let i = 0; i < 3; i++) if (x.n[i] !== y.n[i]) return x.n[i]! > y.n[i]! ? 1 : -1;
|
||||
if (!x.pre.length || !y.pre.length) return x.pre.length === y.pre.length ? 0 : x.pre.length ? -1 : 1;
|
||||
for (let i = 0; i < Math.min(x.pre.length, y.pre.length); i++) {
|
||||
const s = x.pre[i]!, t = y.pre[i]!;
|
||||
if (s === t) continue;
|
||||
const ns = /^\d+$/.test(s), nt = /^\d+$/.test(t);
|
||||
if (ns && nt) return Number(s) > Number(t) ? 1 : -1;
|
||||
if (ns !== nt) return ns ? -1 : 1;
|
||||
return s > t ? 1 : -1;
|
||||
}
|
||||
return Math.sign(x.pre.length - y.pre.length);
|
||||
}
|
||||
|
||||
/** The publish guard: a dev version goes out only above the live dev latest.json (null: nothing published yet). */
|
||||
export function guardProblem(next: string, live: string | null): string | null {
|
||||
if (!/^\d+\.\d+\.\d+-dev\.\d{14}$/.test(next)) return `${next} is not a dev version`;
|
||||
if (live && compare(next, live) <= 0) return `${next} is not above the live dev build ${live}: refused (the updater installs only a newer version)`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Dev drafts to delete: all but the newest `keep` by version (other releases are never touched). */
|
||||
export function pruneDev<T extends { tag_name: string }>(releases: T[], keep = KEEP): T[] {
|
||||
const dev = releases.filter(r => DEV_TAG.test(r.tag_name)).sort((a, b) => compare(b.tag_name.slice(4), a.tag_name.slice(4)));
|
||||
return dev.slice(keep);
|
||||
}
|
||||
|
||||
async function liveDev(): Promise<string | null> {
|
||||
const r = await fetch(`${DEV_URL}?t=${Date.now()}`, { signal: AbortSignal.timeout(20_000) }).catch(() => null);
|
||||
if (!r || r.status === 404) return null;
|
||||
if (!r.ok) throw new Error(`${DEV_URL}: HTTP ${r.status}`);
|
||||
return ((await r.json()) as { version?: string }).version ?? null;
|
||||
}
|
||||
|
||||
function commitDate(): Date {
|
||||
const r = Bun.spawnSync(['git', 'log', '-1', '--format=%cI'], { stdout: 'pipe' });
|
||||
const d = new Date(r.stdout.toString().trim());
|
||||
if (r.exitCode !== 0 || Number.isNaN(d.getTime())) throw new Error('git log: no commit date');
|
||||
return d;
|
||||
}
|
||||
|
||||
async function stamp(): Promise<string> {
|
||||
const { version, drift } = versions();
|
||||
if (!version || drift.length) throw new Error('desktop version drift (bun tools/desktop-version.ts)');
|
||||
const v = devVersion(version, commitDate());
|
||||
const problem = guardProblem(v, await liveDev());
|
||||
if (problem) throw new Error(problem);
|
||||
return v;
|
||||
}
|
||||
|
||||
const arg = (argv: string[], n: string) => { const i = argv.indexOf(`--${n}`); return i >= 0 ? argv[i + 1] : undefined; };
|
||||
|
||||
if (import.meta.main) {
|
||||
const argv = process.argv.slice(2);
|
||||
try {
|
||||
if (argv[0] === 'stamp') console.log(await stamp());
|
||||
else if (argv[0] === 'set') { const v = await stamp(); setVersion(v); console.log(v); }
|
||||
else if (argv[0] === 'record') {
|
||||
const { upsert, listReleases, api } = await import('./forgejo-release');
|
||||
const dir = arg(argv, 'dir') ?? 'dev';
|
||||
const files = readdirSync(dir).filter(f => /\.(exe|AppImage|deb)$/.test(f)).map(f => join(dir, f));
|
||||
const vs = [...new Set(files.map(f => /_(\d+\.\d+\.\d+-dev\.\d{14})_/.exec(f)?.[1]))];
|
||||
if (vs.length !== 1 || !vs[0]) throw new Error(`${dir}: expected the files of one dev version, got ${vs.join(', ') || 'none'}`);
|
||||
const v = vs[0];
|
||||
for (const kind of [/_x64-setup\.exe$/, /_amd64\.AppImage$/, /_amd64\.deb$/]) if (!files.some(f => kind.test(f))) throw new Error(`${dir}: no ${kind.source} of ${v}`);
|
||||
const problem = guardProblem(v, await liveDev());
|
||||
if (problem) throw new Error(problem);
|
||||
const yes = argv.includes('--yes');
|
||||
await upsert({ tag: `dev-${v}`, title: `dev ${v}`, notes: `Dev build of ${process.env.SHA ?? 'HEAD'} (signed and published by the VDS to ${DEV_URL}).`, draft: true, prerelease: true, files, yes });
|
||||
const old = pruneDev(await listReleases());
|
||||
for (const r of old) {
|
||||
console.log(`${yes ? 'delete' : 'would delete'} draft ${r.tag_name}`);
|
||||
if (yes) await api('DELETE', `/releases/${(r as { id: number }).id}`);
|
||||
}
|
||||
} else if (argv[0] === 'wait') {
|
||||
const v = argv[1] ?? ''; const minutes = Number(arg(argv, 'minutes') ?? 15);
|
||||
const t0 = Date.now();
|
||||
for (;;) {
|
||||
const live = await liveDev().catch(() => null);
|
||||
if (live && compare(live, v) >= 0) { console.log(`live on the dev channel: ${live}`); break; }
|
||||
if (Date.now() - t0 > minutes * 60_000) throw new Error(`${v} not on ${DEV_URL} after ${minutes} min (live: ${live ?? 'none'}); see journalctl -u storebound-desktop-dev on the VDS`);
|
||||
console.log(`waiting for the VDS to publish ${v} (live: ${live ?? 'none'}, ${Math.round((Date.now() - t0) / 1000)} s)`);
|
||||
await Bun.sleep(20_000);
|
||||
}
|
||||
} else { console.log('usage: stamp | set | record --dir <d> [--yes] | wait <version> [--minutes n]'); process.exit(2); }
|
||||
} catch (e) { console.error(`desktop-dev: ${(e as Error).message}`); process.exit(1); }
|
||||
}
|
||||
171
tools/vds/storebound-desktop-dev
Normal file
171
tools/vds/storebound-desktop-dev
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
#!/usr/bin/env python3
|
||||
# Root-owned dev channel publisher for storebound desktop (install as /usr/local/sbin/storebound-desktop-dev, root:root
|
||||
# 755, run by storebound-desktop-dev.timer every 2 minutes; see docs/desktop-release.md, "Dev channel").
|
||||
#
|
||||
# Every push to main attaches its dev bundles (Windows NSIS setup, Linux AppImage + deb, SHA256SUMS) to a Forgejo
|
||||
# draft release `dev-<x.y.z-dev.yyyymmddHHMMSS>` (ci.yml `dev` job, tools/desktop-dev.ts). This script, with a
|
||||
# read-only Forgejo token (/root/.storebound-keys/forgejo-read-token):
|
||||
# 1. takes the newest complete dev draft whose version is above the live dev/latest.json (never a lower one),
|
||||
# 2. downloads its files into /var/lib/storebound-desktop/incoming/<version>/ and checks them against SHA256SUMS,
|
||||
# 3. signs each with the updater key (/root/.storebound-keys/updater.key; it never leaves this host),
|
||||
# 4. writes latest.json + SHA256SUMS and runs storebound-desktop-publish <version> dev (latest.json last),
|
||||
# 5. keeps the newest 5 dev versions in /var/www/storebound-desktop/dl/ (and whatever dev/latest.json names).
|
||||
# Nothing it downloads is executed. One run at a time (flock).
|
||||
import fcntl, hashlib, json, os, re, shutil, subprocess, sys, urllib.request
|
||||
from datetime import datetime, timezone
|
||||
|
||||
FORGEJO = "https://git.crt.fyi"
|
||||
REPO = "CRT/storebound"
|
||||
KEYS = "/root/.storebound-keys"
|
||||
TOKEN = f"{KEYS}/forgejo-read-token"
|
||||
KEY = f"{KEYS}/updater.key"
|
||||
KEY_PASSWORD = f"{KEYS}/updater.key.password"
|
||||
TAURI_CLI = "@tauri-apps/cli@2.12.1"
|
||||
INCOMING = "/var/lib/storebound-desktop/incoming"
|
||||
ROOT = "/var/www/storebound-desktop"
|
||||
SITE = "https://storebound.dev/desktop"
|
||||
KEEP = 5
|
||||
DEV = re.compile(r"^(\d+)\.(\d+)\.(\d+)-dev\.(\d{14})$")
|
||||
SAFE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(f"storebound-desktop-dev: {msg}", flush=True)
|
||||
|
||||
|
||||
def key(v):
|
||||
m = DEV.match(v)
|
||||
return tuple(int(x) for x in m.groups()) if m else None
|
||||
|
||||
|
||||
def get(url, binary=False):
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"token {open(TOKEN).read().strip()}"})
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
return r.read() if binary else json.loads(r.read())
|
||||
|
||||
|
||||
def live_version():
|
||||
try:
|
||||
return json.load(open(f"{ROOT}/dev/latest.json")).get("version")
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def kind(name, v):
|
||||
if name == f"storebound_{v}_x64-setup.exe":
|
||||
return "nsis", f"storebound_{v}_x64-setup-unsigned.exe"
|
||||
if name == f"storebound_{v}_amd64.AppImage":
|
||||
return "appimage", name
|
||||
if name == f"storebound_{v}_amd64.deb":
|
||||
return "deb", name
|
||||
return None, None
|
||||
|
||||
|
||||
def sign(path):
|
||||
pw = open(KEY_PASSWORD).read().strip() if os.path.exists(KEY_PASSWORD) else ""
|
||||
env = dict(os.environ, TAURI_SIGNING_PRIVATE_KEY_PASSWORD=pw)
|
||||
if os.path.exists(path + ".sig"):
|
||||
os.remove(path + ".sig")
|
||||
subprocess.run(["timeout", "300", "bunx", TAURI_CLI, "signer", "sign", "-f", KEY, path], env=env, stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL, check=True)
|
||||
s = open(path + ".sig").read().strip()
|
||||
os.remove(path + ".sig")
|
||||
return s
|
||||
|
||||
|
||||
def sha256(path):
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for b in iter(lambda: f.read(1 << 20), b""):
|
||||
h.update(b)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def publish(rel, v):
|
||||
assets = {a["name"]: a for a in rel["assets"]}
|
||||
sums = {}
|
||||
for line in get(f"{FORGEJO}/attachments/{assets['SHA256SUMS']['uuid']}", binary=True).decode().splitlines():
|
||||
m = re.match(r"^([0-9a-f]{64})\s+\*?(\S+)$", line.strip())
|
||||
if m:
|
||||
sums[m.group(2)] = m.group(1)
|
||||
files = [(n,) + kind(n, v) for n in assets if kind(n, v)[0]]
|
||||
if sorted(k for _, k, _ in files) != ["appimage", "deb", "nsis"]:
|
||||
log(f"{v}: draft incomplete ({', '.join(assets)}), waiting")
|
||||
return False
|
||||
inc = f"{INCOMING}/{v}"
|
||||
shutil.rmtree(inc, ignore_errors=True)
|
||||
os.makedirs(inc, mode=0o755)
|
||||
manifest = {"version": v, "notes": f"storebound desktop {v} (dev channel: every push to main)",
|
||||
"pub_date": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "platforms": {}, "downloads": {}}
|
||||
out_sums = []
|
||||
for name, k, dest in files:
|
||||
assert SAFE.match(dest) and name in sums, name
|
||||
p = f"{inc}/{dest}"
|
||||
log(f"{v}: fetching {name}")
|
||||
req = urllib.request.Request(f"{FORGEJO}/attachments/{assets[name]['uuid']}", headers={"Authorization": f"token {open(TOKEN).read().strip()}"})
|
||||
with urllib.request.urlopen(req, timeout=900) as r, open(p, "wb") as f:
|
||||
shutil.copyfileobj(r, f, 1 << 20)
|
||||
h = sha256(p)
|
||||
if h != sums[name]:
|
||||
raise SystemExit(f"{name}: SHA-256 differs from the draft's SHA256SUMS")
|
||||
sig = sign(p)
|
||||
url = f"{SITE}/dl/{v}/{dest}"
|
||||
d = {"file": dest, "url": url, "sha256": h, "size": os.path.getsize(p), "signature": sig, "codeSigned": False}
|
||||
if k == "nsis":
|
||||
manifest["downloads"]["windows-x86_64-nsis"] = d
|
||||
manifest["platforms"]["windows-x86_64"] = {"signature": sig, "url": url}
|
||||
elif k == "appimage":
|
||||
manifest["downloads"]["linux-x86_64-appimage"] = d
|
||||
manifest["platforms"]["linux-x86_64"] = {"signature": sig, "url": url}
|
||||
else:
|
||||
manifest["downloads"]["linux-x86_64-deb"] = d
|
||||
out_sums.append(f"{h} {dest}")
|
||||
log(f"{v}: {dest} checked and signed")
|
||||
with open(f"{inc}/latest.json", "w") as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
out_sums.append(f"{sha256(inc + '/latest.json')} latest.json")
|
||||
with open(f"{inc}/SHA256SUMS", "w") as f:
|
||||
f.write("\n".join(sorted(out_sums, key=lambda l: l.split()[1])) + "\n")
|
||||
for n in os.listdir(inc):
|
||||
os.chmod(f"{inc}/{n}", 0o644)
|
||||
subprocess.run(["/usr/local/sbin/storebound-desktop-publish", v, "dev"], check=True)
|
||||
return True
|
||||
|
||||
|
||||
def prune():
|
||||
live = live_version()
|
||||
dl = f"{ROOT}/dl"
|
||||
dev = sorted((d for d in os.listdir(dl) if key(d)), key=key, reverse=True)
|
||||
for d in dev[KEEP:]:
|
||||
if d != live:
|
||||
shutil.rmtree(f"{dl}/{d}")
|
||||
log(f"pruned dl/{d}")
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(INCOMING, exist_ok=True)
|
||||
lock = open(f"{INCOMING}/.dev.lock", "w")
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
return
|
||||
rels = get(f"{FORGEJO}/api/v1/repos/{REPO}/releases?draft=true&limit=50")
|
||||
cands = [(r, r["tag_name"][4:]) for r in rels if r["tag_name"].startswith("dev-") and key(r["tag_name"][4:])]
|
||||
if not cands:
|
||||
return
|
||||
rel, v = max(cands, key=lambda c: key(c[1]))
|
||||
live = live_version()
|
||||
if live and key(live) and key(v) <= key(live):
|
||||
prune()
|
||||
return
|
||||
log(f"publishing {v} (live: {live or 'none'})")
|
||||
if publish(rel, v):
|
||||
prune()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except subprocess.CalledProcessError as e:
|
||||
log(f"failed: {e}")
|
||||
sys.exit(1)
|
||||
11
tools/vds/storebound-desktop-dev.service
Normal file
11
tools/vds/storebound-desktop-dev.service
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# /etc/systemd/system/storebound-desktop-dev.service (docs/desktop-release.md, "Dev channel")
|
||||
[Unit]
|
||||
Description=storebound desktop dev channel: sign and publish the newest dev build
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
ExecStart=/usr/local/sbin/storebound-desktop-dev
|
||||
TimeoutStartSec=30min
|
||||
Nice=10
|
||||
10
tools/vds/storebound-desktop-dev.timer
Normal file
10
tools/vds/storebound-desktop-dev.timer
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# /etc/systemd/system/storebound-desktop-dev.timer
|
||||
[Unit]
|
||||
Description=storebound desktop dev channel every 2 minutes
|
||||
|
||||
[Timer]
|
||||
OnBootSec=2min
|
||||
OnUnitInactiveSec=2min
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
# Root-owned publish step for storebound desktop releases (install as /usr/local/sbin/storebound-desktop-publish,
|
||||
# root:root 755; see docs/desktop-release.md). tools/desktop-release.ts publish uploads one version folder to
|
||||
# /var/lib/storebound-desktop/incoming/<version>/ and then runs: storebound-desktop-publish <version> [stable|beta|rehearsal]
|
||||
# /var/lib/storebound-desktop/incoming/<version>/ and then runs: storebound-desktop-publish <version> [stable|beta|rehearsal|dev]
|
||||
# (rehearsal: tools/release.ts --rehearsal, a throwaway prerelease at /desktop/rehearsal/latest.json that no app reads)
|
||||
#
|
||||
# It checks the upload (regular files with plain names only, SHA256SUMS, latest.json of that version whose every URL
|
||||
|
|
@ -21,10 +21,12 @@ ROOT=/var/www/storebound-desktop
|
|||
SITE=https://storebound.dev/desktop
|
||||
|
||||
die() { echo "storebound-desktop-publish: $*" >&2; exit 1; }
|
||||
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]] || die "usage: storebound-desktop-publish <version> [stable|beta|rehearsal]"
|
||||
[[ "$CHANNEL" == stable || "$CHANNEL" == beta || "$CHANNEL" == rehearsal ]] || die "channel: stable, beta or rehearsal"
|
||||
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]] || die "usage: storebound-desktop-publish <version> [stable|beta|rehearsal|dev]"
|
||||
[[ "$CHANNEL" == stable || "$CHANNEL" == beta || "$CHANNEL" == rehearsal || "$CHANNEL" == dev ]] || die "channel: stable, beta, rehearsal or dev"
|
||||
# a stable or beta version is never a rehearsal, and a rehearsal never reaches stable or beta
|
||||
if [[ "$VERSION" == *-rehearsal.* ]]; then [ "$CHANNEL" = rehearsal ] || die "a rehearsal version goes to the rehearsal channel only"; else [ "$CHANNEL" != rehearsal ] || die "the rehearsal channel takes rehearsal versions only"; fi
|
||||
# a dev build (storebound-desktop-dev, every push to main) goes to the dev channel only, and the dev channel takes only those
|
||||
if [[ "$VERSION" =~ -dev\.[0-9]{14}$ ]]; then [ "$CHANNEL" = dev ] || die "a dev version goes to the dev channel only"; else [ "$CHANNEL" != dev ] || die "the dev channel takes dev versions only"; fi
|
||||
IN="$INCOMING/$VERSION"
|
||||
DL="$ROOT/dl/$VERSION"
|
||||
[ -d "$IN" ] && [ ! -L "$IN" ] || die "nothing uploaded at $IN"
|
||||
|
|
@ -54,7 +56,7 @@ for e in entries:
|
|||
assert e.get("signature"), url
|
||||
PY
|
||||
|
||||
install -d -m 755 "$ROOT" "$ROOT/dl" "$ROOT/beta" "$ROOT/rehearsal"
|
||||
install -d -m 755 "$ROOT" "$ROOT/dl" "$ROOT/beta" "$ROOT/rehearsal" "$ROOT/dev"
|
||||
if [ ! -e "$DL" ]; then
|
||||
TMP="$(mktemp -d "$ROOT/dl/.tmp-$VERSION.XXXXXX")"
|
||||
for f in "$IN"/*; do n="$(basename "$f")"; [ "$n" = latest.json ] || install -m 644 "$f" "$TMP/$n"; done
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue