diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 005e8e8..c1a6a6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,7 @@ jobs: run: | cd site npm ci + node scripts/check-archives.mjs npm run build node scripts/check-no-html.mjs node scripts/check-pages.mjs diff --git a/README.md b/README.md index 94f1d52..c8d8c52 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Twelve skills for Hermes Agent. Frame the job, shape the change, prove it, and o curl -fsSL https://hstack.sh/install | sh ``` -The curl form downloads `install.sh` and the skill archive from hstack.sh, checks the sha256, and extracts into the Hermes skills directory. It does not clone this repository. `HSTACK_BASE_URL` points the download at another host, such as a `*.vercel.app` URL before DNS is in place. With no version set, the installer fetches the latest alias, `hstack.tar.gz`, which is 0.2.0. `HSTACK_VERSION=0.1.0` still fetches `hstack-v0.1.0.tar.gz`. The archive is one directory containing the skills, `scripts/apply_addons.py`, `scripts/apply_hooks.py`, `hooks/`, and the `bin/` helpers, so `--with` works from the curl form too. +The curl form downloads `install.sh` and the skill archive from hstack.sh, checks the sha256, and extracts into the Hermes skills directory. It does not clone this repository. `HSTACK_BASE_URL` points the download at another host, such as a `*.vercel.app` URL before DNS is in place. With no version set, the installer fetches the latest alias, `hstack.tar.gz`, which is 0.2.1. `HSTACK_VERSION=0.2.0` still fetches the frozen `hstack-v0.2.0.tar.gz`. `HSTACK_VERSION=0.1.0` still fetches `hstack-v0.1.0.tar.gz`. The archive is one directory containing the skills, `scripts/apply_addons.py`, `scripts/apply_hooks.py`, `hooks/`, and the `bin/` helpers, so `--with` works from the curl form too. From a checkout of this repo, `sh install.sh` installs the skills beside the script. A second run replaces the HStack skills and leaves every other skill alone. Pass `--dry-run` to print the plan and write nothing. diff --git a/VERSION b/VERSION index 0ea3a94..0c62199 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.0 +0.2.1 diff --git a/docs/v0.2-DESIGN.md b/docs/v0.2-DESIGN.md index d5984d3..edc9ead 100644 --- a/docs/v0.2-DESIGN.md +++ b/docs/v0.2-DESIGN.md @@ -25,7 +25,7 @@ Each installed hook is a few lines that call `hooks/hstack_hook.py`. The skills Hermes still asks the first time it sees each `(event, command)` pair and stores the answer in `~/.hermes/shell-hooks-allowlist.json`. The installer does not write that file. A gateway, cron job, or other non-interactive surface will not run a new hook until that prompt has been answered, or until the user passes `--accept-hooks` / `HERMES_ACCEPT_HOOKS=1` themselves. Editing the script later does not clear the allowlist. Hermes keys it on the command string (`hermes hooks doctor` reports mtime drift). -`--with hermes-hooks` installs all three. A subset is `hermes-hooks-guard`, `hermes-hooks-save`, or `hermes-hooks-suggest`. The installer copies the shims to `$HERMES_HOME/hstack/hooks/`, edits `config.yaml`, and copies `apply_hooks.py` beside the addon script so a later `curl | sh -s -- --uninstall` can remove them. It backs up `config.yaml` to `$HERMES_HOME/hstack/backups/` before a change. A second run that would write the same block does not write and does not take another backup. `--uninstall` removes only the entries marked `# hstack:` and the files this installer wrote. Another product's hook stays. +`--with hermes-hooks` installs all three. A subset is `hermes-hooks-guard`, `hermes-hooks-save`, or `hermes-hooks-suggest`. The installer copies the shims to `$HERMES_HOME/hstack/hooks/`, edits `config.yaml`, and copies `apply_hooks.py` beside the addon script so a later `curl | sh -s -- --uninstall` can remove them. It backs up `config.yaml` to `$HERMES_HOME/hstack-backups/` before a change. Uninstall removes `$HERMES_HOME/hstack/` and leaves that backup directory in place. A second run that would write the same block does not write and does not take another backup. `--uninstall` removes only the entries marked `# hstack:` and the files this installer wrote. Another product's hook stays. ### `guard` — `pre_tool_call` on `terminal` diff --git a/install.ps1 b/install.ps1 index f4c6d61..ad93d42 100644 --- a/install.ps1 +++ b/install.ps1 @@ -348,7 +348,7 @@ if ($links.Count -gt 0) { Die 'refusing to copy a link or special file from the skill tree' } -$ver = '0.2.0' +$ver = '0.2.1' $versionFile = if ($AddonRoot) { Join-Path $AddonRoot 'VERSION' } else { $null } if ($versionFile -and (Test-Path -LiteralPath $versionFile)) { $digits = ([System.IO.File]::ReadAllText($versionFile) -replace '[^0-9.]', '') diff --git a/install.sh b/install.sh index 4361cab..2327366 100755 --- a/install.sh +++ b/install.sh @@ -446,7 +446,7 @@ if find "$SRC" \( -type l -o -type b -o -type c -o -type p -o -type s \) -print die "refusing to copy a link or special file from the skill tree" fi -ver=0.2.0 +ver=0.2.1 if [ -n "$ADDON_ROOT" ] && [ -f "$ADDON_ROOT/VERSION" ]; then got=$(tr -cd '0-9.' < "$ADDON_ROOT/VERSION") if [ -n "$got" ]; then diff --git a/scripts/apply_hooks.py b/scripts/apply_hooks.py index 7b32c30..d69f036 100644 --- a/scripts/apply_hooks.py +++ b/scripts/apply_hooks.py @@ -332,9 +332,13 @@ def copy_self(dest_root: Path, dry_run: bool) -> None: target.chmod(0o644) +def backup_dir(root: Path) -> Path: + return root / "hstack-backups" + + def backup_config(config: Path, root: Path) -> None: stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") - dest = root / "hstack" / "backups" / f"config.yaml.{stamp}" + dest = backup_dir(root) / f"config.yaml.{stamp}" dest.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(config, dest) say(f"backed up config.yaml to {dest}") @@ -437,11 +441,6 @@ def apply(root: Path, source: Path | None, hooks: list[str], remove: bool, dry_r self_copy = dest_root / "apply_hooks.py" if self_copy.is_file() and not self_copy.is_symlink(): self_copy.unlink() - backups = dest_root / "backups" - if backups.is_dir(): - for child in backups.iterdir(): - if child.is_file() and child.name.startswith("config.yaml."): - child.unlink() prune_root(dest_root) return if not marker.exists(): diff --git a/scripts/check_hooks.py b/scripts/check_hooks.py index d9d858d..79abb8d 100644 --- a/scripts/check_hooks.py +++ b/scripts/check_hooks.py @@ -72,10 +72,10 @@ def live(root: Path, command_home: str, hooks: list[str]) -> None: fail(f"dry-run wrote config for {command_home}") apply_hooks.apply(root, source, hooks, remove=False, dry_run=False, command_home=command_home) config = (root / "config.yaml").read_text(encoding="utf-8") - backups = list((root / "hstack" / "backups").glob("config.yaml.*")) if (root / "hstack" / "backups").exists() else [] + backups = list((root / "hstack-backups").glob("config.yaml.*")) if (root / "hstack-backups").exists() else [] # First write creates the file, so there is no previous config to back up. apply_hooks.apply(root, source, hooks, remove=False, dry_run=False, command_home=command_home) - backups_after = list((root / "hstack" / "backups").glob("config.yaml.*")) if (root / "hstack" / "backups").exists() else [] + backups_after = list((root / "hstack-backups").glob("config.yaml.*")) if (root / "hstack-backups").exists() else [] if len(backups_after) != len(backups): fail(f"idempotent install took another backup for {command_home}") again = (root / "config.yaml").read_text(encoding="utf-8") @@ -93,6 +93,11 @@ def live(root: Path, command_home: str, hooks: list[str]) -> None: fail(f"uninstall left config for {command_home}") if (root / "hstack" / "hooks").exists(): fail(f"uninstall left hook scripts for {command_home}") + kept = list((root / "hstack-backups").glob("config.yaml.*")) + if len(kept) != 1 or kept[0].read_text(encoding="utf-8") != config: + fail(f"uninstall deleted the config backup for {command_home}") + if (root / "hstack" / "backups").exists(): + fail(f"uninstall left a backup inside hstack for {command_home}") def foreign_backup(root: Path) -> None: @@ -100,13 +105,13 @@ def foreign_backup(root: Path) -> None: original = 'model: local\nhooks:\n post_tool_call:\n - command: "/usr/bin/log"\n' (root / "config.yaml").write_text(original, encoding="utf-8") apply_hooks.apply(root, ROOT, ["guard"], remove=False, dry_run=False, command_home=root.as_posix()) - backups = list((root / "hstack" / "backups").glob("config.yaml.*")) + backups = list((root / "hstack-backups").glob("config.yaml.*")) if len(backups) != 1: fail(f"expected one backup, found {len(backups)}") if backups[0].read_text(encoding="utf-8") != original: fail("backup is not the original config") apply_hooks.apply(root, ROOT, ["guard"], remove=False, dry_run=False, command_home=root.as_posix()) - if len(list((root / "hstack" / "backups").glob("config.yaml.*"))) != 1: + if len(list((root / "hstack-backups").glob("config.yaml.*"))) != 1: fail("second install added a backup") text = (root / "config.yaml").read_text(encoding="utf-8") if "/usr/bin/log" not in text or "# hstack:guard" not in text: @@ -117,6 +122,11 @@ def foreign_backup(root: Path) -> None: fail("guard uninstall left its entry") if "/usr/bin/log" not in left: fail("guard uninstall removed the foreign hook") + surviving = list((root / "hstack-backups").glob("config.yaml.*")) + if not any(item.read_text(encoding="utf-8") == original for item in surviving): + fail("uninstall deleted the original config backup") + if (root / "hstack" / "backups").exists(): + fail("uninstall left a backup inside hstack") def installer() -> None: @@ -157,6 +167,11 @@ def installer() -> None: state = home / "hstack" / "hooks.json" if state.exists(): fail(f"hook state remained: {state.read_text()}") + kept = list((home / "hstack-backups").glob("config.yaml.*")) + if not kept or not any("hstack" in item.read_text(encoding="utf-8") for item in kept): + fail("uninstall deleted its config backup") + if (home / "hstack" / "backups").exists(): + fail("uninstall left a backup inside the removed tree") def behavior() -> None: diff --git a/scripts/check_piped_install.sh b/scripts/check_piped_install.sh index f60560a..990c9bb 100755 --- a/scripts/check_piped_install.sh +++ b/scripts/check_piped_install.sh @@ -63,7 +63,7 @@ install_with() { test -f "$home/hermes/skills/hstack/hstack/references/frame.md" || fail "$label missing playbook" test "$(count_skills "$home/hermes")" -eq 12 || fail "$label expected 12 skills" test ! -e "$home/hermes/skills/hstack/VERSION" || fail "$label left VERSION in the skill tree" - grep -q 'version: 0.2.0' "$home/hermes/skills/hstack/.hstack-managed" || fail "$label marker version" + grep -q 'version: 0.2.1' "$home/hermes/skills/hstack/.hstack-managed" || fail "$label marker version" diff -q "$ROOT/skills/hstack/reframe/SKILL.md" "$home/hermes/skills/hstack/reframe/SKILL.md" >/dev/null \ || fail "$label skill bytes differ" if [ -n "$path_value" ]; then diff --git a/scripts/check_skills.py b/scripts/check_skills.py index a6c7ab8..6f0a7e4 100755 --- a/scripts/check_skills.py +++ b/scripts/check_skills.py @@ -291,8 +291,8 @@ def check_skills() -> dict[str, int]: fail(f"{name}: description must be one sentence of at most 60 characters") if clip_description(desc) != desc or clip_description_stable(desc) != desc: fail(f"{name}: Hermes would clip the description") - if meta.get("version") != "0.2.0": - fail(f"{name}: version must be 0.2.0") + if meta.get("version") != "0.2.1": + fail(f"{name}: version must be 0.2.1") if meta.get("license") != "MIT": fail(f"{name}: license must be MIT") if meta.get("author") != "Preetham Kyanam": diff --git a/site/DEPLOY.md b/site/DEPLOY.md index 12cb38d..53d2f72 100644 --- a/site/DEPLOY.md +++ b/site/DEPLOY.md @@ -38,19 +38,22 @@ Leave the Porkbun nameservers alone. The registrar stays Porkbun. Vercel does no - Any other path under `/install/` is a plain-text 404 (`not found`), not an HTML page. - `curl -fsSL hstack.sh/install` follows HTTP to HTTPS and receives the script. `curl -fsSL www.hstack.sh/install` follows the 308 to the apex and receives the same script. -Do not rebuild `/dl/hstack-v0.1.0.tar.gz`. The site build copies `site/vendor/dl/hstack-v0.1.0.tar.gz` into `dist` unchanged. Those bytes are the archive already on hstack.sh. sha256 `0c8009ee04fb31126b76e4202c91eee14e8d5be7d1cd42ac0cee551012eeda6e`, 19967 bytes. The checksum file is that digest, two spaces, then `hstack-v0.1.0.tar.gz`. +Do not rebuild `/dl/hstack-v0.1.0.tar.gz` or `/dl/hstack-v0.2.0.tar.gz`. The site build copies both from `site/vendor/dl/` unchanged. + +`hstack-v0.1.0.tar.gz` is sha256 `0c8009ee04fb31126b76e4202c91eee14e8d5be7d1cd42ac0cee551012eeda6e`, 19967 bytes. `hstack-v0.2.0.tar.gz` is the file hstack.sh served for 0.2.0, sha256 `c16dfee4cd2841939a97a5cebf816c3fc3e8d71e28f2f1874639ea539ea20810`, 30914 bytes. Each checksum line is that digest, two spaces, then the filename. After this deploy, `/dl` is: | Path | Bytes | Cache-Control | |---|---|---| -| `/dl/hstack-v0.1.0.tar.gz` and `.sha256` | the vendored 0.1.0 archive, unchanged | `public, max-age=31536000, immutable` | -| `/dl/hstack-v0.2.0.tar.gz` and `.sha256` | the new archive from this tree | `public, max-age=31536000, immutable` | -| `/dl/hstack.tar.gz` and `.sha256` | the same bytes as `hstack-v0.2.0.tar.gz` | `public, max-age=300` | +| `/dl/hstack-v0.1.0.tar.gz` and `.sha256` | the vendored 0.1.0 archive | `public, max-age=31536000, immutable` | +| `/dl/hstack-v0.2.0.tar.gz` and `.sha256` | the vendored live 0.2.0 archive, not a rebuild | `public, max-age=31536000, immutable` | +| `/dl/hstack-v0.2.1.tar.gz` and `.sha256` | the vendored 0.2.1 archive | `public, max-age=31536000, immutable` | +| `/dl/hstack.tar.gz` and `.sha256` | the same bytes as `hstack-v0.2.1.tar.gz` | `public, max-age=300` | -`HSTACK_VERSION=0.1.0` requests the versioned 0.1.0 file, not the alias. The alias changes on this deploy. Its five-minute cache is the existing rule. Until this build is deployed, hstack.sh still serves 0.1.0 at `/dl/hstack.tar.gz`. +`HSTACK_VERSION=0.1.0` and `HSTACK_VERSION=0.2.0` request those versioned files, not the alias. The alias changes on this deploy. Its five-minute cache is the existing rule. Until this build is deployed, hstack.sh still serves 0.2.0 at `/dl/hstack.tar.gz`. -The 0.2.0 archive has one top-level directory. Inside it are `skills/hstack/`, `scripts/apply_addons.py`, `scripts/apply_hooks.py`, `hooks/`, `bin/` (the addon helpers), and `VERSION`. +The 0.2.1 archive has one top-level directory. Inside it are `skills/hstack/`, `scripts/apply_addons.py`, `scripts/apply_hooks.py`, `hooks/`, `bin/` (the addon helpers), and `VERSION`. ## Cloudflare Pages fallback diff --git a/site/_headers b/site/_headers index bf35749..2f4330e 100644 --- a/site/_headers +++ b/site/_headers @@ -46,6 +46,14 @@ Content-Type: text/plain; charset=utf-8 Cache-Control: public, max-age=31536000, immutable +/dl/hstack-v0.2.1.tar.gz + Content-Type: application/gzip + Cache-Control: public, max-age=31536000, immutable + +/dl/hstack-v0.2.1.tar.gz.sha256 + Content-Type: text/plain; charset=utf-8 + Cache-Control: public, max-age=31536000, immutable + /fonts/* Access-Control-Allow-Origin: * Cache-Control: public, max-age=31536000, immutable diff --git a/site/content/changelog.md b/site/content/changelog.md index ff4b575..2462bd2 100644 --- a/site/content/changelog.md +++ b/site/content/changelog.md @@ -1,8 +1,17 @@ --- title: Changelog -description: HStack 0.2.0. Optional Hermes hooks, a consent gate for outside stacks, and the same 204-token idle index. +description: HStack 0.2.1. Frozen release archives, deterministic builds, and config backups that survive uninstall. --- +## 0.2.1 + +28 October 2026. + +- `/dl/hstack-v0.2.0.tar.gz` is the archive that hstack.sh already served. The build copies those bytes and does not rebuild them. The latest alias is 0.2.1. +- New archives use a fixed mtime, sorted names, numeric owner 0, and `gzip -n`. Two builds of the same tree produce the same sha256. +- `--uninstall` keeps `config.yaml` backups in `$HERMES_HOME/hstack-backups/`. It no longer stores them under `$HERMES_HOME/hstack/`, which uninstall removes. +- Idle index stays 204. + ## 0.2.0 28 October 2026. diff --git a/site/content/docs/index.md b/site/content/docs/index.md index 2837b9a..ed5420f 100644 --- a/site/content/docs/index.md +++ b/site/content/docs/index.md @@ -17,4 +17,4 @@ Skills land in `~/.hermes/skills/hstack/`. Set `HERMES_HOME` when Hermes lives s The planning path is `hstack`, `reframe`, and `shape`: 1,052 tokens, under 2% of GStack's planning load on Hermes. The idle index is 204 tokens. All twelve `SKILL.md` bodies are 3,907. Counts use Hermes's estimator, `ceil(bytes / 4)`. -Playbooks live under `skills/hstack/hstack/references/` and load one at a time. After that playbook, the router may read `stacks.md` and ask before using gstack, pstack, tstack, or gbrain. Hermes shell hooks stay off unless the install names `--with hermes-hooks`. The [skills index](/skills) lists each skill. [Parity](/parity) has one row for every GStack, TStack, and PStack feature that was inventoried. +Playbooks live under `skills/hstack/hstack/references/` and load one at a time. After that playbook, the router may read `stacks.md` and ask before using gstack, pstack, tstack, or gbrain. Hermes shell hooks, since 0.2.0, stay off unless the install names `--with hermes-hooks`. The [skills index](/skills) lists each skill. [Parity](/parity) has one row for every GStack, TStack, and PStack feature that was inventoried. diff --git a/site/content/docs/install.md b/site/content/docs/install.md index 35664fd..ea7aeaf 100644 --- a/site/content/docs/install.md +++ b/site/content/docs/install.md @@ -56,12 +56,12 @@ powershell -ExecutionPolicy Bypass -File .\install.ps1 --uninstall ## Version and host -With no version set, the installer fetches `/dl/hstack.tar.gz`, the latest alias. That alias is 0.2.0. `HSTACK_VERSION=0.1.0` still fetches `/dl/hstack-v0.1.0.tar.gz`, the previous archive, byte for byte. `HSTACK_BASE_URL` points the download at another host, such as a `*.vercel.app` URL before DNS is in place. +With no version set, the installer fetches `/dl/hstack.tar.gz`, the latest alias. That alias is 0.2.1. `HSTACK_VERSION=0.2.0` still fetches `/dl/hstack-v0.2.0.tar.gz`, and `HSTACK_VERSION=0.1.0` still fetches `/dl/hstack-v0.1.0.tar.gz`. Those versioned files stay byte for byte. `HSTACK_BASE_URL` points the download at another host, such as a `*.vercel.app` URL before DNS is in place. ```sh install.sh curl -fsSL https://hstack.sh/install | HSTACK_VERSION=0.1.0 sh ``` -The archive is one directory. Inside 0.2.0: the skills, `scripts/apply_addons.py`, `scripts/apply_hooks.py`, `hooks/`, and the `bin/` helpers. The published checksum files are `HEX filename`, two spaces. A mismatch stops the install and does not write skills. +The archive is one directory. Inside 0.2.1: the skills, `scripts/apply_addons.py`, `scripts/apply_hooks.py`, `hooks/`, and the `bin/` helpers. The published checksum files are `HEX filename`, two spaces. A mismatch stops the install and does not write skills. WSL can run `install.sh`. The Windows script takes the same flags and the same archive. diff --git a/site/content/integrations.md b/site/content/integrations.md index 3cc2fc4..466fec8 100644 --- a/site/content/integrations.md +++ b/site/content/integrations.md @@ -28,7 +28,7 @@ Commands, dry-run, and the Windows `-File` form are on the [add-ons](/docs/addon ## Hooks -`--with hermes-hooks` registers shell hooks in `~/.hermes/config.yaml`. Nothing in the skills imports them, so a Hermes update that moves a Python API does not break the router. The guard blocks a short denylist of destructive terminal commands. The session hook appends a stamp when the session actually ends (`on_session_finalize`, not `on_session_end`, which fires every turn). The suggest hook can add context on the first turn only, and only an HStack playbook name. Hermes still asks before a new hook command runs, unless you pass `--accept-hooks` yourself. +Since 0.2.0, `--with hermes-hooks` registers shell hooks in `~/.hermes/config.yaml`. Nothing in the skills imports them, so a Hermes update that moves a Python API does not break the router. The guard blocks a short denylist of destructive terminal commands. The session hook appends a stamp when the session actually ends (`on_session_finalize`, not `on_session_end`, which fires every turn). The suggest hook can add context on the first turn only, and only an HStack playbook name. Hermes still asks before a new hook command runs, unless you pass `--accept-hooks` yourself. ## Outside stacks diff --git a/site/scripts/archive.mjs b/site/scripts/archive.mjs new file mode 100644 index 0000000..d3a9649 --- /dev/null +++ b/site/scripts/archive.mjs @@ -0,0 +1,110 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { chmodSync, cpSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +// Commit time of 13678a1, the 0.2.0 release. SOURCE_DATE_EPOCH overrides it. +export const ARCHIVE_MTIME = process.env.SOURCE_DATE_EPOCH || "1793189427"; + +export const PINS = { + "0.1.0": "0c8009ee04fb31126b76e4202c91eee14e8d5be7d1cd42ac0cee551012eeda6e", + "0.2.0": "c16dfee4cd2841939a97a5cebf816c3fc3e8d71e28f2f1874639ea539ea20810", + "0.2.1": "61108ad0d5f54ffa2f42ce91ee50758fc459eca7520ba6a1956353678c3c7816", +}; + +export const CURRENT = "0.2.1"; + +const HELPERS = [ + "hstack-bench", + "hstack-decisions", + "hstack-pr", + "hstack-scan", + "hstack-test", + "hstack-visdiff", +]; + +export function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +export function vendorArchive(site, version) { + return path.join(site, "vendor", "dl", `hstack-v${version}.tar.gz`); +} + +function skipCache(src) { + return !src.split(path.sep).includes("__pycache__"); +} + +function normalizeTree(top) { + const stack = [top]; + while (stack.length) { + const dir = stack.pop(); + chmodSync(dir, 0o755); + for (const name of readdirSync(dir)) { + const file = path.join(dir, name); + const info = lstatSync(file); + if (info.isSymbolicLink()) { + throw new Error(`refusing symlink in archive tree: ${file}`); + } + if (info.isDirectory()) { + stack.push(file); + continue; + } + const rel = path.relative(top, file).split(path.sep).join("/"); + chmodSync(file, rel.startsWith("bin/") ? 0o755 : 0o644); + } + } +} + +export function buildReleaseArchive(root, version) { + const skillsRoot = path.join(root, "skills", "hstack"); + const stage = mkdtempSync(path.join(tmpdir(), "hstack-archive-")); + try { + const top = path.join(stage, "hstack"); + mkdirSync(path.join(top, "scripts"), { recursive: true }); + mkdirSync(path.join(top, "bin"), { recursive: true }); + cpSync(skillsRoot, path.join(top, "skills", "hstack"), { recursive: true, filter: skipCache }); + cpSync(path.join(root, "hooks"), path.join(top, "hooks"), { recursive: true, filter: skipCache }); + cpSync(path.join(root, "scripts", "apply_addons.py"), path.join(top, "scripts", "apply_addons.py")); + cpSync(path.join(root, "scripts", "apply_hooks.py"), path.join(top, "scripts", "apply_hooks.py")); + for (const helper of HELPERS) { + cpSync(path.join(root, "bin", helper), path.join(top, "bin", helper)); + } + writeFileSync(path.join(top, "VERSION"), `${version}\n`); + normalizeTree(top); + const tar = spawnSync("tar", [ + "--format=ustar", + "--owner=0", + "--group=0", + "--numeric-owner", + `--mtime=@${ARCHIVE_MTIME}`, + "--sort=name", + "-cf", + "-", + "-C", + stage, + "hstack", + ], { maxBuffer: 20 * 1024 * 1024 }); + if (tar.status !== 0) { + throw new Error(`tar failed: ${tar.stderr.toString()}`); + } + const gzip = spawnSync("gzip", ["-n", "-c"], { input: tar.stdout, maxBuffer: 20 * 1024 * 1024 }); + if (gzip.status !== 0) { + throw new Error(`gzip failed: ${gzip.stderr.toString()}`); + } + return gzip.stdout; + } finally { + rmSync(stage, { recursive: true, force: true }); + } +} + +export function assertVendoredPins(site) { + for (const [version, pin] of Object.entries(PINS)) { + const file = vendorArchive(site, version); + const digest = sha256(readFileSync(file)); + if (digest !== pin) { + throw new Error(`vendored hstack-v${version}.tar.gz is ${digest}, pin is ${pin}`); + } + } +} diff --git a/site/scripts/build.mjs b/site/scripts/build.mjs index b66b05a..f9405f5 100644 --- a/site/scripts/build.mjs +++ b/site/scripts/build.mjs @@ -1,10 +1,9 @@ -import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync, copyFileSync } from "node:fs"; -import { mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { copyFileSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import path from "node:path"; import { execFileSync } from "node:child_process"; import crypto from "node:crypto"; import { emitSite } from "./render.mjs"; +import { assertVendoredPins, CURRENT, PINS, vendorArchive } from "./archive.mjs"; const site = path.resolve(import.meta.dirname, ".."); const root = path.resolve(site, ".."); @@ -59,8 +58,8 @@ const version = skills[0].version; if (!skills.every((skill) => skill.version === version)) { throw new Error("skill versions differ"); } -if (version !== "0.2.0") { - throw new Error(`version is ${version}; dist publishes hstack-v0.2.0.tar.gz`); +if (version !== CURRENT) { + throw new Error(`version is ${version}; dist publishes hstack-v${CURRENT}.tar.gz`); } const idle = tokens(" hstack:", "category") + skills.reduce((sum, skill) => sum + skill.idle, 0); @@ -93,34 +92,22 @@ const helpers = [ "hstack-test", "hstack-visdiff", ]; -const stage = mkdtempSync(path.join(tmpdir(), "hstack-skills-")); -try { - const top = path.join(stage, "hstack"); - mkdirSync(path.join(top, "skills"), { recursive: true }); - mkdirSync(path.join(top, "scripts"), { recursive: true }); - mkdirSync(path.join(top, "bin"), { recursive: true }); - mkdirSync(path.join(top, "hooks"), { recursive: true }); - const skipCache = (src) => !src.split(path.sep).includes("__pycache__"); - cpSync(skillsRoot, path.join(top, "skills", "hstack"), { recursive: true, filter: skipCache }); - cpSync(path.join(root, "hooks"), path.join(top, "hooks"), { recursive: true, filter: skipCache }); - cpSync(path.join(root, "scripts", "apply_addons.py"), path.join(top, "scripts", "apply_addons.py")); - cpSync(path.join(root, "scripts", "apply_hooks.py"), path.join(top, "scripts", "apply_hooks.py")); - for (const helper of helpers) { - cpSync(path.join(root, "bin", helper), path.join(top, "bin", helper)); +assertVendoredPins(site); +{ + for (const [frozenVersion, pin] of Object.entries(PINS)) { + const bytes = readFileSync(vendorArchive(site, frozenVersion)); + const digest = crypto.createHash("sha256").update(bytes).digest("hex"); + if (digest !== pin) { + throw new Error(`vendored hstack-v${frozenVersion}.tar.gz is ${digest}`); + } + const target = path.join(dist, "dl", `hstack-v${frozenVersion}.tar.gz`); + writeFileSync(target, bytes); + writeFileSync(`${target}.sha256`, `${digest} hstack-v${frozenVersion}.tar.gz\n`); + } + const versioned = path.join(dist, "dl", `hstack-v${CURRENT}.tar.gz`); + if (!PINS[CURRENT]) { + throw new Error(`missing pin for hstack-v${CURRENT}.tar.gz`); } - writeFileSync(path.join(top, "VERSION"), `${version}\n`); - const versioned = path.join(dist, "dl", `hstack-v${version}.tar.gz`); - execFileSync("tar", [ - "--format=ustar", - "--owner=0", - "--group=0", - "--numeric-owner", - "-czf", - versioned, - "-C", - stage, - "hstack", - ], { stdio: "inherit" }); const members = execFileSync("tar", ["-tzf", versioned], { encoding: "utf8" }).trim().split("\n"); const tops = new Set(members.map((member) => member.replace(/^\.\//, "").split("/")[0]).filter(Boolean)); if (tops.size !== 1 || !tops.has("hstack")) { @@ -162,24 +149,15 @@ try { throw new Error(`archive helper is not executable: ${helper}`); } } - const frozenVersion = "0.1.0"; - const frozenSha = "0c8009ee04fb31126b76e4202c91eee14e8d5be7d1cd42ac0cee551012eeda6e"; - const vendorArchive = path.join(site, "vendor", "dl", `hstack-v${frozenVersion}.tar.gz`); - const frozenBytes = readFileSync(vendorArchive); - const frozenDigest = crypto.createHash("sha256").update(frozenBytes).digest("hex"); - if (frozenDigest !== frozenSha) { - throw new Error(`vendored hstack-v${frozenVersion}.tar.gz hash is ${frozenDigest}`); - } - const frozenTarget = path.join(dist, "dl", `hstack-v${frozenVersion}.tar.gz`); - writeFileSync(frozenTarget, frozenBytes); - copyFileSync(`${vendorArchive}.sha256`, `${frozenTarget}.sha256`); const latest = path.join(dist, "dl", "hstack.tar.gz"); copyFileSync(versioned, latest); const hash = crypto.createHash("sha256").update(readFileSync(versioned)).digest("hex"); - if (hash === frozenSha) { - throw new Error("0.2.0 archive matches the frozen 0.1.0 bytes"); + if (hash !== PINS[CURRENT]) { + throw new Error(`latest alias is ${hash}, vendored ${CURRENT} pin is ${PINS[CURRENT]}`); + } + if (hash === PINS["0.1.0"] || hash === PINS["0.2.0"]) { + throw new Error("latest alias still matches an older frozen archive"); } - writeFileSync(`${versioned}.sha256`, `${hash} hstack-v${version}.tar.gz\n`); writeFileSync(`${latest}.sha256`, `${hash} hstack.tar.gz\n`); for (const published of ["install.sh", "install.ps1"]) { if (readFileSync(path.join(dist, published), "utf8").includes("github.com")) { @@ -187,6 +165,4 @@ try { } } console.log(`idle ${idle} planning ${planning} bodies ${bodies} js ${jsGzip} gzip ${hash.slice(0, 12)}`); -} finally { - rmSync(stage, { recursive: true, force: true }); } diff --git a/site/scripts/check-archives.mjs b/site/scripts/check-archives.mjs new file mode 100644 index 0000000..fcafaac --- /dev/null +++ b/site/scripts/check-archives.mjs @@ -0,0 +1,29 @@ +import { buildReleaseArchive, CURRENT, PINS, sha256, vendorArchive } from "./archive.mjs"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +const site = path.resolve(import.meta.dirname, ".."); +const root = path.resolve(site, ".."); + +function fail(message) { + console.error(`check-archives: ${message}`); + process.exit(1); +} + +for (const [version, pin] of Object.entries(PINS)) { + const file = vendorArchive(site, version); + const digest = sha256(readFileSync(file)); + if (digest !== pin) fail(`vendored hstack-v${version}.tar.gz is ${digest}, pin is ${pin}`); + console.log(`vendored ${version} ${digest}`); +} + +const first = buildReleaseArchive(root, CURRENT); +const second = buildReleaseArchive(root, CURRENT); +const firstHash = sha256(first); +const secondHash = sha256(second); +console.log(`determinism: first ${firstHash}`); +console.log(`determinism: second ${secondHash}`); +if (firstHash !== secondHash) fail("two archive builds differ"); +if (!PINS[CURRENT]) fail(`missing pin for ${CURRENT}`); +if (firstHash !== PINS[CURRENT]) fail(`build ${firstHash} does not match vendored ${CURRENT} pin ${PINS[CURRENT]}`); +console.log(`determinism: match ${firstHash}`); diff --git a/site/scripts/check-no-html.mjs b/site/scripts/check-no-html.mjs index 0e400d3..b5830a4 100644 --- a/site/scripts/check-no-html.mjs +++ b/site/scripts/check-no-html.mjs @@ -38,6 +38,8 @@ const urls = new Set([ "/dl/hstack-v0.1.0.tar.gz.sha256", "/dl/hstack-v0.2.0.tar.gz", "/dl/hstack-v0.2.0.tar.gz.sha256", + "/dl/hstack-v0.2.1.tar.gz", + "/dl/hstack-v0.2.1.tar.gz.sha256", ]); const external = new Set(); @@ -106,16 +108,23 @@ try { const { createHash } = await import("node:crypto"); const actual = createHash("sha256").update(archive).digest("hex"); if (actual !== digest) fail("hstack.tar.gz does not match its sha256"); - const current = Buffer.from(await (await fetch(`${base}/dl/hstack-v0.2.0.tar.gz`)).arrayBuffer()); - if (!current.equals(archive)) fail("latest alias is not hstack-v0.2.0.tar.gz"); + const current = Buffer.from(await (await fetch(`${base}/dl/hstack-v0.2.1.tar.gz`)).arrayBuffer()); + if (!current.equals(archive)) fail("latest alias is not hstack-v0.2.1.tar.gz"); const frozen = Buffer.from(await (await fetch(`${base}/dl/hstack-v0.1.0.tar.gz`)).arrayBuffer()); const frozenHash = createHash("sha256").update(frozen).digest("hex"); if (frozenHash !== "0c8009ee04fb31126b76e4202c91eee14e8d5be7d1cd42ac0cee551012eeda6e") { fail("hstack-v0.1.0.tar.gz bytes changed"); } - if (frozen.equals(archive)) fail("latest alias is still the 0.1.0 archive"); + const previous = Buffer.from(await (await fetch(`${base}/dl/hstack-v0.2.0.tar.gz`)).arrayBuffer()); + const previousHash = createHash("sha256").update(previous).digest("hex"); + if (previousHash !== "c16dfee4cd2841939a97a5cebf816c3fc3e8d71e28f2f1874639ea539ea20810") { + fail("hstack-v0.2.0.tar.gz bytes changed"); + } + if (frozen.equals(archive) || previous.equals(archive)) fail("latest alias still matches an older archive"); const frozenSum = await (await fetch(`${base}/dl/hstack-v0.1.0.tar.gz.sha256`)).text(); if (!frozenSum.startsWith(`${frozenHash} hstack-v0.1.0.tar.gz`)) fail("v0.1.0 checksum file drifted"); + const previousSum = await (await fetch(`${base}/dl/hstack-v0.2.0.tar.gz.sha256`)).text(); + if (!previousSum.startsWith(`${previousHash} hstack-v0.2.0.tar.gz`)) fail("v0.2.0 checksum file drifted"); const page = await (await fetch(`${base}/no-such-page`)).text(); if (!page.includes("