mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
1 parent
a1f63d7964
commit
3959932434
7 files changed
+318
-17
No files matched your search
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
Executable
+100
@@ -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()
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
Executable
+36
@@ -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
|
||||
Reference in new issue
Block a user