From 91672160c264733ca3f114e726eeeabf9e0c51e9 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 13 Oct 2026 15:41:43 +0300 Subject: [PATCH] release: add storefront artwork and draft completeness gate --- .github/workflows/release.yml | 52 +++ README.md | 73 ++-- build/background.png | Bin 0 -> 4785 bytes build/background@2x.png | Bin 0 -> 8925 bytes docs/assets/lh_banner_dark.svg | 37 ++ docs/assets/lh_banner_light.svg | 38 ++ docs/desktop-release.md | 27 +- electron-builder.yml | 12 + package.json | 2 +- scripts/verify-release.ts | 611 ++++++++++++++++++++++++++++++++ tests/verify-release.test.ts | 454 ++++++++++++++++++++++++ 11 files changed, 1276 insertions(+), 30 deletions(-) create mode 100644 build/background.png create mode 100644 build/background@2x.png create mode 100644 docs/assets/lh_banner_dark.svg create mode 100644 docs/assets/lh_banner_light.svg create mode 100644 scripts/verify-release.ts create mode 100644 tests/verify-release.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a26dbb5..a52e2f7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -183,3 +183,55 @@ jobs: dist/*.deb dist/*.blockmap dist/latest*.yml + + # The last gate before a human publishes: after ALL three packaging jobs + # have uploaded, fail the run unless the tag's single draft release holds + # exactly the 11 verified assets (4 installers + mac updater ZIP + their + # blockmaps + 3 latest*.yml feeds) and the feeds reference those uploads + # by exact name at the package.json version. Read-only: it never creates, + # edits, publishes, or deletes anything. Manual rehearsals upload nothing, + # so there is no draft to verify and the job stays skipped. + verify: + name: Verify draft release + needs: package + if: github.event_name == 'push' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + env: + GH_TOKEN: ${{ github.token }} + steps: + # The tagged revision — verify exactly what was packaged, not a moving default. + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + - uses: actions/setup-node@v4 + with: + node-version: "26" + cache: npm + - run: npm ci + - name: List every release, drafts included + # --slurp wraps the paginated pages into one JSON document + # ([[release,...],[release,...]]); the validator flattens it. + run: gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/releases?per_page=100" > "$RUNNER_TEMP/releases.json" + - name: Select the tag's single draft + # Machine JSON on stdout (release id + asset ids); warnings on stderr. + run: npx tsx scripts/verify-release.ts select "$RUNNER_TEMP/releases.json" --tag "$GITHUB_REF_NAME" > "$RUNNER_TEMP/draft.json" + - name: Download the three update feeds + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/metadata" + for name in latest-mac.yml latest.yml latest-linux.yml; do + id=$(DRAFT="$RUNNER_TEMP/draft.json" NAME="$name" node -e ' + const fs = require("node:fs"); + const draft = JSON.parse(fs.readFileSync(process.env.DRAFT, "utf8")); + const asset = draft.assets.find((entry) => entry.name === process.env.NAME); + if (asset == null) { console.error("draft has no " + process.env.NAME); process.exit(1); } + console.log(asset.id); + ') + gh api "repos/$GITHUB_REPOSITORY/releases/assets/$id" -H "Accept: application/octet-stream" > "$RUNNER_TEMP/metadata/$name" + echo "downloaded $name ($(wc -c < "$RUNNER_TEMP/metadata/$name") bytes)" + done + - name: Verify draft assets and update feeds + run: npx tsx scripts/verify-release.ts verify "$RUNNER_TEMP/releases.json" "$RUNNER_TEMP/metadata" --tag "$GITHUB_REF_NAME" diff --git a/README.md b/README.md index fdd4c03..ceaaaf8 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,65 @@ -# LearnHarness +

+ + + + LearnHarness + +

-A desktop AI tutor that teaches by questioning — one reasoning step at a time — and keeps everything you learn in a growing knowledge graph. +

An agent harness for learning.

-It runs on your machine, with your own API key. Your data stays in plain files you can open. +

+ Download · + How it works · + Documentation · + Issues +

-## The idea +LearnHarness uses AI agents to plan and teach lessons from your current understanding to your goal. It builds a detailed picture of what you understand and how you reason. That picture is updated as you learn. -Understanding has a dependency structure: some things require other things first. A lesson that ignores what you already hold is wasted on you; a lesson that outruns what you hold is lost on you. There is exactly one optimal path — the smallest set of steps from what you know to what you want to know — and finding it requires knowing precisely what you know. +Instead of just giving you an explanation, it uses your answers and thinking to decide what you need next. Teaching builds understanding one reasoning step at a time. Practice makes it usable. A persistent knowledge graph carries it across sessions. -So LearnHarness measures first. It questions you to bracket the edge of your understanding, plans the shortest path from that edge to your goal, then teaches it one reasoning step at a time — checking that each step lands before building on it. Every step is small enough to feel obvious on arrival. + -And it remembers. Everything taught accumulates into a single persistent structure — propositions and the reasoning chains that connect them — rendered as the graph that is the app's home view. The next session starts from what the last one built: no re-probing what you already hold, questions aimed at the actual boundary of your knowledge, learning that compounds. +## How it works -Teaching installs understanding; practice makes it available when you need it. Separate practice sessions draw problems from the same graph — retrieval at the edge of your ability, not review — because being taught something and having it come to mind in the right situation are different states. +1. **Measure.** Before teaching, the agent finds where your understanding ends on each part the lesson depends on. Your answers, confidence, and thinking show it where to start. +2. **Plan.** It works out a dependency map from that starting point to your goal. You can see the plan, and the lesson adapts when your understanding turns out different. +3. **Teach.** Each step builds on something you already understand. You predict, get an explanation, and check your understanding. Confusions are repaired before anything is built on them. The aim is for the goal to feel obviously true, not merely familiar. +4. **Practice.** Separate sessions pose varied problems that require the right knowledge to come to mind without being named first. Problems and hints adapt to your reasoning. -## Yours, locally +**Think out loud.** The thinking fields let you explain your reasoning, uncertainties, and reactions in your own words. This is how the agent gets a detailed picture rather than just a record of right and wrong answers. You can type or use dictation. -A system whose premise is that it can be trusted is built so that it can be checked: +The agent can also research questions in the background, create visual explanations, and build interactive demonstrations. -- **Your keys.** Bring your own ([OpenRouter](https://openrouter.ai/) works out of the box). No subscription, no markup — a live cost meter shows what each session actually spent. -- **Your machine.** Chats, graph, settings — plain files under your home directory. Nothing is sent anywhere except the model calls you configure. -- **Inspectable.** Every system prompt is visible and tunable. The graph is stored as reasoning chains — the structure of what you were taught, not a pile of text. +## Knowledge that carries forward -## Download +What you are taught accumulates as propositions and reasoning chains: individual claims and the premises that lead to them. The graph shows this structure, with links back to the source sessions. You can also import notes. -**[Releases](https://github.com/amosblomqvist/learnharness/releases)** +Future teaching sessions can read the graph instead of starting from scratch. Practice sessions can draw their material from it. It stores the ideas and their connections, not ratings of your ability. -- **macOS (Apple Silicon):** signed and notarized DMG. -- **Windows (x64):** installer. Currently unsigned; Windows may show an “unknown publisher” / SmartScreen warning. -- **Linux (x64):** AppImage (recommended) or Debian/Ubuntu `.deb`. + -All three platforms support in-app updates. AppImage updates need no administrator permission; `.deb` updates may request it. +## Download and get started -## Quick start +**[Download from learnharness.ai](https://learnharness.ai)** · [GitHub Releases](https://github.com/amosblomqvist/learnharness/releases) -1. Download your platform’s package: drag the macOS app to Applications, run the Windows installer, or make the Linux AppImage executable and open it (alternatively, install the `.deb`). -2. Open Settings, paste your OpenRouter API key, and pick your models. -3. Start a session and say what you want to understand. +- **macOS (Apple Silicon):** signed and notarized DMG. Drag the app to Applications. +- **Windows (x64):** run the installer. It is currently unsigned; Windows may show an “unknown publisher” or SmartScreen warning. +- **Linux (x64):** make the AppImage executable and open it, or install the Debian/Ubuntu `.deb`. -## Development +Add your provider API key and choose your models, then start a session with what you want to understand. LearnHarness supports 30+ provider options, including OpenAI, Anthropic, Google, DeepSeek, and OpenRouter. Optional dictation and web search use separate service keys. + +All three platforms support in-app updates. + +## Your keys, your data + +- **Your keys and models.** No LearnHarness subscription or markup. You pay your providers for usage; a live cost meter shows model costs. +- **Local storage.** Your chats, graph, settings, and API keys live on your machine. Learning records are plain files you can inspect. +- **External services.** Models run through your configured providers. Model requests send the context needed for the task; web research and dictation also contact external services. Local storage does not mean offline processing. +- **Inspectable behavior.** System prompts are visible and editable. The app is open source under the AGPL-3.0 license. + +## Development and support ```bash npm install @@ -46,9 +67,9 @@ npm run dev # http://localhost:3000 npm test # deterministic, no LLM, no network ``` -The architecture spec and full development guide live in [`docs/`](docs/) — start with [`docs/architecture.md`](docs/architecture.md) and [`docs/development.md`](docs/development.md). +See the [development guide](docs/development.md), [architecture](docs/architecture.md), and [desktop release guide](docs/desktop-release.md). -Questions or bugs: [GitHub Issues](https://github.com/amosblomqvist/learnharness/issues). +Questions, bug reports, and feature requests: [GitHub Issues](https://github.com/amosblomqvist/learnharness/issues). ## License diff --git a/build/background.png b/build/background.png new file mode 100644 index 0000000000000000000000000000000000000000..93bc6dae7ca1d01753efd4873c048ab869408059 GIT binary patch literal 4785 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 bZz#Rd!zayt + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/lh_banner_light.svg b/docs/assets/lh_banner_light.svg new file mode 100644 index 0000000..da05bd3 --- /dev/null +++ b/docs/assets/lh_banner_light.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/desktop-release.md b/docs/desktop-release.md index e6dd66f..59554ad 100644 --- a/docs/desktop-release.md +++ b/docs/desktop-release.md @@ -47,9 +47,30 @@ npx electron-builder --linux --x64 --publish never `.github/workflows/release.yml` has two deliberately separate modes: - **Manual / Run workflow:** unsigned rehearsal installers saved as workflow artifacts for 7 days. No signing secrets needed, no release created, and nothing published. macOS notarization is skipped. Use the Windows/Linux artifacts for the first human smoke pass. These are not production macOS installers. -- **Push `v`:** gates first, then one job creates or reuses a **single draft GitHub Release** before the three OS jobs package/upload their artifacts and update metadata in parallel. This avoids competing draft creation. A published or ambiguous existing release fails the preparation step instead of being modified. Separate runs for the same ref are serialized, never cancelled mid-upload. The tag must match the package version. macOS must sign and notarize successfully; missing secrets fail loudly. The workflow never publishes the draft. +- **Push `v`:** gates first, then one job creates or reuses a **single draft GitHub Release** before the three OS jobs package/upload their artifacts and update metadata in parallel. This avoids competing draft creation. A published or ambiguous existing release fails the preparation step instead of being modified. Separate runs for the same ref are serialized, never cancelled mid-upload. The tag must match the package version. macOS must sign and notarize successfully; missing secrets fail loudly. The workflow never publishes the draft. After all three packaging jobs succeed, a final read-only **Verify draft release** job (below) fails the run unless that draft is complete and self-consistent. -The three jobs upload distinct updater feeds: `latest-mac.yml`, `latest.yml`, `latest-linux.yml`, with the matching ZIP/NSIS/AppImage/deb assets and blockmaps. Do not publish a partially populated draft. The app uses the baked GitHub feed without a token: **the repository must be public before end users can download updates.** Making the repository public and publishing the release remain the owner's actions. +The three jobs upload distinct updater feeds: `latest-mac.yml`, `latest.yml`, `latest-linux.yml`, with the matching ZIP/NSIS/AppImage/deb assets and blockmaps. **A partially populated draft is never fit to publish — and the final job enforces it mechanically.** The app uses the baked GitHub feed without a token: **the repository must be public before end users can download updates.** Making the repository public and publishing the release remain the owner's actions. + +### The verification job + +`verify` runs only on tag pushes (`needs: package`, so after **all three** packaging legs; manual rehearsals upload nothing and skip it). With a read-only `contents: read` token it checks out the tagged revision and runs `scripts/verify-release.ts`, which: + +- finds the draft using the same tag matching as the draft job and electron-builder's publisher — `tag_name` equals `v` **or** the bare ``; zero matches, two matches, or an already-published match fail the run, and a draft matched only via the unprefixed form is flagged with a warning (a later run could create a second draft under the `v`-tag and split the assets — rename before publishing); +- fails unless the release holds **exactly** these 11 assets — nothing missing, nothing extra (a stale name from an older run is an error), nothing zero-size, nothing still in a non-`uploaded` state: + +| Asset | Role | +|---|---| +| `LearnHarness--arm64.dmg` + `.blockmap` | macOS installer | +| `LearnHarness--arm64-mac.zip` + `.blockmap` | macOS updater payload (Squirrel.Mac installs from the ZIP) | +| `LearnHarness--x64-setup.exe` + `.blockmap` | Windows NSIS installer | +| `LearnHarness--x86_64.AppImage` | Linux AppImage (no sidecar blockmap) | +| `LearnHarness--amd64.deb` | Linux deb (no sidecar blockmap) | +| `latest-mac.yml` / `latest.yml` / `latest-linux.yml` | updater feeds | + + (The Linux names are deliberate: the user-set `linux.artifactName` applies to both Linux targets, and `${arch}` expands per target — `x86_64` inside the AppImage, `amd64` in the deb — exactly what the installed app-builder-lib 26.15.3 emits. The script's expectations fail closed if `electron-builder.yml` drifts from these shapes.); +- downloads the three feeds from the draft and fails unless each parses, carries the `package.json` version, and references **exactly** the uploaded assets — by bare file name only (no paths, traversal, or URLs), with sizes matching the API-reported asset sizes, the ZIP first in `latest-mac.yml`, and the legacy top-level `path`/`sha512` backed by that first entry. + +The job only reads (release list + asset downloads); it never creates, edits, publishes, or deletes anything. ## macOS CI secrets @@ -71,7 +92,7 @@ The workflow maps the certificate secrets to electron-builder's `CSC_LINK` / `CS 2. Human smoke on Windows/Linux: install/open, window buttons and dragging, sidebar at narrow widths, zoom, setup/keys, session/resume, native PDF/SVG verification and demo helper. macOS remains signed/notarized through the production path. 3. Add macOS signing/notarization secrets. 4. Bump `package.json` and the lockfile to the agreed release version; run local gates and push a matching `v...` tag. -5. Wait for **all three** packaging jobs. Verify all installer assets and updater feeds are in the draft. +5. Wait for **all three** packaging jobs **and the final Verify draft release job** — the gate mechanically checks the draft's 11 assets and the three update feeds (single draft, right names/version/sizes/upload state, feeds referencing only what the release actually holds). If it fails, fix the cause and re-run the workflow before publishing; electron-builder replaces same-named assets on re-upload, and the gate rejects leftovers. 6. Owner makes the repository public, publishes the complete draft, then checks the real GitHub update path using a prior installed version. A full human pass is required before the first three-platform release. Subsequent content-only updates rely on the deterministic suite plus the per-OS build/boot-smoke matrix. Changes to `electron/`, native modules, packaging/updater configuration, or Electron itself call for another platform smoke pass. diff --git a/electron-builder.yml b/electron-builder.yml index 5c91e18..ff2c659 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -128,6 +128,18 @@ mac: notarize: true dmg: title: "${productName} ${version}" + # Owner artwork: 540×380 at 1×; the sibling background@2x.png is + # combined into a Retina TIFF by dmg-builder. Centres use 1× coordinates. + background: build/background.png + iconSize: 80 + contents: + - x: 130 + y: 190 + type: file + - x: 410 + y: 190 + type: link + path: /Applications afterPack: scripts/after-pack.cjs win: icon: build/icon.ico diff --git a/package.json b/package.json index c4c1e69..0a77ccd 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ }, "homepage": "https://github.com/amosblomqvist/learnharness", "desktopName": "learnharness.desktop", - "description": "A standalone learning harness: a teacher agent (AI SDK) with quiz/ask interactions, session artifacts, and a derived knowledge graph of what the learner knows.", + "description": "An agent harness for learning: adaptive teaching, practice, and a persistent knowledge graph.", "type": "module", "private": true, "main": "electron/main.cjs", diff --git a/scripts/verify-release.ts b/scripts/verify-release.ts new file mode 100644 index 0000000..428a3a7 --- /dev/null +++ b/scripts/verify-release.ts @@ -0,0 +1,611 @@ +/** + * verify-release.ts — the final desktop-release gate (2026-10-13). + * + * Runs in the release workflow's LAST job, after all three packaging jobs + * have uploaded to the tag's single draft GitHub Release, and fails the + * workflow unless that draft holds exactly what end users and + * electron-updater need. The workflow never publishes; this script only + * reads (release list via the workflow's read-only token, update feeds + * downloaded by the workflow's own gh steps). + * + * Every expected file name below was verified against the INSTALLED + * builder (app-builder-lib 26.15.3) — these are the artifacts it actually + * emits for this repo's electron-builder.yml, not pattern guesses: + * + * mac (dmg + zip, arm64; no user artifactName there): + * dmg LearnHarness--arm64.dmg + .blockmap (createBlockmap) + * zip LearnHarness--arm64-mac.zip + .blockmap (createBlockmap; + * Squirrel.Mac updates from the ZIP — it IS the updater payload) + * feed latest-mac.yml — files[0] is the ZIP (writeUpdateInfoFiles + * sorts zip first) and the legacy top-level path/sha512 point + * at it. + * win (nsis x64; artifactName "${productName}-${version}-${arch}-setup.${ext}"): + * exe LearnHarness--x64-setup.exe + .blockmap (createBlockmap) + * feed latest.yml — a single file entry, the exe. + * linux (AppImage + deb, x64; artifactName "${productName}-${version}-${arch}.${ext}" + * is user-forced at the linux level, so it overrides BOTH targets' + * default name formats, and ${arch} expands per target ext: + * x64→x86_64 for AppImage, x64→amd64 for deb): + * image LearnHarness--x86_64.AppImage (no blockmap in 26.15.3) + * deb LearnHarness--amd64.deb (no blockmap) + * feed latest-linux.yml — the AppImage and deb entries merged by + * writeUpdateInfoFiles (order not deterministic; checked as a set). + * + * GitHub asset names equal these file names (all are already "safe" GitHub + * names, so electron-publish uploads under the plain name) and every yml + * files[].url equals the uploaded asset name. + * + * Draft selection is FAIL-CLOSED and replicates the existing matching used + * by the workflow's draft job and electron-builder's GitHub publisher: + * tag_name equals the tag ("v0.2.0") OR the bare version ("0.2.0"). Zero + * matches, 2+ matches, or a published match → fail. A draft matched only + * via the bare unprefixed form is a reported hazard: electron-builder + * matches both spellings, so a later run could create a SECOND draft under + * the v-tag and split the assets across two releases. + * + * CLI (cwd = the checked-out tag revision): + * tsx scripts/verify-release.ts select --tag + * → one JSON object on stdout: {releaseId, matchedTagName, warnings, + * assets:[{id,name,size,state}]}; diagnostics on stderr. Used by the + * workflow to locate the draft and its asset ids. + * tsx scripts/verify-release.ts verify --tag + * → full gate; human report on stdout; exit 0 only when everything + * below passes. + * + * The gate checks, and fails closed on any of: + * - tag ≠ "v" + package.json version, or a malformed release list; + * - electron-builder.yml/package.json drifting from the shapes the + * expected names were derived from (update this file's expectations + * when the packaging config changes — deliberately strict); + * - draft selection problems (above); + * - expected assets missing, zero-size, not in state "uploaded", + * duplicated, or unexpected extras (stale names from an older run); + * - update feeds that do not parse, carry the wrong version, reference + * a path/URL instead of a bare asset name (traversal / untrusted + * origin), reference an asset the release does not hold, disagree + * with the API-reported asset size, lack/misplace entries (mac ZIP + * must be first and back the legacy path/sha512), or carry an + * implausible sha512. + */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; + +const require = createRequire(import.meta.url); +const yaml = require("js-yaml") as { load(source: string): unknown }; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type ReleaseAsset = { id: number; name: string; size: number; state: string }; +export type ReleaseRecord = { id: number; tag_name: string; draft: boolean; assets: ReleaseAsset[] }; + +export type Failures = { ok: false; errors: string[] }; +export type DraftSelection = + | { ok: true; release: ReleaseRecord; warnings: string[] } + | Failures; + +/** The full expected asset inventory for one version (11 files). */ +export type ExpectedAssets = { + version: string; + mac: { dmg: string; zip: string; feed: string }; + win: { exe: string; feed: string }; + linux: { appImage: string; deb: string; feed: string }; + /** Every asset name the draft must hold, and no others. */ + files: string[]; +}; + +type Issue = string; +type Issues = Issue[]; + +// --------------------------------------------------------------------------- +// Version + expected assets +// --------------------------------------------------------------------------- + +/** Verified product name spelled into every artifact (electron-builder.yml productName). */ +const PRODUCT_NAME = "LearnHarness"; + +const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/; + +export function checkVersion(version: unknown): { ok: true; version: string } | { ok: false; errors: Issues } { + if (typeof version !== "string" || !VERSION_PATTERN.test(version)) { + return { ok: false, errors: [`package.json version must be a plain semver string, got ${JSON.stringify(version)}`] }; + } + return { ok: true, version }; +} + +/** The exact asset set the installed builder emits for this repo's config. */ +export function expectedAssets(version: string): ExpectedAssets { + const mac = { + dmg: `${PRODUCT_NAME}-${version}-arm64.dmg`, + zip: `${PRODUCT_NAME}-${version}-arm64-mac.zip`, + feed: "latest-mac.yml", + }; + const win = { + exe: `${PRODUCT_NAME}-${version}-x64-setup.exe`, + feed: "latest.yml", + }; + const linux = { + appImage: `${PRODUCT_NAME}-${version}-x86_64.AppImage`, + deb: `${PRODUCT_NAME}-${version}-amd64.deb`, + feed: "latest-linux.yml", + }; + // Sidecar differential-update blockmaps: emitted for dmg, mac zip and + // NSIS exe (createBlockmap); AppImage/deb have none in app-builder-lib 26.15.3. + const blockmaps = [mac.dmg, mac.zip, win.exe].map((name) => `${name}.blockmap`); + return { + version, + mac, + win, + linux, + files: [mac.dmg, mac.zip, win.exe, linux.appImage, linux.deb, ...blockmaps, mac.feed, win.feed, linux.feed], + }; +} + +// --------------------------------------------------------------------------- +// Release-list parsing (fail closed on any malformed entry) +// --------------------------------------------------------------------------- + +/** + * `gh api --paginate --slurp` emits an array of pages ([[...], [...]]); a + * hand-made single page is a plain array. Both are accepted, everything + * else fails. + */ +export function normalizeReleasePages(input: unknown): { ok: true; releases: unknown[] } | { ok: false; errors: Issues } { + if (!Array.isArray(input)) { + return { ok: false, errors: ["release list is not a JSON array"] }; + } + const pages = input.length > 0 && Array.isArray(input[0]) ? input : [input]; + return { ok: true, releases: pages.flat() }; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function parseReleases(entries: unknown[]): { ok: true; releases: ReleaseRecord[] } | { ok: false; errors: Issues } { + const errors: Issues = []; + const releases: ReleaseRecord[] = []; + entries.forEach((entry, index) => { + const label = `releases[${index}]`; + if (!isPlainObject(entry)) { + errors.push(`${label}: not an object`); + return; + } + const id = entry.id; + const tagName = entry.tag_name; + const draft = entry.draft; + const assets = entry.assets; + if (typeof id !== "number" || !Number.isFinite(id)) errors.push(`${label}: id is not a number`); + if (typeof tagName !== "string" || tagName === "") errors.push(`${label}: tag_name is not a non-empty string`); + if (typeof draft !== "boolean") errors.push(`${label}: draft is not a boolean`); + const parsedAssets: ReleaseAsset[] = []; + if (!Array.isArray(assets)) { + errors.push(`${label}: assets is not an array`); + } else { + assets.forEach((asset, assetIndex) => { + const assetLabel = `${label}.assets[${assetIndex}]`; + if (!isPlainObject(asset)) { + errors.push(`${assetLabel}: not an object`); + return; + } + const { id: assetId, name, size, state } = asset; + const assetOk = + typeof assetId === "number" && Number.isFinite(assetId) && + typeof name === "string" && name !== "" && + typeof size === "number" && Number.isFinite(size) && + typeof state === "string" && state !== ""; + if (!assetOk) { + errors.push(`${assetLabel}: needs numeric id, non-empty string name, numeric size, non-empty string state`); + return; + } + parsedAssets.push({ id: assetId, name, size, state }); + }); + } + if (typeof id === "number" && typeof tagName === "string" && tagName !== "" && typeof draft === "boolean") { + releases.push({ id, tag_name: tagName, draft, assets: parsedAssets }); + } + }); + if (errors.length > 0) return { ok: false, errors }; + return { ok: true, releases }; +} + +// --------------------------------------------------------------------------- +// Draft selection (replicates the draft job's + builder's matching, fail-closed) +// --------------------------------------------------------------------------- + +export function selectDraft(releases: ReleaseRecord[], tag: string, version: string): DraftSelection { + const matches = releases.filter((release) => release.tag_name === tag || release.tag_name === version); + if (matches.length === 0) { + return { + ok: false, + errors: [ + `no release matches "${tag}" or "${version}" — the draft job should have prepared exactly one draft; refusing to pass`, + ], + }; + } + if (matches.length > 1) { + const list = matches.map((release) => `#${release.id} (${release.tag_name}, ${release.draft ? "draft" : "published"})`).join(", "); + return { ok: false, errors: [`${matches.length} releases match ${tag}: ${list} — resolve to exactly one before publishing`] }; + } + const [match] = matches; + if (!match) return { ok: false, errors: ["internal: empty match"] }; + if (!match.draft) { + return { + ok: false, + errors: [`release #${match.id} (${match.tag_name}) is already published — the workflow never modifies a published release`], + }; + } + const warnings: string[] = []; + if (match.tag_name !== tag) { + warnings.push( + `draft #${match.id} carries the unprefixed tag "${match.tag_name}" instead of "${tag}": electron-builder matches both spellings, ` + + `so a future run could create a SECOND draft under "${tag}" and split the assets — rename the release to "${tag}" before publishing`, + ); + } + return { ok: true, release: match, warnings }; +} + +// --------------------------------------------------------------------------- +// Packaging-config drift guard +// --------------------------------------------------------------------------- + +/** Names below were derived from these exact config shapes; drift means the + * expectations in this file must be re-derived against the new builder + * output. Failing here is deliberate: a silently stale gate is worse. */ +export function verifyConfigAssumptions(config: unknown, pkg: unknown): { ok: true } | { ok: false; errors: Issues } { + const errors: Issues = []; + const bad = (message: string) => errors.push(message); + if (!isPlainObject(pkg) || typeof pkg.version !== "string") bad("package.json has no string version"); + if (!isPlainObject(pkg) || pkg.name !== "learnharness") bad("package.json name is no longer \"learnharness\""); + if (!isPlainObject(config)) { + return { ok: false, errors: ["electron-builder.yml did not parse to a mapping", ...errors] }; + } + if (config.productName !== PRODUCT_NAME) bad(`productName is ${JSON.stringify(config.productName)}, expected "${PRODUCT_NAME}"`); + if (config.artifactName != null) bad("a top-level artifactName now overrides target defaults — expected names no longer hold"); + const sameTargets = (actual: unknown, expected: Array>) => + JSON.stringify(actual) === JSON.stringify(expected); + if (!isPlainObject(config.mac)) bad("mac section missing"); + else { + if (config.mac.artifactName != null) bad("mac.artifactName set — expected names no longer hold"); + if (!sameTargets(config.mac.target, [{ target: "dmg", arch: ["arm64"] }, { target: "zip", arch: ["arm64"] }])) { + bad(`mac.target changed (${JSON.stringify(config.mac.target)}) — expected names no longer hold`); + } + } + if (!isPlainObject(config.win)) bad("win section missing"); + else { + if (config.win.artifactName !== "${productName}-${version}-${arch}-setup.${ext}") { + bad(`win.artifactName changed (${JSON.stringify(config.win.artifactName)}) — expected names no longer hold`); + } + if (!sameTargets(config.win.target, [{ target: "nsis", arch: ["x64"] }])) { + bad(`win.target changed (${JSON.stringify(config.win.target)}) — expected names no longer hold`); + } + } + if (!isPlainObject(config.linux)) bad("linux section missing"); + else { + if (config.linux.artifactName !== "${productName}-${version}-${arch}.${ext}") { + bad(`linux.artifactName changed (${JSON.stringify(config.linux.artifactName)}) — expected names no longer hold`); + } + if (!sameTargets(config.linux.target, [{ target: "AppImage", arch: ["x64"] }, { target: "deb", arch: ["x64"] }])) { + bad(`linux.target changed (${JSON.stringify(config.linux.target)}) — expected names no longer hold`); + } + } + const publish = Array.isArray(config.publish) ? config.publish[0] : undefined; + if (!isPlainObject(publish) || publish.provider !== "github" || publish.releaseType !== "draft") { + bad(`publish[0] must be the github provider with releaseType "draft" (${JSON.stringify(config.publish)}) — uploads would not land in the draft`); + } + if (errors.length > 0) { + errors.push("packaging config drifted from the verified shapes — re-derive the expected assets in scripts/verify-release.ts"); + return { ok: false, errors }; + } + return { ok: true }; +} + +// --------------------------------------------------------------------------- +// Asset inventory +// --------------------------------------------------------------------------- + +export function verifyAssets(release: ReleaseRecord, expected: ExpectedAssets): { ok: true } | { ok: false; errors: Issues } { + const errors: Issues = []; + const seen = new Map(); + for (const asset of release.assets) { + if (seen.has(asset.name)) errors.push(`duplicate asset name on the release: ${asset.name}`); + else seen.set(asset.name, asset); + // Checked for EVERY entry, duplicates included — fail closed. + if (asset.state !== "uploaded") errors.push(`${asset.name}: state is "${asset.state}", not "uploaded"`); + if (!(asset.size > 0)) errors.push(`${asset.name}: zero-size asset (size=${asset.size})`); + if (!expected.files.includes(asset.name)) { + errors.push(`${asset.name}: unexpected asset — not in the expected set (stale name from an older run?)`); + } + } + for (const name of expected.files) { + if (!seen.has(name)) errors.push(`${name}: missing from the release`); + } + if (errors.length > 0) return { ok: false, errors }; + return { ok: true }; +} + +// --------------------------------------------------------------------------- +// Update-feed (latest*.yml) verification +// --------------------------------------------------------------------------- + +type FeedExpectation = { + file: string; + /** Every files[].url the feed must reference — exactly these, no more. */ + urls: string[]; + /** If set, files[0].url must be this entry (mac: the updater ZIP). */ + first?: string; +}; + +export function feedExpectations(expected: ExpectedAssets): FeedExpectation[] { + return [ + { file: expected.mac.feed, urls: [expected.mac.zip, expected.mac.dmg], first: expected.mac.zip }, + { file: expected.win.feed, urls: [expected.win.exe] }, + { file: expected.linux.feed, urls: [expected.linux.appImage, expected.linux.deb] }, + ]; +} + +/** An updater reference must be a bare file name: no directories, no + * traversal, no URI scheme, no query/fragment. Anything else is a path the + * updater could be steered with. */ +export function isPlainAssetName(url: unknown): url is string { + return typeof url === "string" && url !== "" && url !== "." && url !== ".." && + !url.includes("/") && !url.includes("\\") && !url.includes(":") && !url.includes("?") && !url.includes("#") && !url.startsWith("~"); +} + +const SHA512_BASE64 = /^[A-Za-z0-9+/]{80,}={0,2}$/; + +type FeedFileEntry = { url: string; sha512: string; size: number }; + +export function verifyMetadataFiles( + files: Record, + release: ReleaseRecord, + expected: ExpectedAssets, +): { ok: true } | { ok: false; errors: Issues } { + const errors: Issues = []; + const byName = new Map(release.assets.map((asset) => [asset.name, asset])); + const expectations = feedExpectations(expected); + + for (const expectation of expectations) { + const content = files[expectation.file]; + if (content == null) { + errors.push(`${expectation.file}: not downloaded (or empty) — cannot verify the update feed`); + continue; + } + let info: unknown; + try { + info = yaml.load(content); + } catch (error) { + errors.push(`${expectation.file}: does not parse as YAML (${String(error)})`); + continue; + } + if (!isPlainObject(info)) { + errors.push(`${expectation.file}: top level is not a mapping`); + continue; + } + if (info.version !== expected.version) { + errors.push(`${expectation.file}: version is ${JSON.stringify(info.version)}, expected "${expected.version}"`); + } + if (!Array.isArray(info.files) || info.files.length === 0) { + errors.push(`${expectation.file}: no files[] entries`); + continue; + } + const entries: FeedFileEntry[] = []; + let malformed = false; + info.files.forEach((entry, index) => { + if (!isPlainObject(entry)) { + errors.push(`${expectation.file}: files[${index}] is not a mapping`); + malformed = true; + return; + } + const { url, sha512, size } = entry; + if (!isPlainAssetName(url)) { + errors.push(`${expectation.file}: files[${index}].url is not a bare asset name (${JSON.stringify(entry.url)}) — traversal or untrusted origin`); + malformed = true; + return; + } + if (typeof sha512 !== "string" || !SHA512_BASE64.test(sha512)) { + errors.push(`${expectation.file}: files[${index}] (${entry.url}): sha512 is not a base64 digest`); + malformed = true; + return; + } + if (typeof size !== "number" || !Number.isInteger(size) || size <= 0) { + errors.push(`${expectation.file}: files[${index}] (${entry.url}): size must be a positive integer`); + malformed = true; + return; + } + entries.push({ url, sha512, size }); + }); + if (malformed) continue; + const urls = entries.map((entry) => entry.url); + for (const url of urls) { + const asset = byName.get(url); + if (asset == null) { + errors.push(`${expectation.file}: references "${url}" which is not an asset of release #${release.id}`); + } else if (asset.size !== entries.find((entry) => entry.url === url)?.size) { + errors.push(`${expectation.file}: "${url}" size ${entries.find((entry) => entry.url === url)?.size} ≠ release asset size ${asset.size}`); + } + } + const expectedSet = [...expectation.urls].sort().join("\n"); + const actualSet = [...urls].sort().join("\n"); + if (actualSet !== expectedSet) { + errors.push(`${expectation.file}: references [${urls.join(", ")}] but must reference exactly [${expectation.urls.join(", ")}]`); + } + if (expectation.first != null && urls[0] !== expectation.first) { + errors.push(`${expectation.file}: files[0] must be "${expectation.first}" (got "${urls[0] ?? "nothing"}")`); + } + // Legacy single-file fields electron-updater still reads on some paths: + // the builder backs them with files[0]. + const first = entries[0]; + if (first) { + if (info.path !== first.url) { + errors.push(`${expectation.file}: top-level path is ${JSON.stringify(info.path)}, must be "${first.url}"`); + } + if (info.sha512 !== first.sha512) { + errors.push(`${expectation.file}: top-level sha512 does not match files[0] (${first.url})`); + } + } + } + if (errors.length > 0) return { ok: false, errors }; + return { ok: true }; +} + +// --------------------------------------------------------------------------- +// CLI (thin: parse args, read files, run the pure core, print, exit) +// --------------------------------------------------------------------------- + +function usage(): string { + return [ + "usage:", + " verify-release.ts select --tag ", + " verify-release.ts verify --tag ", + ].join("\n"); +} + +function readJson(file: string): { ok: true; value: unknown } | { ok: false; error: string } { + let source: string; + try { + source = fs.readFileSync(file, "utf8"); + } catch (error) { + return { ok: false, error: `cannot read ${file}: ${String(error)}` }; + } + try { + return { ok: true, value: JSON.parse(source) }; + } catch (error) { + return { ok: false, error: `${file} is not valid JSON: ${String(error)}` }; + } +} + +function loadReleases(file: string): { ok: true; releases: ReleaseRecord[] } | { ok: false; errors: Issues } { + const json = readJson(file); + if (!json.ok) return { ok: false, errors: [json.error] }; + const normalized = normalizeReleasePages(json.value); + if (!normalized.ok) return normalized; + return parseReleases(normalized.releases); +} + +/** Reads package.json (version) + electron-builder.yml from the cwd — the + * workflow checks out the tagged revision before running the gate. */ +function loadRepoConfig(): { ok: true; pkg: unknown; config: unknown } | { ok: false; errors: Issues } { + const pkg = readJson("package.json"); + if (!pkg.ok) return { ok: false, errors: [pkg.error] }; + let configSource: string; + try { + configSource = fs.readFileSync("electron-builder.yml", "utf8"); + } catch (error) { + return { ok: false, errors: [`cannot read electron-builder.yml: ${String(error)}`] }; + } + try { + return { ok: true, pkg: pkg.value, config: yaml.load(configSource) }; + } catch (error) { + return { ok: false, errors: [`electron-builder.yml does not parse as YAML: ${String(error)}`] }; + } +} + +function fail(errors: Issues): number { + for (const error of errors) console.error(`ERROR: ${error}`); + console.error("FAILED: the draft release gate found problems — do not publish; fix and re-run the release workflow."); + return 1; +} + +function parseArgs(argv: string[]): { ok: true; command: string; positional: string[]; tag?: string } | { ok: false; errors: Issues } { + const positional: string[] = []; + let tag: string | undefined; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]!; + if (arg === "--tag") { + tag = argv[++i]; + if (tag == null || tag === "") return { ok: false, errors: ["--tag needs a value"] }; + } else { + positional.push(arg); + } + } + const [command] = positional; + if (command !== "select" && command !== "verify") return { ok: false, errors: [usage()] }; + return { ok: true, command, positional: positional.slice(1), tag }; +} + +/** Tag ↔ version agreement + release-list load, shared by both commands. */ +function startGate(argv: string[]): + | { ok: true; tag: string; version: string; releases: ReleaseRecord[]; config: unknown; pkg: unknown; positional: string[] } + | { ok: false; errors: Issues } { + const parsed = parseArgs(argv); + if (!parsed.ok) return parsed; + const releasesFile = parsed.positional[0]; + if (parsed.tag == null) return { ok: false, errors: ["--tag is required (the pushed tag, e.g. v0.2.0)"] }; + if (releasesFile == null) return { ok: false, errors: [usage()] }; + const repo = loadRepoConfig(); + if (!repo.ok) return repo; + const versioned = checkVersion(isPlainObject(repo.pkg) ? repo.pkg.version : undefined); + if (!versioned.ok) return versioned; + const expectedTag = `v${versioned.version}`; + if (parsed.tag !== expectedTag) { + return { ok: false, errors: [`tag ${parsed.tag} does not equal "v" + package.json version ("${expectedTag}")`] }; + } + const releases = loadReleases(releasesFile); + if (!releases.ok) return releases; + return { + ok: true, + tag: parsed.tag, + version: versioned.version, + releases: releases.releases, + config: repo.config, + pkg: repo.pkg, + positional: parsed.positional, + }; +} + +function main(argv: string[]): number { + const start = startGate(argv); + if (!start.ok) return fail(start.errors); + const expected = expectedAssets(start.version); + + if (argv[0] === "select") { + const selection = selectDraft(start.releases, start.tag, start.version); + if (!selection.ok) return fail(selection.errors); + for (const warning of selection.warnings) console.error(`WARNING: ${warning}`); + const report = { + releaseId: selection.release.id, + tag: start.tag, + matchedTagName: selection.release.tag_name, + warnings: selection.warnings, + assets: selection.release.assets.map((asset) => ({ id: asset.id, name: asset.name, size: asset.size, state: asset.state })), + }; + process.stdout.write(`${JSON.stringify(report, null, "\t")}\n`); + return 0; + } + + // verify + const metadataDir = start.positional[1]; + if (metadataDir == null) return fail([usage()]); + const errors: Issues = []; + const assumptions = verifyConfigAssumptions(start.config, start.pkg); + if (!assumptions.ok) errors.push(...assumptions.errors); + const selection = selectDraft(start.releases, start.tag, start.version); + if (!selection.ok) return fail([...errors, ...selection.errors]); + console.log(`draft release #${selection.release.id} — tag "${selection.release.tag_name}"`); + for (const warning of selection.warnings) console.log(`WARNING: ${warning}`); + const assets = verifyAssets(selection.release, expected); + if (!assets.ok) errors.push(...assets.errors); + const files: Record = {}; + for (const name of [expected.mac.feed, expected.win.feed, expected.linux.feed]) { + try { + files[name] = fs.readFileSync(path.join(metadataDir, name), "utf8"); + } catch { + // verifyMetadataFiles reports the missing feed by name. + } + } + const metadata = verifyMetadataFiles(files, selection.release, expected); + if (!metadata.ok) errors.push(...metadata.errors); + if (errors.length > 0) return fail(errors); + const present = expected.files.filter((name) => selection.release.assets.some((asset) => asset.name === name)); + console.log(`assets: ${present.length}/${expected.files.length} expected files present, non-zero size, and "uploaded"`); + console.log(`feeds: ${expected.mac.feed}, ${expected.win.feed}, ${expected.linux.feed} parse, carry version ${start.version}, and reference only uploaded assets by exact name`); + console.log(`OK: draft for ${start.tag} is complete and consistent — ready for the owner to review and publish.`); + return 0; +} + +const invokedAsCli = process.argv[1] != null && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href; +if (invokedAsCli) process.exitCode = main(process.argv.slice(2)); diff --git a/tests/verify-release.test.ts b/tests/verify-release.test.ts new file mode 100644 index 0000000..899ca56 --- /dev/null +++ b/tests/verify-release.test.ts @@ -0,0 +1,454 @@ +/** + * The final release gate (scripts/verify-release.ts) — pure-function tests + * plus CLI end-to-end runs, all offline against fixtures. The expected asset + * names asserted here are the ones verified against the INSTALLED + * app-builder-lib 26.15.3 (see the script header for the derivation): + * 11 assets — 4 installers (dmg / NSIS exe / AppImage / deb), the mac + * updater ZIP, its/exe/dmg blockmaps, and the three latest*.yml feeds. + */ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import { createRequire } from "node:module"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, it } from "node:test"; + +import { + checkVersion, expectedAssets, feedExpectations, isPlainAssetName, normalizeReleasePages, + parseReleases, selectDraft, verifyAssets, verifyConfigAssumptions, verifyMetadataFiles, + type ReleaseAsset, type ReleaseRecord, +} from "../scripts/verify-release.ts"; + +const require = createRequire(import.meta.url); +const repoRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url))); +const script = path.join(repoRoot, "scripts", "verify-release.ts"); +const VERSION = "0.2.0"; +const TAG = `v${VERSION}`; + +/** A base64-looking sha512 (the gate checks shape, not the actual digest). */ +const sha512 = (seed: string) => Buffer.from(seed.padEnd(64, "0")).toString("base64"); + +function asset(id: number, name: string, overrides: Partial = {}): ReleaseAsset { + return { id, name, size: 5000, state: "uploaded", ...overrides }; +} + +/** The complete, healthy 11-asset draft for a version. */ +function healthyRelease(version: string, tagForm: "v" | "bare" = "v"): ReleaseRecord { + const e = expectedAssets(version); + return { + id: 42, + tag_name: tagForm === "v" ? `v${version}` : version, + draft: true, + assets: e.files.map((name, index) => asset(1000 + index, name)), + }; +} + +/** The three update feeds exactly as app-builder-lib writes them. */ +function healthyFeeds(version: string, release: ReleaseRecord): Record { + const byName = new Map(release.assets.map((entry) => [entry.name, entry])); + const feeds: Record = {}; + for (const expectation of feedExpectations(expectedAssets(version))) { + const files = expectation.urls.map((url) => { + const found = byName.get(url); + assert.ok(found, `fixture needs ${url}`); + return ` - url: ${url}\n sha512: ${sha512(url)}\n size: ${found.size}\n`; + }); + const first = expectation.urls[0]!; + feeds[expectation.file] = + `version: ${version}\nfiles:\n${files.join("")}` + + `path: ${first}\nsha512: ${sha512(first)}\nreleaseDate: '2026-10-13T09:37:13.265Z'\n`; + } + return feeds; +} + +describe("release gate — expected asset inventory", () => { + it("is exactly the 11 verified builder outputs for the version", () => { + assert.deepEqual(expectedAssets(VERSION).files, [ + "LearnHarness-0.2.0-arm64.dmg", + "LearnHarness-0.2.0-arm64-mac.zip", + "LearnHarness-0.2.0-x64-setup.exe", + "LearnHarness-0.2.0-x86_64.AppImage", + "LearnHarness-0.2.0-amd64.deb", + "LearnHarness-0.2.0-arm64.dmg.blockmap", + "LearnHarness-0.2.0-arm64-mac.zip.blockmap", + "LearnHarness-0.2.0-x64-setup.exe.blockmap", + "latest-mac.yml", + "latest.yml", + "latest-linux.yml", + ]); + }); + + it("accepts plain semver versions and nothing else", () => { + assert.ok(checkVersion("1.2.3").ok); + assert.ok(checkVersion("1.2.3-beta.1").ok); + assert.ok(!checkVersion("1.2").ok); + assert.ok(!checkVersion("0.2.0 ; rm -rf").ok); + assert.ok(!checkVersion(undefined).ok); + }); +}); + +describe("release gate — release-list parsing", () => { + it("flattens gh --paginate --slurp pages and accepts a plain array", () => { + const release = healthyRelease(VERSION); + const paged = normalizeReleasePages([[release], [], [release]]); + assert.ok(paged.ok); + assert.deepEqual(paged.releases, [release, release]); + const plain = normalizeReleasePages([release]); + assert.ok(plain.ok); + assert.deepEqual(plain.releases, [release]); + }); + + it("fails closed on a non-array document", () => { + assert.ok(!normalizeReleasePages({ message: "Bad credentials" }).ok); + }); + + it("rejects malformed release entries and assets", () => { + const bad = [{ id: 1, tag_name: "v0.2.0", draft: "yes", assets: [] }]; + assert.ok(!parseReleases(bad).ok); + const badAssets = [{ id: 1, tag_name: "v0.2.0", draft: true, assets: [{ id: 1, name: "x", size: 1, state: "" }] }]; + assert.ok(!parseReleases(badAssets).ok); + assert.ok(parseReleases([healthyRelease(VERSION)]).ok); + }); +}); + +describe("release gate — draft selection (fail-closed, existing tag matching)", () => { + it("selects the single draft carrying the v-tag", () => { + const selection = selectDraft([healthyRelease(VERSION)], TAG, VERSION); + assert.ok(selection.ok && selection.release.id === 42 && selection.warnings.length === 0); + }); + + it("still matches the legacy unprefixed tag form but reports the hazard", () => { + const selection = selectDraft([healthyRelease(VERSION, "bare")], TAG, VERSION); + assert.ok(selection.ok); + assert.equal(selection.warnings.length, 1); + assert.match(selection.warnings[0]!, /unprefixed tag "0.2.0"/); + assert.match(selection.warnings[0]!, /SECOND draft/); + }); + + it("fails when nothing matches", () => { + const selection = selectDraft([healthyRelease("0.1.0")], TAG, VERSION); + assert.ok(!selection.ok); + assert.match(selection.errors.join("\n"), /no release matches/); + }); + + it("fails when the match is already published — never modifies it", () => { + const published = { ...healthyRelease(VERSION), draft: false }; + const selection = selectDraft([published], TAG, VERSION); + assert.ok(!selection.ok); + assert.match(selection.errors.join("\n"), /already published/); + }); + + it("fails on ambiguous v-tag + bare-version drafts", () => { + const selection = selectDraft([healthyRelease(VERSION), healthyRelease(VERSION, "bare")], TAG, VERSION); + assert.ok(!selection.ok); + assert.match(selection.errors.join("\n"), /2 releases match/); + }); +}); + +describe("release gate — packaging-config drift guard", () => { + const yaml = require("js-yaml") as { load(source: string): unknown }; + const realConfig = yaml.load(fs.readFileSync(path.join(repoRoot, "electron-builder.yml"), "utf8")); + const realPkg = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")); + + it("accepts the repository's real configuration", () => { + const verdict = verifyConfigAssumptions(realConfig, realPkg); + assert.ok(verdict.ok, JSON.stringify(verdict, null, 2)); + }); + + const drifts: Array<[string, (config: Record) => void]> = [ + ["linux artifactName", (config) => { (config.linux as Record).artifactName = "${name}-${version}-${arch}.${ext}"; }], + ["win artifactName", (config) => { (config.win as Record).artifactName = "${productName}-${version}-setup.${ext}"; }], + ["mac artifactName", (config) => { (config.mac as Record).artifactName = "${productName}-${version}.${ext}"; }], + ["top-level artifactName", (config) => { config.artifactName = "${name}-${version}.${ext}"; }], + ["productName", (config) => { config.productName = "learnharness"; }], + ["mac targets", (config) => { (config.mac as Record).target = [{ target: "dmg", arch: ["arm64"] }]; }], + ["win target", (config) => { (config.win as Record).target = [{ target: "appx", arch: ["x64"] }]; }], + ["linux targets", (config) => { (config.linux as Record).target = [{ target: "AppImage", arch: ["x64"] }]; }], + ["publish provider", (config) => { config.publish = [{ provider: "github", releaseType: "prerelease" }]; }], + ["package name", () => { /* pkg checked below */ }], + ]; + for (const [label, mutate] of drifts) { + it(`fails when ${label} drifts (expectations must be re-derived)`, () => { + const config = structuredClone(realConfig) as Record; + mutate(config); + const pkg = label === "package name" ? { ...realPkg, name: "other" } : realPkg; + const verdict = verifyConfigAssumptions(config, pkg); + assert.ok(!verdict.ok); + assert.match(verdict.errors.join("\n"), /expected names no longer hold|would not land in the draft|no longer "learnharness"|productName is /); + }); + } +}); + +describe("release gate — asset inventory", () => { + it("passes a complete draft and names every deviation", () => { + assert.ok(verifyAssets(healthyRelease(VERSION), expectedAssets(VERSION)).ok); + const release = healthyRelease(VERSION); + release.assets = release.assets.filter((entry) => entry.name !== "LearnHarness-0.2.0-x64-setup.exe.blockmap"); + release.assets.push(asset(9999, "LearnHarness-0.2.0-x64.AppImage")); + release.assets.push(asset(9998, "LearnHarness-0.1.0-x64-setup.exe")); + release.assets.push(asset(9997, "LearnHarness-0.2.0-arm64.dmg", { size: 0 })); + release.assets.push(asset(9996, "LearnHarness-0.2.0-arm64.dmg", { state: "starter" })); + const verdict = verifyAssets(release, expectedAssets(VERSION)); + assert.ok(!verdict.ok); + const joined = verdict.errors.join("\n"); + assert.match(joined, /x64-setup\.exe\.blockmap: missing/); + assert.match(joined, /x64\.AppImage: unexpected/); + assert.match(joined, /0\.1\.0-x64-setup\.exe: unexpected/); + assert.match(joined, /duplicate asset name/); + assert.match(joined, /zero-size/); + assert.match(joined, /state is "starter"/); + }); +}); + +describe("release gate — updater reference hygiene", () => { + it("accepts bare asset names only", () => { + assert.ok(isPlainAssetName("LearnHarness-0.2.0-arm64.dmg")); + for (const bad of ["", ".", "..", "../LearnHarness.zip", "dist/LearnHarness.zip", "https://evil.example/app.zip", + "http://evil.example/app.zip", "file:///etc/passwd", "C:\\app.zip", "~x/app.zip", "app.zip?q=1", "app.zip#x"]) { + assert.equal(isPlainAssetName(bad), false, bad); + } + }); +}); + +describe("release gate — update feeds", () => { + it("accepts the healthy feeds for the complete draft", () => { + const release = healthyRelease(VERSION); + const verdict = verifyMetadataFiles(healthyFeeds(VERSION, release), release, expectedAssets(VERSION)); + assert.ok(verdict.ok, JSON.stringify(verdict, null, 2)); + }); + + it("accepts either AppImage/deb order in latest-linux.yml (builder order is not deterministic)", () => { + const release = healthyRelease(VERSION); + const e = expectedAssets(VERSION); + const feeds = healthyFeeds(VERSION, release); + // Same content, deb entry first (writeUpdateInfoFiles keeps whichever + // target finished first; the gate must accept both). + feeds["latest-linux.yml"] = [ + `version: ${VERSION}`, + "files:", + ` - url: ${e.linux.deb}`, + ` sha512: ${sha512(e.linux.deb)}`, + " size: 5000", + ` - url: ${e.linux.appImage}`, + ` sha512: ${sha512(e.linux.appImage)}`, + " size: 5000", + `path: ${e.linux.deb}`, + `sha512: ${sha512(e.linux.deb)}`, + "releaseDate: '2026-10-13T09:37:13.265Z'", + ].join("\n") + "\n"; + const verdict = verifyMetadataFiles(feeds, release, expectedAssets(VERSION)); + assert.ok(verdict.ok, JSON.stringify(verdict, null, 2)); + }); + + type Mutation = (feeds: Record, release: ReleaseRecord) => void; + const mutations: Array<[string, Mutation, RegExp]> = [ + ["wrong version in latest.yml", (feeds) => { feeds["latest.yml"] = feeds["latest.yml"]!.replace(`version: ${VERSION}`, "version: 0.3.0"); }, /latest\.yml: version is "0\.3\.0"/], + ["path traversal in a reference", (feeds, release) => { + const e = expectedAssets(VERSION); + feeds["latest.yml"] = feeds["latest.yml"]!.replace(`url: ${e.win.exe}`, "url: ../../dist/app.exe"); + void release; + }, /latest\.yml: files\[0\]\.url is not a bare asset name/], + ["absolute-URL reference", (feeds) => { + const e = expectedAssets(VERSION); + feeds["latest.yml"] = feeds["latest.yml"]!.replace(`url: ${e.win.exe}`, "url: https://evil.example/app.exe"); + }, /is not a bare asset name/], + ["reference to an asset the release does not hold", (feeds) => { + const e = expectedAssets(VERSION); + feeds["latest.yml"] = feeds["latest.yml"]!.replace(`url: ${e.win.exe}`, "url: LearnHarness-0.1.0-x64-setup.exe"); + }, /references "LearnHarness-0\.1\.0-x64-setup\.exe" which is not an asset/], + ["size disagreeing with the uploaded asset", (feeds) => { + const e = expectedAssets(VERSION); + feeds["latest.yml"] = feeds["latest.yml"]!.replace("size: 5000", "size: 4999"); + void e; + }, /size 4999 ≠ release asset size 5000/], + ["mac ZIP not first (legacy path/sha512 would mislead the updater)", (feeds) => { + const e = expectedAssets(VERSION); + feeds["latest-mac.yml"] = feeds["latest-mac.yml"]!.replace( + `files:\n - url: ${e.mac.zip}\n sha512: ${sha512(e.mac.zip)}\n size: 5000\n - url: ${e.mac.dmg}\n sha512: ${sha512(e.mac.dmg)}\n size: 5000\n`, + `files:\n - url: ${e.mac.dmg}\n sha512: ${sha512(e.mac.dmg)}\n size: 5000\n - url: ${e.mac.zip}\n sha512: ${sha512(e.mac.zip)}\n size: 5000\n`, + ).replace(`path: ${e.mac.zip}\nsha512: ${sha512(e.mac.zip)}\n`, `path: ${e.mac.dmg}\nsha512: ${sha512(e.mac.dmg)}\n`); + }, /files\[0\] must be/], + ["mac legacy path pointing away from files[0]", (feeds) => { + const e = expectedAssets(VERSION); + feeds["latest-mac.yml"] = feeds["latest-mac.yml"]!.replace(`path: ${e.mac.zip}`, `path: ${e.mac.dmg}`); + }, /top-level path is/], + ["mac legacy sha512 not matching files[0]", (feeds) => { + feeds["latest-mac.yml"] = feeds["latest-mac.yml"]!.replace(`sha512: ${sha512("LearnHarness-0.2.0-arm64-mac.zip")}\nreleaseDate`, `sha512: ${sha512("tampered")}\nreleaseDate`); + }, /top-level sha512 does not match/], + ["missing deb entry in latest-linux.yml", (feeds) => { + const e = expectedAssets(VERSION); + feeds["latest-linux.yml"] = feeds["latest-linux.yml"]!.replace( + ` - url: ${e.linux.deb}\n sha512: ${sha512(e.linux.deb)}\n size: 5000\n`, "", + ).replace(`path: ${e.linux.appImage}`, `path: ${e.linux.appImage}`); + }, /must reference exactly/], + ["extra entry in latest.yml", (feeds) => { + const e = expectedAssets(VERSION); + feeds["latest.yml"] = feeds["latest.yml"]!.replace("path:", ` - url: ${e.mac.zip}\n sha512: ${sha512(e.mac.zip)}\n size: 5000\npath:`); + }, /must reference exactly/], + ["garbage sha512", (feeds) => { + const e = expectedAssets(VERSION); + feeds["latest.yml"] = feeds["latest.yml"]!.replace(`sha512: ${sha512(e.win.exe)}`, "sha512: deadbeef"); + }, /sha512 is not a base64 digest/], + ["unparseable feed", (feeds) => { feeds["latest.yml"] = "\tversion: [unclosed"; }, /does not parse as YAML/], + ["non-mapping feed", (feeds) => { feeds["latest.yml"] = "- just\n- a\n- list\n"; }, /top level is not a mapping/], + ["feed not downloaded", (feeds) => { delete feeds["latest-linux.yml"]; }, /not downloaded/], + ]; + for (const [label, mutate, pattern] of mutations) { + it(`fails on ${label}`, () => { + const release = healthyRelease(VERSION); + const feeds = healthyFeeds(VERSION, release); + mutate(feeds, release); + const verdict = verifyMetadataFiles(feeds, release, expectedAssets(VERSION)); + assert.ok(!verdict.ok, `expected failure for: ${label}`); + assert.match(verdict.errors.join("\n"), pattern); + }); + } +}); + +describe("release gate — workflow wiring", () => { + const yaml = require("js-yaml") as { load(source: string): any }; + const workflow = yaml.load(fs.readFileSync(path.join(repoRoot, ".github", "workflows", "release.yml"), "utf8")); + const verify = workflow.jobs.verify; + + it("runs read-only after ALL packaging jobs, only on real tag pushes", () => { + assert.ok(verify); + assert.equal(verify.needs, "package"); + assert.equal(verify.if, "github.event_name == 'push'"); + assert.deepEqual(verify.permissions, { contents: "read" }); + assert.equal(verify.env.GH_TOKEN, "${{ github.token }}"); + }); + + it("checks out the tagged revision and runs select + verify", () => { + const checkout = verify.steps.find((step: any) => step.uses?.startsWith("actions/checkout")); + assert.deepEqual(checkout.with, { ref: "${{ github.sha }}" }); + const select = verify.steps.find((step: any) => step.run?.includes("verify-release.ts select")); + const final = verify.steps.find((step: any) => step.run?.includes("verify-release.ts verify")); + assert.ok(select && final); + assert.match(final.run, /--tag "\$GITHUB_REF_NAME"/); + // The gate's own job: nothing here may create, edit, publish, or delete. + for (const step of verify.steps) { + assert.equal(step.run?.includes("create") ?? false, false, String(step.name)); + } + }); + + it("download step fetches exactly the three update feeds from the selected draft", () => { + const download = verify.steps.find((step: any) => step.name === "Download the three update feeds"); + assert.ok(download); + for (const feed of ["latest-mac.yml", "latest.yml", "latest-linux.yml"]) assert.ok(download.run.includes(feed)); + assert.match(download.run, /releases\/assets\/\$id/); + }); +}); + +describe("release gate — CLI (offline, repo config)", () => { + // CLI fixtures follow the real package version; pure-core fixtures above + // intentionally stay at 0.2.0. A release version bump must not break tests. + const VERSION = (JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")) as { version: string }).version; + const TAG = `v${VERSION}`; + const tsxImport = fileURLToPath(import.meta.resolve("tsx")); + function run(args: string[], cwd = repoRoot) { + return spawnSync(process.execPath, ["--import", tsxImport, script, ...args], { + cwd, encoding: "utf8", timeout: 60_000, + }); + } + + function workspace(tagForm: "v" | "bare" = "v", version = VERSION) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "lh-release-gate-")); + const release = healthyRelease(version, tagForm); + fs.writeFileSync(path.join(root, "releases.json"), JSON.stringify([release])); + fs.mkdirSync(path.join(root, "metadata")); + for (const [name, content] of Object.entries(healthyFeeds(version, release))) { + fs.writeFileSync(path.join(root, "metadata", name), content); + } + return { root, release }; + } + + it("select emits machine JSON for the workflow's download step", () => { + const { root, release } = workspace(); + try { + const result = run(["select", path.join(root, "releases.json"), "--tag", TAG]); + assert.equal(result.status, 0, String(result.stderr)); + const report = JSON.parse(result.stdout) as { + releaseId: number; tag: string; matchedTagName: string; warnings: string[]; + assets: Array<{ id: number; name: string; size: number; state: string }>; + }; + assert.equal(report.releaseId, release.id); + assert.equal(report.matchedTagName, TAG); + assert.deepEqual(report.warnings, []); + assert.deepEqual(report.assets.map((entry) => entry.name), expectedAssets(VERSION).files); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("verify passes the complete draft and exits 0", () => { + const { root } = workspace(); + try { + const result = run(["verify", path.join(root, "releases.json"), path.join(root, "metadata"), "--tag", TAG]); + assert.equal(result.status, 0, String(result.stderr)); + assert.match(result.stdout, /11\/11 expected files present/); + assert.ok(result.stdout.includes(`OK: draft for ${TAG} is complete`)); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("verify fails a draft with a missing blockmap and a stale asset", () => { + const { root } = workspace(); + try { + const release = healthyRelease(VERSION); + release.assets = release.assets.filter((entry) => entry.name !== `LearnHarness-${VERSION}-arm64.dmg.blockmap`); + release.assets.push(asset(9999, "LearnHarness-0.1.0-x64-setup.exe")); + fs.writeFileSync(path.join(root, "releases.json"), JSON.stringify([release])); + const result = run(["verify", path.join(root, "releases.json"), path.join(root, "metadata"), "--tag", TAG]); + assert.equal(result.status, 1); + assert.match(result.stderr, /arm64\.dmg\.blockmap: missing from the release/); + assert.match(result.stderr, /0\.1\.0-x64-setup\.exe: unexpected asset/); + assert.match(result.stderr, /do not publish/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("verify fails closed when the tag disagrees with package.json", () => { + const { root } = workspace(); + try { + const wrongTag = TAG === "v0.3.0" ? "v0.2.0" : "v0.3.0"; + const result = run(["verify", path.join(root, "releases.json"), path.join(root, "metadata"), "--tag", wrongTag]); + assert.equal(result.status, 1); + assert.match(result.stderr, /does not equal "v" \+ package\.json version/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("verify works after a package version bump without changing the tests", () => { + const futureVersion = "12.3.4"; + const { root } = workspace("v", futureVersion); + try { + const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")); + fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ ...pkg, version: futureVersion })); + fs.copyFileSync(path.join(repoRoot, "electron-builder.yml"), path.join(root, "electron-builder.yml")); + const result = run(["verify", path.join(root, "releases.json"), path.join(root, "metadata"), "--tag", `v${futureVersion}`], root); + assert.equal(result.status, 0, String(result.stderr)); + assert.ok(result.stdout.includes(`OK: draft for v${futureVersion} is complete`)); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("select warns loudly for the legacy unprefixed draft tag", () => { + const { root } = workspace("bare"); + try { + const result = run(["select", path.join(root, "releases.json"), "--tag", TAG]); + assert.equal(result.status, 0, String(result.stdout)); + assert.ok(result.stderr.includes("WARNING:")); + assert.ok(result.stderr.includes(`unprefixed tag "${VERSION}"`)); + const report = JSON.parse(result.stdout) as { warnings: string[] }; + assert.equal(report.warnings.length, 1); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +});