feat(release): the CLI archives carry the web page

The cli job builds the page once and packs it beside the executable under
frontend-next/dist, where serve.FindPage looks, through a packer and a checker
that are run by the job and by tests (#13406).
This commit is contained in:
esengine committed 2026-10-16 03:39:02 -07:00
1 parent a1f63d7964
commit 3959932434
7 files changed
+318 -17

No files matched your search

+1 -1
View File
@@ -88,7 +88,7 @@ jobs:
with:
go-version-file: go.mod
cache: true
- run: node --test scripts/linux-install-smoke.test.mjs scripts/studio-artifacts.test.mjs scripts/ci-platform-scope.test.mjs scripts/cli-archive-build.test.mjs scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/update-acknowledgments.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/studio-release-flags.test.mjs scripts/studio-notes-publish.test.mjs scripts/withdraw-version.test.mjs scripts/brew-cask-guard.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
- run: node --test scripts/linux-install-smoke.test.mjs scripts/studio-artifacts.test.mjs scripts/ci-platform-scope.test.mjs scripts/cli-archive-build.test.mjs scripts/cli-archive-contents.test.mjs scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/update-acknowledgments.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/studio-release-flags.test.mjs scripts/studio-notes-publish.test.mjs scripts/withdraw-version.test.mjs scripts/brew-cask-guard.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
# The ruleset requires the per-OS check names (test (ubuntu-latest) etc.).
# A matrix job skipped at job level reports no per-leg checks at all, so
+21 -12
View File
@@ -900,12 +900,31 @@ jobs:
go-version-file: go.mod
cache: true
# The page every archive carries beside the executable, built once here
# rather than once per target: serve.FindPage looks for
# frontend-next/dist next to the binary.
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10
run_install: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: desktop/frontend-next/pnpm-lock.yaml
- name: Build the page
working-directory: desktop/frontend-next
run: pnpm install --frozen-lockfile && pnpm build
# Every target the release line claims to support, from one runner: the
# CLI is CGO-free, so none of them needs a toolchain of its own.
- name: Cross-compile and archive
env:
VERSION: ${{ needs.resolve.outputs.version }}
SHA: ${{ needs.resolve.outputs.sha }}
PAGE_DIR: desktop/frontend-next/dist
run: |
set -euo pipefail
mkdir -p dist
@@ -917,11 +936,7 @@ jobs:
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -trimpath \
-ldflags "-s -w -X main.version=$VERSION -X main.gitCommit=${SHA:0:12} -X main.buildTimeUTC=$built -X $pkg.linkedVersion=$VERSION -X $pkg.linkedRevision=$SHA" \
-o "reasonix$ext" ./cmd/reasonix
if [ "$os" = windows ]; then
zip -qj "dist/reasonix-$os-$arch.zip" "reasonix$ext"
else
tar czf "dist/reasonix-$os-$arch.tar.gz" "reasonix$ext"
fi
scripts/package-cli-archive.sh "$os" "$arch" "reasonix$ext" "$PAGE_DIR" dist
rm -f "reasonix$ext"
done
# The digest list the remote verifies against. Resolved on the
@@ -933,13 +948,7 @@ jobs:
# A release that published the archives without the digest list is worse
# than one that published neither: the routes resolve SHA256SUMS first.
- name: Check the set is complete
run: |
set -euo pipefail
for want in reasonix-darwin-amd64.tar.gz reasonix-darwin-arm64.tar.gz \
reasonix-linux-amd64.tar.gz reasonix-linux-arm64.tar.gz \
reasonix-windows-amd64.zip reasonix-windows-arm64.zip SHA256SUMS; do
[ -f "dist/$want" ] || { echo "::error::no dist/$want"; exit 1; }
done
run: python3 scripts/check-cli-archives.py dist
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
+2 -2
View File
@@ -1,2 +1,2 @@
- 单独的 reasonix serve 没有网页界面时,浏览器打开它现在会看到一页说明(怎么用桌面版或 --page),不再只是一段 JSON。关联 #13406。
- When a standalone reasonix serve has no web page, a browser opening it now gets a readable explanation (use the desktop app or --page) instead of a bare JSON error. Refs #13406.
- 新增:命令行发布包现在随带网页界面,解压后直接运行 reasonix serve 就能在浏览器里打开。修复 #13406。
- Added: the command-line release archives now carry the web page, so reasonix serve opens in a browser straight after unpacking. Fixes #13406.
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""usage: check-cli-archives.py <dir>
The set a release publishes, and what is inside each archive. The remote install
and `reasonix upgrade` pick the executable out by its name, so an archive may
hold exactly one file of that name; the page must be present and must stay under
frontend-next/dist/. Standard library only.
"""
import os
import sys
import tarfile
import zipfile
WANT = [
"reasonix-darwin-amd64.tar.gz", "reasonix-darwin-arm64.tar.gz",
"reasonix-linux-amd64.tar.gz", "reasonix-linux-arm64.tar.gz",
"reasonix-windows-amd64.zip", "reasonix-windows-arm64.zip", "SHA256SUMS",
]
def fail(message):
print(f"::error::{message}", file=sys.stderr)
sys.exit(1)
def entries(path):
"""(kind, name) for each entry; kind is 'file', 'dir' or 'other'."""
out = []
if path.endswith(".zip"):
with zipfile.ZipFile(path) as z:
for info in z.infolist():
mode = info.external_attr >> 16
if info.is_dir():
kind = "dir"
elif mode == 0 or (mode & 0o170000) == 0o100000:
kind = "file"
else:
kind = "other"
out.append((kind, info.filename))
else:
with tarfile.open(path, "r:gz") as t:
for member in t.getmembers():
kind = "file" if member.isreg() else "dir" if member.isdir() else "other"
out.append((kind, member.name))
return out
def check(path, directory):
base = os.path.basename(path)
exe = "reasonix.exe" if base.endswith(".zip") else "reasonix"
try:
listed = entries(path)
except (tarfile.TarError, zipfile.BadZipFile, OSError, EOFError) as err:
fail(f"{base} cannot be read: {err}")
if not listed:
fail(f"{base} is empty")
roots = 0
has_index = False
for kind, raw in listed:
name = raw[2:] if raw.startswith("./") else raw
if kind == "other":
fail(f"{base}: {name} is not a plain file or directory")
if name.startswith("/") or "\\" in name or "\x00" in name:
fail(f"{base}: {name!r} is not a relative archive path")
parts = name.rstrip("/").split("/")
if ".." in parts or "." in parts or "" in parts:
fail(f"{base}: {name} climbs out of or doubles back in the archive")
if parts[-1] == exe:
if name != exe or kind != "file":
fail(f"{base}: {name} is named like the executable but is not the root binary")
roots += 1
continue
if parts[0] != "frontend-next":
fail(f"{base}: unexpected entry {name}")
if len(parts) == 1:
continue
if parts[1] != "dist":
fail(f"{base}: unexpected entry {name}")
if parts == ["frontend-next", "dist", "index.html"] and kind == "file":
has_index = True
if roots != 1:
fail(f"{base} holds {roots} copies of {exe}, want 1")
if not has_index:
fail(f"{base} has no frontend-next/dist/index.html")
def main():
if len(sys.argv) != 2:
print(__doc__, file=sys.stderr)
sys.exit(2)
directory = sys.argv[1]
for want in WANT:
if not os.path.isfile(os.path.join(directory, want)):
fail(f"no {directory}/{want}")
for want in WANT:
if want != "SHA256SUMS":
check(os.path.join(directory, want), directory)
main()
+6 -2
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { test } from "node:test";
@@ -38,9 +38,12 @@ test("the archives the updaters install report the commit and release they were
script = rewrite(script, /for target in [^;]+; do/, `for target in ${goos}/${goarch}; do`);
script = rewrite(script, /^\(cd .* sha256sum .*$/m, "");
script = script.replaceAll("dist", `"${out}"`);
const page = path.join(out, "page-src");
mkdirSync(page);
writeFileSync(path.join(page, "index.html"), "<title>studio</title>");
execFileSync("bash", ["-c", script], {
cwd: root,
env: { ...process.env, VERSION: "v9.8.7", SHA: sha },
env: { ...process.env, VERSION: "v9.8.7", SHA: sha, PAGE_DIR: page },
stdio: "pipe",
});
const archive = path.join(out, `reasonix-${goos}-${goarch}.${goos === "windows" ? "zip" : "tar.gz"}`);
@@ -52,6 +55,7 @@ test("the archives the updaters install report the commit and release they were
assert.match(report, /^build_time_utc: \d{4}-\d\d-\d\dT/m);
const binary = readFileSync(exe);
assert.ok(binary.includes(sha), "the docs corpus revision is not linked");
assert.equal(readFileSync(path.join(out, "frontend-next", "dist", "index.html"), "utf8"), "<title>studio</title>", "the archive does not carry the page");
} finally {
rmSync(out, { recursive: true, force: true });
}
+152
View File
@@ -0,0 +1,152 @@
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
const here = path.dirname(fileURLToPath(import.meta.url));
const pack = path.join(here, "package-cli-archive.sh");
const check = path.join(here, "check-cli-archives.py");
const TARGETS = [
["darwin", "amd64"], ["darwin", "arm64"], ["linux", "amd64"], ["linux", "arm64"], ["windows", "amd64"], ["windows", "arm64"],
];
const archiveName = (os, arch) => `reasonix-${os}-${arch}.${os === "windows" ? "zip" : "tar.gz"}`;
function fixture() {
const root = mkdtempSync(path.join(tmpdir(), "cli-contents-"));
const page = path.join(root, "dist");
mkdirSync(path.join(page, "assets"), { recursive: true });
writeFileSync(path.join(page, "index.html"), "<title>studio</title>");
writeFileSync(path.join(page, "assets", "app.js"), "// page");
const bin = path.join(root, "reasonix-built");
writeFileSync(bin, "binary");
chmodSync(bin, 0o700);
const out = path.join(root, "out");
mkdirSync(out);
return { root, page, bin, out };
}
function build(f, targets = TARGETS) {
for (const [os, arch] of targets) execFileSync("bash", [pack, os, arch, f.bin, f.page, f.out], { stdio: "pipe" });
writeFileSync(path.join(f.out, "SHA256SUMS"), "");
}
const run = (f) => spawnSync("python3", [check, f.out], { encoding: "utf8" });
function listing(archive) {
return archive.endsWith(".zip")
? execFileSync("unzip", ["-Z1", archive], { encoding: "utf8" })
: execFileSync("tar", ["tzf", archive], { encoding: "utf8" });
}
test("every archive holds the executable at the root and the page under frontend-next/dist, and nothing else", () => {
const f = fixture();
try {
build(f);
for (const [os, arch] of TARGETS) {
const names = listing(path.join(f.out, archiveName(os, arch))).split("\n").filter(Boolean).map((n) => n.replace(/\/$/, ""));
const exe = os === "windows" ? "reasonix.exe" : "reasonix";
assert.deepEqual(new Set(names), new Set([exe, "frontend-next", "frontend-next/dist", "frontend-next/dist/index.html", "frontend-next/dist/assets", "frontend-next/dist/assets/app.js"]), `${os}/${arch}`);
}
const done = run(f);
assert.equal(done.status, 0, done.stderr);
} finally {
rmSync(f.root, { recursive: true, force: true });
}
});
test("the executable keeps its mode and the page is not executable", () => {
const f = fixture();
try {
build(f, [["linux", "amd64"]]);
const verbose = execFileSync("tar", ["tzvf", path.join(f.out, "reasonix-linux-amd64.tar.gz")], { encoding: "utf8" });
assert.match(verbose, /^-rwxr-xr-x .* reasonix$/m);
assert.match(verbose, /^-rw-r--r-- .* frontend-next\/dist\/index\.html$/m);
} finally {
rmSync(f.root, { recursive: true, force: true });
}
});
test("the packer refuses a page that is not built, a missing binary and a page holding a link", () => {
const f = fixture();
try {
rmSync(path.join(f.page, "index.html"));
assert.notEqual(spawnSync("bash", [pack, "linux", "amd64", f.bin, f.page, f.out]).status, 0);
writeFileSync(path.join(f.page, "index.html"), "x");
assert.notEqual(spawnSync("bash", [pack, "linux", "amd64", path.join(f.root, "nope"), f.page, f.out]).status, 0);
symlinkSync("/etc/passwd", path.join(f.page, "link"));
assert.notEqual(spawnSync("bash", [pack, "linux", "amd64", f.bin, f.page, f.out]).status, 0);
} finally {
rmSync(f.root, { recursive: true, force: true });
}
});
// Repack one archive with an extra entry and see the checker refuse the set.
function tamper(f, os, arch, extra) {
const staging = path.join(f.root, "tamper");
rmSync(staging, { recursive: true, force: true });
mkdirSync(staging);
const archive = path.join(f.out, archiveName(os, arch));
if (os === "windows") execFileSync("unzip", ["-q", archive, "-d", staging]);
else execFileSync("tar", ["xzf", archive, "-C", staging]);
extra(staging);
rmSync(archive);
if (os === "windows") execFileSync("zip", ["-qry", archive, "reasonix.exe", "frontend-next", ...(extraTop.length ? extraTop : [])], { cwd: staging });
else execFileSync("tar", ["czf", archive, "-C", staging, "reasonix", "frontend-next", ...extraTop], {});
extraTop.length = 0;
}
const extraTop = [];
test("the checker refuses a second file named like the executable, even inside the page", () => {
const f = fixture();
try {
build(f);
tamper(f, "linux", "amd64", (dir) => writeFileSync(path.join(dir, "frontend-next", "dist", "reasonix"), "x"));
const done = run(f);
assert.notEqual(done.status, 0);
assert.match(done.stderr, /named like the executable/);
} finally {
rmSync(f.root, { recursive: true, force: true });
}
});
test("the checker refuses a top-level extra file, a link, and a page without index.html", () => {
for (const [label, extra, want] of [
["extra top-level file", (dir) => { writeFileSync(path.join(dir, "notes.txt"), "x"); extraTop.push("notes.txt"); }, /unexpected entry/],
["link in the page", (dir) => symlinkSync("/etc/passwd", path.join(dir, "frontend-next", "dist", "link")), /not a plain file/],
["no index", (dir) => rmSync(path.join(dir, "frontend-next", "dist", "index.html")), /no frontend-next\/dist\/index\.html/],
]) {
const f = fixture();
try {
build(f);
tamper(f, "linux", "arm64", extra);
const done = run(f);
assert.notEqual(done.status, 0, label);
assert.match(done.stderr, want, label);
} finally {
rmSync(f.root, { recursive: true, force: true });
}
}
});
test("the checker refuses a zip that carries a second exe, and a set with a file missing", () => {
const f = fixture();
try {
build(f);
tamper(f, "windows", "amd64", (dir) => writeFileSync(path.join(dir, "frontend-next", "dist", "reasonix.exe"), "x"));
assert.match(run(f).stderr, /named like the executable/);
} finally {
rmSync(f.root, { recursive: true, force: true });
}
const g = fixture();
try {
build(g);
rmSync(path.join(g.out, "SHA256SUMS"));
assert.match(run(g).stderr, /no .*SHA256SUMS/);
} finally {
rmSync(g.root, { recursive: true, force: true });
}
});
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# usage: package-cli-archive.sh <goos> <goarch> <binary> <page-dir> <out-dir>
# One CLI archive: the executable at the root and the built page under
# frontend-next/dist/, where serve.FindPage looks beside the executable. Nothing
# else goes in, and the modes are fixed here rather than inherited from the runner.
set -euo pipefail
[ $# -eq 5 ] || { echo "usage: $0 <goos> <goarch> <binary> <page-dir> <out-dir>" >&2; exit 2; }
os="$1"; arch="$2"; bin="$3"; page="$4"; out="$5"
[ -f "$bin" ] || { echo "::error::no binary at $bin" >&2; exit 1; }
[ -f "$page/index.html" ] || { echo "::error::no $page/index.html: the page is not built" >&2; exit 1; }
[ -d "$out" ] || { echo "::error::no output directory $out" >&2; exit 1; }
out="$(cd "$out" && pwd)"
name=reasonix
[ "$os" = windows ] && name=reasonix.exe
stage="$(mktemp -d)"
trap 'rm -rf "$stage"' EXIT
cp "$bin" "$stage/$name"
chmod 755 "$stage/$name"
mkdir -p "$stage/frontend-next"
cp -R "$page" "$stage/frontend-next/dist"
if [ -n "$(find "$stage/frontend-next" ! -type f ! -type d -print -quit)" ]; then
echo "::error::the page holds something that is neither a file nor a directory" >&2
exit 1
fi
find "$stage/frontend-next" -type d -exec chmod 755 {} +
find "$stage/frontend-next" -type f -exec chmod 644 {} +
if [ "$os" = windows ]; then
(cd "$stage" && zip -qr "$out/reasonix-$os-$arch.zip" "$name" frontend-next)
else
tar czf "$out/reasonix-$os-$arch.tar.gz" -C "$stage" "$name" frontend-next
fi