release: add storefront artwork and draft completeness gate

This commit is contained in:
amosblomqvist
2026-10-13 15:41:43 +03:00
parent cf042b25e7
commit 91672160c2
11 changed files with 1276 additions and 30 deletions
+52
View File
@@ -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"
+47 -26
View File
@@ -1,44 +1,65 @@
# LearnHarness
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/lh_banner_dark.svg">
<source media="(prefers-color-scheme: light)" srcset="docs/assets/lh_banner_light.svg">
<img src="docs/assets/lh_banner_light.svg" alt="LearnHarness" width="640">
</picture>
</p>
A desktop AI tutor that teaches by questioning — one reasoning step at a time — and keeps everything you learn in a growing knowledge graph.
<p align="center"><strong>An agent harness for learning.</strong></p>
It runs on your machine, with your own API key. Your data stays in plain files you can open.
<p align="center">
<a href="https://learnharness.ai">Download</a> ·
<a href="#how-it-works">How it works</a> ·
<a href="docs/development.md">Documentation</a> ·
<a href="https://github.com/amosblomqvist/learnharness/issues">Issues</a>
</p>
## 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.
<!-- Teaching-session screenshot goes here when supplied. -->
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`.
<!-- Knowledge-graph screenshot goes here when supplied. -->
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
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

+37
View File
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 1876 944" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,0,59)">
<g transform="matrix(5.079991,0,0,5.079991,-2933.477454,-2223.073769)">
<path d="M591.811,597.18L591.811,594.836L597.206,594.836L597.206,565.222L591.811,565.222L591.811,562.878C593.08,562.976 596.132,563.025 600.966,563.025C606.24,563.025 609.601,562.976 611.049,562.878L611.049,565.222L604.311,565.222L604.311,594.836L609.462,594.836C610.699,594.836 611.826,594.677 612.844,594.36C613.861,594.043 614.691,593.66 615.334,593.213C615.977,592.765 616.567,592.163 617.104,591.406C617.641,590.649 618.044,589.99 618.312,589.428C618.581,588.867 618.821,588.135 619.033,587.231C619.244,586.328 619.374,585.661 619.423,585.229C619.472,584.798 619.553,584.216 619.667,583.484L622.011,583.484L620.449,597.18L591.811,597.18Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M626.015,585.925C626.015,582.654 627.105,579.935 629.286,577.771C631.467,575.606 634.487,574.524 638.344,574.524C641.778,574.524 644.431,575.427 646.303,577.234C648.175,579.105 649.111,581.669 649.111,584.924C649.111,585.559 649.001,585.945 648.781,586.084C648.561,586.222 648.134,586.291 647.499,586.291L632.411,586.291C632.411,592.411 634.755,595.471 639.443,595.471L639.492,595.471C641.119,595.471 642.588,595.105 643.898,594.372C645.208,593.64 646.124,592.68 646.645,591.491C646.808,591.052 646.958,590.767 647.097,590.637C647.235,590.507 647.507,590.442 647.914,590.442C648.712,590.442 649.111,590.735 649.111,591.321C649.111,591.548 649.017,591.874 648.83,592.297C648.643,592.72 648.309,593.241 647.829,593.86C647.349,594.478 646.751,595.052 646.034,595.581C645.318,596.11 644.35,596.557 643.129,596.924C641.909,597.29 640.549,597.473 639.052,597.473C635.081,597.473 631.915,596.399 629.555,594.25C627.195,592.102 626.015,589.327 626.015,585.925ZM632.411,584.631L644.35,584.631C644.187,579.097 642.185,576.33 638.344,576.33C636.31,576.33 634.731,577.193 633.608,578.918C632.94,579.96 632.542,581.864 632.411,584.631Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M652.358,591.784C652.358,587.016 657.77,584.395 668.593,583.923L668.593,582.239C668.593,578.3 666.762,576.33 663.1,576.33C661.798,576.33 660.593,576.444 659.487,576.672C660.219,577.177 660.585,577.999 660.585,579.138C660.585,580.131 660.272,580.904 659.645,581.457C659.019,582.011 658.282,582.287 657.436,582.287C656.671,582.287 655.963,582.011 655.312,581.457C654.661,580.904 654.335,580.131 654.335,579.138C654.335,576.078 657.322,574.54 663.295,574.524C666.892,574.524 669.627,575.224 671.498,576.623C673.37,578.023 674.306,579.895 674.306,582.239L674.306,592.981C674.306,593.453 674.339,593.786 674.404,593.982C674.469,594.177 674.733,594.368 675.197,594.555C675.661,594.743 676.381,594.836 677.358,594.836C677.651,594.836 677.862,594.852 677.993,594.885C678.123,594.918 678.265,595.015 678.42,595.178C678.574,595.341 678.652,595.609 678.652,595.984C678.652,596.521 678.534,596.854 678.298,596.985C678.062,597.115 677.643,597.18 677.04,597.18L674.648,597.18C673.411,597.18 672.41,597.062 671.645,596.826C670.88,596.59 670.355,596.24 670.07,595.776C669.785,595.312 669.61,594.922 669.545,594.604C669.48,594.287 669.448,593.86 669.448,593.322C668.015,596.089 665.533,597.473 662.001,597.473C661.008,597.473 660.007,597.4 658.998,597.253C657.989,597.107 656.952,596.842 655.886,596.46C654.819,596.077 653.965,595.487 653.322,594.69C652.679,593.892 652.358,592.924 652.358,591.784ZM658.193,591.736C658.193,592.842 658.636,593.778 659.523,594.543C660.41,595.308 661.489,595.691 662.758,595.691C663.198,595.691 663.69,595.621 664.235,595.483C664.78,595.345 665.411,595.109 666.127,594.775C666.843,594.441 667.433,593.876 667.897,593.078C668.361,592.281 668.593,591.329 668.593,590.222L668.593,585.486C667.861,585.518 667.169,585.575 666.518,585.656C665.867,585.738 664.992,585.921 663.893,586.206C662.795,586.491 661.863,586.832 661.098,587.231C660.333,587.63 659.658,588.224 659.072,589.013C658.486,589.803 658.193,590.71 658.193,591.736Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M680.532,597.18L680.532,594.836L683.974,594.836L683.974,579.382C683.974,578.454 683.799,577.897 683.449,577.71C683.099,577.523 682.127,577.429 680.532,577.429L680.532,575.085L688.832,574.67L688.832,580.334C690.2,576.574 692.413,574.686 695.473,574.67C696.873,574.67 698.106,575.04 699.172,575.781C700.238,576.522 700.771,577.518 700.771,578.772C700.771,579.699 700.462,580.44 699.843,580.993C699.225,581.547 698.517,581.823 697.719,581.823C696.922,581.823 696.214,581.559 695.595,581.03C694.977,580.501 694.667,579.748 694.667,578.772C694.667,577.877 694.985,577.112 695.619,576.477C694.448,576.477 693.43,576.802 692.568,577.453C691.705,578.104 691.058,578.955 690.627,580.005C690.195,581.054 689.878,582.08 689.675,583.081C689.471,584.082 689.369,585.079 689.369,586.072L689.369,594.836L693.715,594.836L693.715,597.18C692.511,597.082 690.216,597.033 686.83,597.033C686.131,597.033 684.031,597.082 680.532,597.18Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M704.555,597.18L704.555,594.836L707.997,594.836L707.997,579.382C707.997,578.454 707.822,577.897 707.472,577.71C707.123,577.523 706.15,577.429 704.555,577.429L704.555,575.085L713.149,574.67L713.149,580.09C714.988,576.493 717.909,574.686 721.913,574.67C724.485,574.67 726.41,575.232 727.687,576.355C728.965,577.478 729.604,579.317 729.604,581.872L729.604,594.836L733.071,594.836L733.071,597.18C730.271,597.082 728.167,597.033 726.76,597.033C725.352,597.033 723.248,597.082 720.449,597.18L720.449,594.836L723.915,594.836L723.915,581.335C723.915,579.529 723.679,578.267 723.207,577.551C722.735,576.835 722.052,576.477 721.157,576.477C719.317,576.477 717.617,577.169 716.054,578.552C714.492,579.935 713.71,581.88 713.71,584.387L713.71,594.836L717.153,594.836L717.153,597.18C714.353,597.082 712.254,597.033 710.854,597.033C709.454,597.033 707.354,597.082 704.555,597.18Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M736.147,597.18L736.147,594.836L741.542,594.836L741.542,565.222L736.147,565.222L736.147,562.878C737.416,562.976 740.399,563.025 745.095,563.025C749.79,563.025 752.773,562.976 754.042,562.878L754.042,565.222L748.647,565.222L748.647,578.186L764.687,578.186L764.687,565.222L759.291,565.222L759.291,562.878C760.561,562.976 763.543,563.025 768.239,563.025C772.935,563.025 775.917,562.976 777.187,562.878L777.187,565.222L771.791,565.222L771.791,594.836L777.187,594.836L777.187,597.18C775.917,597.082 772.935,597.033 768.239,597.033C763.543,597.033 760.561,597.082 759.291,597.18L759.291,594.836L764.687,594.836L764.687,580.53L748.647,580.53L748.647,594.836L754.042,594.836L754.042,597.18C752.773,597.082 749.79,597.033 745.095,597.033C740.399,597.033 737.416,597.082 736.147,597.18Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M780.8,591.784C780.8,587.016 786.212,584.395 797.035,583.923L797.035,582.239C797.035,578.3 795.204,576.33 791.542,576.33C790.24,576.33 789.036,576.444 787.929,576.672C788.661,577.177 789.028,577.999 789.028,579.138C789.028,580.131 788.714,580.904 788.088,581.457C787.461,582.011 786.725,582.287 785.878,582.287C785.113,582.287 784.405,582.011 783.754,581.457C783.103,580.904 782.778,580.131 782.778,579.138C782.778,576.078 785.764,574.54 791.738,574.524C795.335,574.524 798.069,575.224 799.941,576.623C801.812,578.023 802.748,579.895 802.748,582.239L802.748,592.981C802.748,593.453 802.781,593.786 802.846,593.982C802.911,594.177 803.176,594.368 803.639,594.555C804.103,594.743 804.824,594.836 805.8,594.836C806.093,594.836 806.305,594.852 806.435,594.885C806.565,594.918 806.708,595.015 806.862,595.178C807.017,595.341 807.094,595.609 807.094,595.984C807.094,596.521 806.976,596.854 806.74,596.985C806.504,597.115 806.085,597.18 805.483,597.18L803.09,597.18C801.853,597.18 800.852,597.062 800.087,596.826C799.322,596.59 798.797,596.24 798.513,595.776C798.228,595.312 798.053,594.922 797.988,594.604C797.923,594.287 797.89,593.86 797.89,593.322C796.458,596.089 793.976,597.473 790.444,597.473C789.451,597.473 788.45,597.4 787.441,597.253C786.432,597.107 785.394,596.842 784.328,596.46C783.262,596.077 782.407,595.487 781.764,594.69C781.122,593.892 780.8,592.924 780.8,591.784ZM786.635,591.736C786.635,592.842 787.079,593.778 787.966,594.543C788.853,595.308 789.931,595.691 791.201,595.691C791.64,595.691 792.132,595.621 792.678,595.483C793.223,595.345 793.854,595.109 794.57,594.775C795.286,594.441 795.876,593.876 796.34,593.078C796.804,592.281 797.035,591.329 797.035,590.222L797.035,585.486C796.303,585.518 795.611,585.575 794.96,585.656C794.309,585.738 793.434,585.921 792.336,586.206C791.237,586.491 790.305,586.832 789.54,587.231C788.775,587.63 788.1,588.224 787.514,589.013C786.928,589.803 786.635,590.71 786.635,591.736Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M808.974,597.18L808.974,594.836L812.416,594.836L812.416,579.382C812.416,578.454 812.241,577.897 811.891,577.71C811.541,577.523 810.569,577.429 808.974,577.429L808.974,575.085L817.275,574.67L817.275,580.334C818.642,576.574 820.855,574.686 823.915,574.67C825.315,574.67 826.548,575.04 827.614,575.781C828.68,576.522 829.213,577.518 829.213,578.772C829.213,579.699 828.904,580.44 828.285,580.993C827.667,581.547 826.959,581.823 826.161,581.823C825.364,581.823 824.656,581.559 824.037,581.03C823.419,580.501 823.11,579.748 823.11,578.772C823.11,577.877 823.427,577.112 824.062,576.477C822.89,576.477 821.873,576.802 821.01,577.453C820.147,578.104 819.5,578.955 819.069,580.005C818.638,581.054 818.32,582.08 818.117,583.081C817.914,584.082 817.812,585.079 817.812,586.072L817.812,594.836L822.158,594.836L822.158,597.18C820.953,597.082 818.658,597.033 815.273,597.033C814.573,597.033 812.473,597.082 808.974,597.18Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M832.997,597.18L832.997,594.836L836.44,594.836L836.44,579.382C836.44,578.454 836.265,577.897 835.915,577.71C835.565,577.523 834.592,577.429 832.997,577.429L832.997,575.085L841.591,574.67L841.591,580.09C843.43,576.493 846.352,574.686 850.356,574.67C852.927,574.67 854.852,575.232 856.13,576.355C857.407,577.478 858.046,579.317 858.046,581.872L858.046,594.836L861.513,594.836L861.513,597.18C858.714,597.082 856.61,597.033 855.202,597.033C853.794,597.033 851.69,597.082 848.891,597.18L848.891,594.836L852.358,594.836L852.358,581.335C852.358,579.529 852.122,578.267 851.65,577.551C851.178,576.835 850.494,576.477 849.599,576.477C847.76,576.477 846.059,577.169 844.496,578.552C842.934,579.935 842.153,581.88 842.153,584.387L842.153,594.836L845.595,594.836L845.595,597.18C842.796,597.082 840.696,597.033 839.296,597.033C837.896,597.033 835.797,597.082 832.997,597.18Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M864.247,585.925C864.247,582.654 865.338,579.935 867.519,577.771C869.7,575.606 872.719,574.524 876.576,574.524C880.011,574.524 882.664,575.427 884.535,577.234C886.407,579.105 887.343,581.669 887.343,584.924C887.343,585.559 887.233,585.945 887.014,586.084C886.794,586.222 886.367,586.291 885.732,586.291L870.644,586.291C870.644,592.411 872.988,595.471 877.675,595.471L877.724,595.471C879.352,595.471 880.82,595.105 882.131,594.372C883.441,593.64 884.356,592.68 884.877,591.491C885.04,591.052 885.191,590.767 885.329,590.637C885.467,590.507 885.74,590.442 886.147,590.442C886.944,590.442 887.343,590.735 887.343,591.321C887.343,591.548 887.25,591.874 887.062,592.297C886.875,592.72 886.541,593.241 886.061,593.86C885.581,594.478 884.983,595.052 884.267,595.581C883.551,596.11 882.582,596.557 881.362,596.924C880.141,597.29 878.782,597.473 877.284,597.473C873.313,597.473 870.147,596.399 867.787,594.25C865.427,592.102 864.247,589.327 864.247,585.925ZM870.644,584.631L882.582,584.631C882.42,579.097 880.418,576.33 876.576,576.33C874.542,576.33 872.963,577.193 871.84,578.918C871.173,579.96 870.774,581.864 870.644,584.631Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M890.883,596.081L890.883,590.637C890.883,590.002 890.94,589.599 891.054,589.428C891.168,589.258 891.51,589.172 892.079,589.172C892.486,589.172 892.763,589.209 892.909,589.282C893.056,589.355 893.178,589.555 893.276,589.88C893.471,590.58 893.695,591.198 893.947,591.736C894.199,592.273 894.586,592.883 895.107,593.567C895.628,594.25 896.344,594.775 897.255,595.141C898.167,595.508 899.241,595.691 900.478,595.691C904.27,595.691 906.174,594.437 906.191,591.931C906.191,591.231 906.004,590.633 905.629,590.136C905.255,589.64 904.718,589.249 904.018,588.965C903.318,588.68 902.744,588.476 902.297,588.354C901.849,588.232 901.292,588.122 900.624,588.025C898.997,587.732 897.866,587.516 897.231,587.378C896.596,587.239 895.872,586.995 895.058,586.645C894.244,586.295 893.52,585.844 892.885,585.29C891.55,584.151 890.883,582.751 890.883,581.091C890.883,580.066 891.066,579.15 891.432,578.344C891.799,577.539 892.307,576.908 892.958,576.452C893.609,575.997 894.252,575.63 894.887,575.354C895.522,575.077 896.246,574.886 897.06,574.78C897.874,574.674 898.472,574.605 898.854,574.572C899.237,574.54 899.648,574.524 900.087,574.524C902.187,574.524 903.871,574.841 905.141,575.476C906.443,574.841 907.192,574.524 907.387,574.524C907.794,574.524 908.046,574.617 908.144,574.804C908.242,574.992 908.29,575.37 908.29,575.94L908.29,579.919C908.29,580.554 908.229,580.949 908.107,581.103C907.985,581.258 907.639,581.335 907.07,581.335C906.565,581.335 906.256,581.266 906.142,581.128C906.028,580.989 905.955,580.725 905.922,580.334C905.662,577.535 903.701,576.135 900.038,576.135C896.311,576.135 894.439,577.169 894.423,579.236C894.423,579.805 894.582,580.289 894.899,580.688C895.217,581.087 895.701,581.4 896.352,581.628C897.003,581.856 897.512,582.023 897.878,582.129C898.244,582.234 898.793,582.352 899.526,582.483C901.69,582.857 903.249,583.183 904.201,583.459C905.153,583.736 906.077,584.208 906.972,584.875C908.811,586.34 909.731,588.106 909.731,590.173C909.731,591.345 909.548,592.374 909.181,593.261C908.815,594.149 908.307,594.84 907.656,595.337C907.005,595.833 906.362,596.24 905.727,596.557C905.092,596.875 904.36,597.09 903.53,597.204C902.7,597.318 902.101,597.392 901.735,597.424C901.369,597.457 900.95,597.473 900.478,597.473C898.085,597.473 896.116,596.944 894.57,595.886L893.276,596.74C892.592,597.229 892.104,597.473 891.811,597.473L891.786,597.473C891.38,597.473 891.127,597.375 891.03,597.18C890.932,596.985 890.883,596.618 890.883,596.081Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M913.515,596.081L913.515,590.637C913.515,590.002 913.572,589.599 913.686,589.428C913.8,589.258 914.142,589.172 914.711,589.172C915.118,589.172 915.395,589.209 915.541,589.282C915.688,589.355 915.81,589.555 915.908,589.88C916.103,590.58 916.327,591.198 916.579,591.736C916.831,592.273 917.218,592.883 917.739,593.567C918.259,594.25 918.976,594.775 919.887,595.141C920.798,595.508 921.873,595.691 923.11,595.691C926.902,595.691 928.806,594.437 928.823,591.931C928.823,591.231 928.635,590.633 928.261,590.136C927.887,589.64 927.35,589.249 926.65,588.965C925.95,588.68 925.376,588.476 924.929,588.354C924.481,588.232 923.923,588.122 923.256,588.025C921.629,587.732 920.497,587.516 919.863,587.378C919.228,587.239 918.504,586.995 917.69,586.645C916.876,586.295 916.152,585.844 915.517,585.29C914.182,584.151 913.515,582.751 913.515,581.091C913.515,580.066 913.698,579.15 914.064,578.344C914.43,577.539 914.939,576.908 915.59,576.452C916.241,575.997 916.884,575.63 917.519,575.354C918.154,575.077 918.878,574.886 919.692,574.78C920.506,574.674 921.104,574.605 921.486,574.572C921.869,574.54 922.28,574.524 922.719,574.524C924.819,574.524 926.503,574.841 927.773,575.476C929.075,574.841 929.824,574.524 930.019,574.524C930.426,574.524 930.678,574.617 930.776,574.804C930.873,574.992 930.922,575.37 930.922,575.94L930.922,579.919C930.922,580.554 930.861,580.949 930.739,581.103C930.617,581.258 930.271,581.335 929.701,581.335C929.197,581.335 928.888,581.266 928.774,581.128C928.66,580.989 928.587,580.725 928.554,580.334C928.294,577.535 926.332,576.135 922.67,576.135C918.943,576.135 917.071,577.169 917.055,579.236C917.055,579.805 917.214,580.289 917.531,580.688C917.848,581.087 918.333,581.4 918.984,581.628C919.635,581.856 920.143,582.023 920.51,582.129C920.876,582.234 921.425,582.352 922.158,582.483C924.322,582.857 925.881,583.183 926.833,583.459C927.785,583.736 928.709,584.208 929.604,584.875C931.443,586.34 932.363,588.106 932.363,590.173C932.363,591.345 932.18,592.374 931.813,593.261C931.447,594.149 930.938,594.84 930.287,595.337C929.636,595.833 928.993,596.24 928.359,596.557C927.724,596.875 926.992,597.09 926.161,597.204C925.331,597.318 924.733,597.392 924.367,597.424C924.001,597.457 923.582,597.473 923.11,597.473C920.717,597.473 918.748,596.944 917.201,595.886L915.908,596.74C915.224,597.229 914.736,597.473 914.443,597.473L914.418,597.473C914.011,597.473 913.759,597.375 913.661,597.18C913.564,596.985 913.515,596.618 913.515,596.081Z" style="fill:white;fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.541987,0,0,0.541987,660.419273,13.838813)">
<g transform="matrix(1,0,0,1,0.001713,-0.000688)">
<g transform="matrix(0.939904,0,0,0.939904,30.769428,30.766742)">
<g transform="matrix(-0.232199,0,0,0.232199,1384.403797,-416.791824)">
<g transform="matrix(0.573992,-2.142166,2.142166,0.573992,-5516.07298,4432.117471)">
<path d="M1234.728,3305.937C871.642,3324.965 587,3624.915 587,3988.5C587,4365.734 893.266,4672 1270.5,4672C1647.734,4672 1954,4365.734 1954,3988.5C1954,3880.637 1928.472,3774.304 1879.503,3678.197L1794.243,3721.64C1836.356,3804.292 1858.31,3895.737 1858.31,3988.5C1858.31,4312.921 1594.921,4576.31 1270.5,4576.31C946.079,4576.31 682.69,4312.921 682.69,3988.5C682.69,3675.817 927.482,3417.86 1239.736,3401.496L1234.728,3305.937Z" style="fill:rgb(148,148,148);"/>
</g>
<g transform="matrix(1.261034,0,0,1.261034,1170.373407,-2014.261188)">
<path d="M1270.5,3305C893.266,3305 587,3611.266 587,3988.5C587,4365.734 893.266,4672 1270.5,4672C1647.734,4672 1954,4365.734 1954,3988.5C1954,3611.266 1647.734,3305 1270.5,3305ZM1270.5,3471.537C1555.819,3471.537 1787.463,3703.181 1787.463,3988.5C1787.463,4273.819 1555.819,4505.463 1270.5,4505.463C985.181,4505.463 753.537,4273.819 753.537,3988.5C753.537,3703.181 985.181,3471.537 1270.5,3471.537Z" style="fill:white;"/>
</g>
<g transform="matrix(1.261034,0,0,1.261034,3139.627182,-45.007413)">
<circle cx="1270.5" cy="3988.5" r="683.5" style="fill:white;"/>
</g>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 19 KiB

+38
View File
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 1876 944" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,0,59)">
<g transform="matrix(5.079991,0,0,5.079991,-2933.477454,-2223.073769)">
<path d="M591.811,597.18L591.811,594.836L597.206,594.836L597.206,565.222L591.811,565.222L591.811,562.878C593.08,562.976 596.132,563.025 600.966,563.025C606.24,563.025 609.601,562.976 611.049,562.878L611.049,565.222L604.311,565.222L604.311,594.836L609.462,594.836C610.699,594.836 611.826,594.677 612.844,594.36C613.861,594.043 614.691,593.66 615.334,593.213C615.977,592.765 616.567,592.163 617.104,591.406C617.641,590.649 618.044,589.99 618.312,589.428C618.581,588.867 618.821,588.135 619.033,587.231C619.244,586.328 619.374,585.661 619.423,585.229C619.472,584.798 619.553,584.216 619.667,583.484L622.011,583.484L620.449,597.18L591.811,597.18Z" style="fill:rgb(20,20,20);fill-rule:nonzero;"/>
<path d="M626.015,585.925C626.015,582.654 627.105,579.935 629.286,577.771C631.467,575.606 634.487,574.524 638.344,574.524C641.778,574.524 644.431,575.427 646.303,577.234C648.175,579.105 649.111,581.669 649.111,584.924C649.111,585.559 649.001,585.945 648.781,586.084C648.561,586.222 648.134,586.291 647.499,586.291L632.411,586.291C632.411,592.411 634.755,595.471 639.443,595.471L639.492,595.471C641.119,595.471 642.588,595.105 643.898,594.372C645.208,593.64 646.124,592.68 646.645,591.491C646.808,591.052 646.958,590.767 647.097,590.637C647.235,590.507 647.507,590.442 647.914,590.442C648.712,590.442 649.111,590.735 649.111,591.321C649.111,591.548 649.017,591.874 648.83,592.297C648.643,592.72 648.309,593.241 647.829,593.86C647.349,594.478 646.751,595.052 646.034,595.581C645.318,596.11 644.35,596.557 643.129,596.924C641.909,597.29 640.549,597.473 639.052,597.473C635.081,597.473 631.915,596.399 629.555,594.25C627.195,592.102 626.015,589.327 626.015,585.925ZM632.411,584.631L644.35,584.631C644.187,579.097 642.185,576.33 638.344,576.33C636.31,576.33 634.731,577.193 633.608,578.918C632.94,579.96 632.542,581.864 632.411,584.631Z" style="fill:rgb(20,20,20);fill-rule:nonzero;"/>
<path d="M652.358,591.784C652.358,587.016 657.77,584.395 668.593,583.923L668.593,582.239C668.593,578.3 666.762,576.33 663.1,576.33C661.798,576.33 660.593,576.444 659.487,576.672C660.219,577.177 660.585,577.999 660.585,579.138C660.585,580.131 660.272,580.904 659.645,581.457C659.019,582.011 658.282,582.287 657.436,582.287C656.671,582.287 655.963,582.011 655.312,581.457C654.661,580.904 654.335,580.131 654.335,579.138C654.335,576.078 657.322,574.54 663.295,574.524C666.892,574.524 669.627,575.224 671.498,576.623C673.37,578.023 674.306,579.895 674.306,582.239L674.306,592.981C674.306,593.453 674.339,593.786 674.404,593.982C674.469,594.177 674.733,594.368 675.197,594.555C675.661,594.743 676.381,594.836 677.358,594.836C677.651,594.836 677.862,594.852 677.993,594.885C678.123,594.918 678.265,595.015 678.42,595.178C678.574,595.341 678.652,595.609 678.652,595.984C678.652,596.521 678.534,596.854 678.298,596.985C678.062,597.115 677.643,597.18 677.04,597.18L674.648,597.18C673.411,597.18 672.41,597.062 671.645,596.826C670.88,596.59 670.355,596.24 670.07,595.776C669.785,595.312 669.61,594.922 669.545,594.604C669.48,594.287 669.448,593.86 669.448,593.322C668.015,596.089 665.533,597.473 662.001,597.473C661.008,597.473 660.007,597.4 658.998,597.253C657.989,597.107 656.952,596.842 655.886,596.46C654.819,596.077 653.965,595.487 653.322,594.69C652.679,593.892 652.358,592.924 652.358,591.784ZM658.193,591.736C658.193,592.842 658.636,593.778 659.523,594.543C660.41,595.308 661.489,595.691 662.758,595.691C663.198,595.691 663.69,595.621 664.235,595.483C664.78,595.345 665.411,595.109 666.127,594.775C666.843,594.441 667.433,593.876 667.897,593.078C668.361,592.281 668.593,591.329 668.593,590.222L668.593,585.486C667.861,585.518 667.169,585.575 666.518,585.656C665.867,585.738 664.992,585.921 663.893,586.206C662.795,586.491 661.863,586.832 661.098,587.231C660.333,587.63 659.658,588.224 659.072,589.013C658.486,589.803 658.193,590.71 658.193,591.736Z" style="fill:rgb(20,20,20);fill-rule:nonzero;"/>
<path d="M680.532,597.18L680.532,594.836L683.974,594.836L683.974,579.382C683.974,578.454 683.799,577.897 683.449,577.71C683.099,577.523 682.127,577.429 680.532,577.429L680.532,575.085L688.832,574.67L688.832,580.334C690.2,576.574 692.413,574.686 695.473,574.67C696.873,574.67 698.106,575.04 699.172,575.781C700.238,576.522 700.771,577.518 700.771,578.772C700.771,579.699 700.462,580.44 699.843,580.993C699.225,581.547 698.517,581.823 697.719,581.823C696.922,581.823 696.214,581.559 695.595,581.03C694.977,580.501 694.667,579.748 694.667,578.772C694.667,577.877 694.985,577.112 695.619,576.477C694.448,576.477 693.43,576.802 692.568,577.453C691.705,578.104 691.058,578.955 690.627,580.005C690.195,581.054 689.878,582.08 689.675,583.081C689.471,584.082 689.369,585.079 689.369,586.072L689.369,594.836L693.715,594.836L693.715,597.18C692.511,597.082 690.216,597.033 686.83,597.033C686.131,597.033 684.031,597.082 680.532,597.18Z" style="fill:rgb(20,20,20);fill-rule:nonzero;"/>
<path d="M704.555,597.18L704.555,594.836L707.997,594.836L707.997,579.382C707.997,578.454 707.822,577.897 707.472,577.71C707.123,577.523 706.15,577.429 704.555,577.429L704.555,575.085L713.149,574.67L713.149,580.09C714.988,576.493 717.909,574.686 721.913,574.67C724.485,574.67 726.41,575.232 727.687,576.355C728.965,577.478 729.604,579.317 729.604,581.872L729.604,594.836L733.071,594.836L733.071,597.18C730.271,597.082 728.167,597.033 726.76,597.033C725.352,597.033 723.248,597.082 720.449,597.18L720.449,594.836L723.915,594.836L723.915,581.335C723.915,579.529 723.679,578.267 723.207,577.551C722.735,576.835 722.052,576.477 721.157,576.477C719.317,576.477 717.617,577.169 716.054,578.552C714.492,579.935 713.71,581.88 713.71,584.387L713.71,594.836L717.153,594.836L717.153,597.18C714.353,597.082 712.254,597.033 710.854,597.033C709.454,597.033 707.354,597.082 704.555,597.18Z" style="fill:rgb(20,20,20);fill-rule:nonzero;"/>
<path d="M736.147,597.18L736.147,594.836L741.542,594.836L741.542,565.222L736.147,565.222L736.147,562.878C737.416,562.976 740.399,563.025 745.095,563.025C749.79,563.025 752.773,562.976 754.042,562.878L754.042,565.222L748.647,565.222L748.647,578.186L764.687,578.186L764.687,565.222L759.291,565.222L759.291,562.878C760.561,562.976 763.543,563.025 768.239,563.025C772.935,563.025 775.917,562.976 777.187,562.878L777.187,565.222L771.791,565.222L771.791,594.836L777.187,594.836L777.187,597.18C775.917,597.082 772.935,597.033 768.239,597.033C763.543,597.033 760.561,597.082 759.291,597.18L759.291,594.836L764.687,594.836L764.687,580.53L748.647,580.53L748.647,594.836L754.042,594.836L754.042,597.18C752.773,597.082 749.79,597.033 745.095,597.033C740.399,597.033 737.416,597.082 736.147,597.18Z" style="fill:rgb(20,20,20);fill-rule:nonzero;"/>
<path d="M780.8,591.784C780.8,587.016 786.212,584.395 797.035,583.923L797.035,582.239C797.035,578.3 795.204,576.33 791.542,576.33C790.24,576.33 789.036,576.444 787.929,576.672C788.661,577.177 789.028,577.999 789.028,579.138C789.028,580.131 788.714,580.904 788.088,581.457C787.461,582.011 786.725,582.287 785.878,582.287C785.113,582.287 784.405,582.011 783.754,581.457C783.103,580.904 782.778,580.131 782.778,579.138C782.778,576.078 785.764,574.54 791.738,574.524C795.335,574.524 798.069,575.224 799.941,576.623C801.812,578.023 802.748,579.895 802.748,582.239L802.748,592.981C802.748,593.453 802.781,593.786 802.846,593.982C802.911,594.177 803.176,594.368 803.639,594.555C804.103,594.743 804.824,594.836 805.8,594.836C806.093,594.836 806.305,594.852 806.435,594.885C806.565,594.918 806.708,595.015 806.862,595.178C807.017,595.341 807.094,595.609 807.094,595.984C807.094,596.521 806.976,596.854 806.74,596.985C806.504,597.115 806.085,597.18 805.483,597.18L803.09,597.18C801.853,597.18 800.852,597.062 800.087,596.826C799.322,596.59 798.797,596.24 798.513,595.776C798.228,595.312 798.053,594.922 797.988,594.604C797.923,594.287 797.89,593.86 797.89,593.322C796.458,596.089 793.976,597.473 790.444,597.473C789.451,597.473 788.45,597.4 787.441,597.253C786.432,597.107 785.394,596.842 784.328,596.46C783.262,596.077 782.407,595.487 781.764,594.69C781.122,593.892 780.8,592.924 780.8,591.784ZM786.635,591.736C786.635,592.842 787.079,593.778 787.966,594.543C788.853,595.308 789.931,595.691 791.201,595.691C791.64,595.691 792.132,595.621 792.678,595.483C793.223,595.345 793.854,595.109 794.57,594.775C795.286,594.441 795.876,593.876 796.34,593.078C796.804,592.281 797.035,591.329 797.035,590.222L797.035,585.486C796.303,585.518 795.611,585.575 794.96,585.656C794.309,585.738 793.434,585.921 792.336,586.206C791.237,586.491 790.305,586.832 789.54,587.231C788.775,587.63 788.1,588.224 787.514,589.013C786.928,589.803 786.635,590.71 786.635,591.736Z" style="fill:rgb(20,20,20);fill-rule:nonzero;"/>
<path d="M808.974,597.18L808.974,594.836L812.416,594.836L812.416,579.382C812.416,578.454 812.241,577.897 811.891,577.71C811.541,577.523 810.569,577.429 808.974,577.429L808.974,575.085L817.275,574.67L817.275,580.334C818.642,576.574 820.855,574.686 823.915,574.67C825.315,574.67 826.548,575.04 827.614,575.781C828.68,576.522 829.213,577.518 829.213,578.772C829.213,579.699 828.904,580.44 828.285,580.993C827.667,581.547 826.959,581.823 826.161,581.823C825.364,581.823 824.656,581.559 824.037,581.03C823.419,580.501 823.11,579.748 823.11,578.772C823.11,577.877 823.427,577.112 824.062,576.477C822.89,576.477 821.873,576.802 821.01,577.453C820.147,578.104 819.5,578.955 819.069,580.005C818.638,581.054 818.32,582.08 818.117,583.081C817.914,584.082 817.812,585.079 817.812,586.072L817.812,594.836L822.158,594.836L822.158,597.18C820.953,597.082 818.658,597.033 815.273,597.033C814.573,597.033 812.473,597.082 808.974,597.18Z" style="fill:rgb(20,20,20);fill-rule:nonzero;"/>
<path d="M832.997,597.18L832.997,594.836L836.44,594.836L836.44,579.382C836.44,578.454 836.265,577.897 835.915,577.71C835.565,577.523 834.592,577.429 832.997,577.429L832.997,575.085L841.591,574.67L841.591,580.09C843.43,576.493 846.352,574.686 850.356,574.67C852.927,574.67 854.852,575.232 856.13,576.355C857.407,577.478 858.046,579.317 858.046,581.872L858.046,594.836L861.513,594.836L861.513,597.18C858.714,597.082 856.61,597.033 855.202,597.033C853.794,597.033 851.69,597.082 848.891,597.18L848.891,594.836L852.358,594.836L852.358,581.335C852.358,579.529 852.122,578.267 851.65,577.551C851.178,576.835 850.494,576.477 849.599,576.477C847.76,576.477 846.059,577.169 844.496,578.552C842.934,579.935 842.153,581.88 842.153,584.387L842.153,594.836L845.595,594.836L845.595,597.18C842.796,597.082 840.696,597.033 839.296,597.033C837.896,597.033 835.797,597.082 832.997,597.18Z" style="fill:rgb(20,20,20);fill-rule:nonzero;"/>
<path d="M864.247,585.925C864.247,582.654 865.338,579.935 867.519,577.771C869.7,575.606 872.719,574.524 876.576,574.524C880.011,574.524 882.664,575.427 884.535,577.234C886.407,579.105 887.343,581.669 887.343,584.924C887.343,585.559 887.233,585.945 887.014,586.084C886.794,586.222 886.367,586.291 885.732,586.291L870.644,586.291C870.644,592.411 872.988,595.471 877.675,595.471L877.724,595.471C879.352,595.471 880.82,595.105 882.131,594.372C883.441,593.64 884.356,592.68 884.877,591.491C885.04,591.052 885.191,590.767 885.329,590.637C885.467,590.507 885.74,590.442 886.147,590.442C886.944,590.442 887.343,590.735 887.343,591.321C887.343,591.548 887.25,591.874 887.062,592.297C886.875,592.72 886.541,593.241 886.061,593.86C885.581,594.478 884.983,595.052 884.267,595.581C883.551,596.11 882.582,596.557 881.362,596.924C880.141,597.29 878.782,597.473 877.284,597.473C873.313,597.473 870.147,596.399 867.787,594.25C865.427,592.102 864.247,589.327 864.247,585.925ZM870.644,584.631L882.582,584.631C882.42,579.097 880.418,576.33 876.576,576.33C874.542,576.33 872.963,577.193 871.84,578.918C871.173,579.96 870.774,581.864 870.644,584.631Z" style="fill:rgb(20,20,20);fill-rule:nonzero;"/>
<path d="M890.883,596.081L890.883,590.637C890.883,590.002 890.94,589.599 891.054,589.428C891.168,589.258 891.51,589.172 892.079,589.172C892.486,589.172 892.763,589.209 892.909,589.282C893.056,589.355 893.178,589.555 893.276,589.88C893.471,590.58 893.695,591.198 893.947,591.736C894.199,592.273 894.586,592.883 895.107,593.567C895.628,594.25 896.344,594.775 897.255,595.141C898.167,595.508 899.241,595.691 900.478,595.691C904.27,595.691 906.174,594.437 906.191,591.931C906.191,591.231 906.004,590.633 905.629,590.136C905.255,589.64 904.718,589.249 904.018,588.965C903.318,588.68 902.744,588.476 902.297,588.354C901.849,588.232 901.292,588.122 900.624,588.025C898.997,587.732 897.866,587.516 897.231,587.378C896.596,587.239 895.872,586.995 895.058,586.645C894.244,586.295 893.52,585.844 892.885,585.29C891.55,584.151 890.883,582.751 890.883,581.091C890.883,580.066 891.066,579.15 891.432,578.344C891.799,577.539 892.307,576.908 892.958,576.452C893.609,575.997 894.252,575.63 894.887,575.354C895.522,575.077 896.246,574.886 897.06,574.78C897.874,574.674 898.472,574.605 898.854,574.572C899.237,574.54 899.648,574.524 900.087,574.524C902.187,574.524 903.871,574.841 905.141,575.476C906.443,574.841 907.192,574.524 907.387,574.524C907.794,574.524 908.046,574.617 908.144,574.804C908.242,574.992 908.29,575.37 908.29,575.94L908.29,579.919C908.29,580.554 908.229,580.949 908.107,581.103C907.985,581.258 907.639,581.335 907.07,581.335C906.565,581.335 906.256,581.266 906.142,581.128C906.028,580.989 905.955,580.725 905.922,580.334C905.662,577.535 903.701,576.135 900.038,576.135C896.311,576.135 894.439,577.169 894.423,579.236C894.423,579.805 894.582,580.289 894.899,580.688C895.217,581.087 895.701,581.4 896.352,581.628C897.003,581.856 897.512,582.023 897.878,582.129C898.244,582.234 898.793,582.352 899.526,582.483C901.69,582.857 903.249,583.183 904.201,583.459C905.153,583.736 906.077,584.208 906.972,584.875C908.811,586.34 909.731,588.106 909.731,590.173C909.731,591.345 909.548,592.374 909.181,593.261C908.815,594.149 908.307,594.84 907.656,595.337C907.005,595.833 906.362,596.24 905.727,596.557C905.092,596.875 904.36,597.09 903.53,597.204C902.7,597.318 902.101,597.392 901.735,597.424C901.369,597.457 900.95,597.473 900.478,597.473C898.085,597.473 896.116,596.944 894.57,595.886L893.276,596.74C892.592,597.229 892.104,597.473 891.811,597.473L891.786,597.473C891.38,597.473 891.127,597.375 891.03,597.18C890.932,596.985 890.883,596.618 890.883,596.081Z" style="fill:rgb(20,20,20);fill-rule:nonzero;"/>
<path d="M913.515,596.081L913.515,590.637C913.515,590.002 913.572,589.599 913.686,589.428C913.8,589.258 914.142,589.172 914.711,589.172C915.118,589.172 915.395,589.209 915.541,589.282C915.688,589.355 915.81,589.555 915.908,589.88C916.103,590.58 916.327,591.198 916.579,591.736C916.831,592.273 917.218,592.883 917.739,593.567C918.259,594.25 918.976,594.775 919.887,595.141C920.798,595.508 921.873,595.691 923.11,595.691C926.902,595.691 928.806,594.437 928.823,591.931C928.823,591.231 928.635,590.633 928.261,590.136C927.887,589.64 927.35,589.249 926.65,588.965C925.95,588.68 925.376,588.476 924.929,588.354C924.481,588.232 923.923,588.122 923.256,588.025C921.629,587.732 920.497,587.516 919.863,587.378C919.228,587.239 918.504,586.995 917.69,586.645C916.876,586.295 916.152,585.844 915.517,585.29C914.182,584.151 913.515,582.751 913.515,581.091C913.515,580.066 913.698,579.15 914.064,578.344C914.43,577.539 914.939,576.908 915.59,576.452C916.241,575.997 916.884,575.63 917.519,575.354C918.154,575.077 918.878,574.886 919.692,574.78C920.506,574.674 921.104,574.605 921.486,574.572C921.869,574.54 922.28,574.524 922.719,574.524C924.819,574.524 926.503,574.841 927.773,575.476C929.075,574.841 929.824,574.524 930.019,574.524C930.426,574.524 930.678,574.617 930.776,574.804C930.873,574.992 930.922,575.37 930.922,575.94L930.922,579.919C930.922,580.554 930.861,580.949 930.739,581.103C930.617,581.258 930.271,581.335 929.701,581.335C929.197,581.335 928.888,581.266 928.774,581.128C928.66,580.989 928.587,580.725 928.554,580.334C928.294,577.535 926.332,576.135 922.67,576.135C918.943,576.135 917.071,577.169 917.055,579.236C917.055,579.805 917.214,580.289 917.531,580.688C917.848,581.087 918.333,581.4 918.984,581.628C919.635,581.856 920.143,582.023 920.51,582.129C920.876,582.234 921.425,582.352 922.158,582.483C924.322,582.857 925.881,583.183 926.833,583.459C927.785,583.736 928.709,584.208 929.604,584.875C931.443,586.34 932.363,588.106 932.363,590.173C932.363,591.345 932.18,592.374 931.813,593.261C931.447,594.149 930.938,594.84 930.287,595.337C929.636,595.833 928.993,596.24 928.359,596.557C927.724,596.875 926.992,597.09 926.161,597.204C925.331,597.318 924.733,597.392 924.367,597.424C924.001,597.457 923.582,597.473 923.11,597.473C920.717,597.473 918.748,596.944 917.201,595.886L915.908,596.74C915.224,597.229 914.736,597.473 914.443,597.473L914.418,597.473C914.011,597.473 913.759,597.375 913.661,597.18C913.564,596.985 913.515,596.618 913.515,596.081Z" style="fill:rgb(20,20,20);fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.541987,0,0,0.541987,660.419273,13.838813)">
<path d="M1023.994,665.998C1023.994,679.668 1024,693.336 1023.914,707.006C1023.847,718.519 1023.715,730.031 1023.403,741.541C1022.723,766.623 1021.247,791.924 1016.788,816.727C1012.265,841.89 1004.883,865.309 993.246,888.171C981.808,910.642 966.862,931.205 949.033,949.033C931.205,966.862 910.642,981.808 888.171,993.246C865.309,1004.883 841.89,1012.265 816.727,1016.788C791.924,1021.247 766.623,1022.723 741.541,1023.403C730.031,1023.715 718.519,1023.847 707.006,1023.914C693.336,1024 679.668,1023.994 665.998,1023.994L358.002,1023.994C344.332,1023.994 330.664,1024 316.994,1023.914C305.481,1023.847 293.969,1023.715 282.459,1023.403C257.377,1022.723 232.076,1021.247 207.272,1016.788C182.11,1012.265 158.691,1004.883 135.829,993.246C113.358,981.808 92.795,966.862 74.967,949.033C57.138,931.205 42.192,910.642 30.754,888.171C19.117,865.309 11.735,841.89 7.212,816.727C2.753,791.924 1.277,766.623 0.597,741.541C0.285,730.031 0.153,718.519 0.086,707.006C0,693.336 0.006,679.668 0.006,665.998L0.006,358.002C0.006,344.332 0,330.664 0.086,316.994C0.153,305.481 0.285,293.969 0.597,282.459C1.277,257.377 2.753,232.076 7.212,207.272C11.735,182.11 19.117,158.691 30.754,135.829C42.192,113.358 57.138,92.795 74.967,74.967C92.795,57.138 113.358,42.192 135.829,30.754C158.691,19.117 182.11,11.735 207.272,7.212C232.076,2.753 257.377,1.277 282.459,0.597C293.969,0.285 305.481,0.153 316.994,0.086C330.664,0 344.332,0.006 358.002,0.006L665.998,0.006C679.668,0.006 693.336,0 707.006,0.086C718.519,0.153 730.031,0.285 741.541,0.597C766.623,1.277 791.924,2.753 816.727,7.212C841.89,11.735 865.309,19.117 888.171,30.754C910.642,42.192 931.205,57.138 949.033,74.967C966.862,92.795 981.808,113.358 993.246,135.829C1004.883,158.691 1012.265,182.11 1016.788,207.272C1021.247,232.076 1022.723,257.377 1023.403,282.459C1023.715,293.969 1023.847,305.481 1023.914,316.994C1024,330.664 1023.994,344.332 1023.994,358.002L1023.994,665.998Z" style="fill:rgb(20,20,20);"/>
<g transform="matrix(1,0,0,1,0.001713,-0.000688)">
<g transform="matrix(0.939904,0,0,0.939904,30.769428,30.766742)">
<g transform="matrix(-0.232199,0,0,0.232199,1384.403797,-416.791824)">
<g transform="matrix(0.573992,-2.142166,2.142166,0.573992,-5516.07298,4432.117471)">
<path d="M1234.728,3305.937C871.642,3324.965 587,3624.915 587,3988.5C587,4365.734 893.266,4672 1270.5,4672C1647.734,4672 1954,4365.734 1954,3988.5C1954,3880.637 1928.472,3774.304 1879.503,3678.197L1794.243,3721.64C1836.356,3804.292 1858.31,3895.737 1858.31,3988.5C1858.31,4312.921 1594.921,4576.31 1270.5,4576.31C946.079,4576.31 682.69,4312.921 682.69,3988.5C682.69,3675.817 927.482,3417.86 1239.736,3401.496L1234.728,3305.937Z" style="fill:rgb(148,148,148);"/>
</g>
<g transform="matrix(1.261034,0,0,1.261034,1170.373407,-2014.261188)">
<path d="M1270.5,3305C893.266,3305 587,3611.266 587,3988.5C587,4365.734 893.266,4672 1270.5,4672C1647.734,4672 1954,4365.734 1954,3988.5C1954,3611.266 1647.734,3305 1270.5,3305ZM1270.5,3471.537C1555.819,3471.537 1787.463,3703.181 1787.463,3988.5C1787.463,4273.819 1555.819,4505.463 1270.5,4505.463C985.181,4505.463 753.537,4273.819 753.537,3988.5C753.537,3703.181 985.181,3471.537 1270.5,3471.537Z" style="fill:white;"/>
</g>
<g transform="matrix(1.261034,0,0,1.261034,3139.627182,-45.007413)">
<circle cx="1270.5" cy="3988.5" r="683.5" style="fill:white;"/>
</g>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 21 KiB

+24 -3
View File
@@ -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<package.json version>`:** 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<package.json version>`:** 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<version>` **or** the bare `<version>`; 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-<v>-arm64.dmg` + `.blockmap` | macOS installer |
| `LearnHarness-<v>-arm64-mac.zip` + `.blockmap` | macOS updater payload (Squirrel.Mac installs from the ZIP) |
| `LearnHarness-<v>-x64-setup.exe` + `.blockmap` | Windows NSIS installer |
| `LearnHarness-<v>-x86_64.AppImage` | Linux AppImage (no sidecar blockmap) |
| `LearnHarness-<v>-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.
+12
View File
@@ -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
+1 -1
View File
@@ -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",
+611
View File
@@ -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-<v>-arm64.dmg + .blockmap (createBlockmap)
* zip LearnHarness-<v>-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-<v>-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-<v>-x86_64.AppImage (no blockmap in 26.15.3)
* deb LearnHarness-<v>-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 <releases.json> --tag <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 <releases.json> <metadata-dir> --tag <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<string, unknown> {
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<Record<string, unknown>>) =>
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<string, ReleaseAsset>();
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<string, string>,
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 <releases.json> --tag <tag>",
" verify-release.ts verify <releases.json> <metadata-dir> --tag <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<string, string> = {};
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));
+454
View File
@@ -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> = {}): 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<string, string> {
const byName = new Map(release.assets.map((entry) => [entry.name, entry]));
const feeds: Record<string, string> = {};
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<string, unknown>) => void]> = [
["linux artifactName", (config) => { (config.linux as Record<string, unknown>).artifactName = "${name}-${version}-${arch}.${ext}"; }],
["win artifactName", (config) => { (config.win as Record<string, unknown>).artifactName = "${productName}-${version}-setup.${ext}"; }],
["mac artifactName", (config) => { (config.mac as Record<string, unknown>).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<string, unknown>).target = [{ target: "dmg", arch: ["arm64"] }]; }],
["win target", (config) => { (config.win as Record<string, unknown>).target = [{ target: "appx", arch: ["x64"] }]; }],
["linux targets", (config) => { (config.linux as Record<string, unknown>).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<string, unknown>;
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<string, string>, 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 });
}
});
});