CodeNomad/scripts/winget/resolve-release-asset.cjs
Pascal André 4e0b62bc7f
build: ship the Linux deb from Electron and the Windows installer without a ZIP (#955)
## Summary

One download per need, with fewer duplicate or unused assets:

| | Before | After |
|---|---|---|
| **Linux Electron** | portable `tar.gz` | **`.deb`** |
| **Linux Tauri** | `.deb` + AppImage | **AppImage** (self-updating) |
| **Windows Tauri** | ZIP wrapping the installer + the same installer as
`.exe` | **`.exe`** only |
| **Updater signatures** | `latest.json` + separate `.sig` assets |
**`latest.json` only** |

### Linux: Electron deb, Tauri AppImage
- The Electron deb installs as root, so electron-builder's post-install
script gives Chromium's `chrome-sandbox` the `root:root` ownership and
`4755` mode that a user-extracted archive cannot. Electron starts **with
its sandbox** and without any manual step. This fixes #541 properly.
- The package is named `codenomad` and **conflicts with / replaces** the
former Tauri deb (`code-nomad`). Both ship
`/usr/share/applications/CodeNomad.desktop`, so without this, installing
over an existing Tauri deb would fail on the shared file.
- The deb declares Electron's full runtime closure. electron-builder's
default `Depends` omit `libgbm1` and ALSA, which previously arrived only
through Recommends. ALSA is declared as `libasound2t64 | libasound2`,
because `libasound2` is a multi-provider virtual package on Ubuntu
24.04.
- Debian versions: dev builds (`<stable>-dev-<date>-<sha>`) become
`<stable>+dev.<date>.<HHMMSS commit time>.<sha>`, so they install as
upgrades after their stable base and in order on the same day; other
prereleases keep electron-builder's `~`, which sorts before the release.
The workflow rewrites the control `Version` with `dpkg-deb` when it
differs; PR and stable builds rewrite a copy to a synthetic dev version
and check that fields, files, modes, owners and control members are
preserved.
- Tauri Linux builds only the AppImage (`tauri build --bundles
appimage`).
- No unused electron-updater metadata is generated (`publish: null`).

### Windows: installer only
- The Tauri NSIS installer is published directly as
`CodeNomad-Tauri-windows-x64-<version>.exe`. It is also the signed
updater payload, so signed builds upload it once.
- The ZIP that wrapped the same installer is no longer published.
- The WinGet automation defaults now point to the `.exe`.

### Updater signatures
- The updater reads each signature from `latest.json`, never from a
`.sig` asset. Staging still verifies every signature and its trusted
comment and records it in the receipt, and publication still requires
every payload's uploaded digest to match.
- Releases no longer carry `.sig` assets, and staging deletes any left
on a rebuilt tag by older builds.

Rebuilt tags also delete obsolete Electron `tar.gz`, Tauri deb and
Windows ZIP assets.

### Docs
- **README:** shows the deb and AppImage commands. The AppImage uses the
static type2 runtime, so it needs no `libfuse2`;
`--appimage-extract-and-run` covers environments without FUSE. The
Wayland/NVIDIA command now targets the AppImage.
- **BUILD.md, `TAURI_UPDATER.md` and the WinGet guide** describe the new
formats.

## Migration note

Installing the Electron deb over a Tauri deb switches the app from Tauri
to Electron:
- `code-nomad` and `codenomad-tauri` are removed, and the launcher
becomes `CodeNomad`;
- server configuration is shared, but desktop window state is not
migrated.

Tauri deb users never had in-app updates, so the release notes should
say this. The AppImage is their Tauri option.

## Validation

- **Electron deb packaging (CI):** checks package, conflicts, replaces
and version metadata, the contents, the post-install `chmod 4755` step,
the resources/asar smoke checks and `ldd`.
- **Electron deb install (CI):** installs on Ubuntu 24.04 **without
recommends**, so `ldd` proves the declared `Depends` are enough. It
installs over a stand-in `code-nomad` package, then checks that the old
package was replaced, the owner of the desktop entry,
`/usr/bin/CodeNomad`, `chrome-sandbox` `root:root 4755` and shared
libraries.
- **Version mapping:** the mapping, the ordering against stable, later
releases and same-day builds, and both repack branches were checked with
dpkg 1.22.6 on Ubuntu 24.04. The repack keeps the control fields and
root ownership.
- **Dependencies:** every declared dependency resolves on Ubuntu 24.04,
either as a real package or through its t64 provider.
- **Updater scripts:** `updater-artifacts.test.mjs` passes, including
signature-in-manifest publication with no staged `.sig`.
- **Workflow:** the YAML parses.
- **Independent review:** no blocker. The major finding (missing deb
dependencies) and the minor ones (dev version ordering, stale BUILD.md
and README command, unchecked exit codes, unused updater metadata) are
addressed.

## Follow-ups outside this PR

- **WinGet:** the `WINGET_WINDOWS_ASSET_NAME_TEMPLATE` repository
variable still pins the `.zip` name and overrides the new default.
Before the next stable release:
  - update it to `.exe`, or delete it;
- check that the automated `winget-pkgs` PR declares a direct `nullsoft`
installer without the old ZIP fields, and correct it if not.

Until then, a stable release's WinGet job waits for the ZIP and fails
without side effects.
- **Ubuntu desktop trial:** test the Electron deb on a real Ubuntu
desktop.
2026-10-14 16:45:49 +02:00

201 lines
5.8 KiB
JavaScript

#!/usr/bin/env node
const crypto = require("node:crypto")
const fs = require("node:fs")
const { Readable } = require("node:stream")
function printHelp() {
console.log(`Usage: node scripts/winget/resolve-release-asset.cjs [options]
Options:
--repo <owner/repo> GitHub repository that owns the release
--release-id <id> Numeric GitHub release id
--tag <tag> Release tag (used for version derivation)
--asset-name-template <template> Expected asset template, use {version} placeholder
--asset-regex <regex> Override exact-match template with a regex
--timeout-seconds <seconds> Total polling timeout (default: 900)
--poll-interval-seconds <seconds> Poll interval (default: 15)
--token <token> Optional GitHub token for release API calls
--github-output <path> Write GitHub Actions outputs to this file
--json Print JSON result to stdout
--help Show this message
`)
}
function parseArgs(argv) {
const args = {}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
if (arg === "--help") {
args.help = true
continue
}
if (arg === "--json") {
args.json = true
continue
}
if (!arg.startsWith("--")) {
throw new Error(`Unexpected argument: ${arg}`)
}
const key = arg.slice(2)
const value = argv[index + 1]
if (!value || value.startsWith("--")) {
throw new Error(`Missing value for --${key}`)
}
args[key] = value
index += 1
}
return args
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function escapeRegex(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
function normalizeVersionFromTag(tag) {
if (!tag) {
throw new Error("A release tag is required to derive the package version")
}
return tag.startsWith("v") ? tag.slice(1) : tag
}
function buildMatcher({ version, assetNameTemplate, assetRegex }) {
if (assetRegex) {
return {
description: assetRegex,
regex: new RegExp(assetRegex),
}
}
const template = assetNameTemplate || "CodeNomad-Tauri-windows-x64-{version}.exe"
if (!template.includes("{version}")) {
throw new Error("asset-name-template must include the {version} placeholder")
}
const expectedName = template.replaceAll("{version}", version)
return {
description: expectedName,
regex: new RegExp(`^${escapeRegex(expectedName)}$`),
}
}
async function githubJson(url, token) {
const response = await fetch(url, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "CodeNomad-winget-release-automation",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
})
if (!response.ok) {
const body = await response.text()
throw new Error(`GitHub API request failed (${response.status}): ${body}`)
}
return response.json()
}
async function downloadAndHash(url) {
const response = await fetch(url)
if (!response.ok || !response.body) {
throw new Error(`Asset download failed (${response.status})`)
}
const hash = crypto.createHash("sha256")
const stream = Readable.fromWeb(response.body)
for await (const chunk of stream) {
hash.update(chunk)
}
return hash.digest("hex").toUpperCase()
}
async function main() {
const args = parseArgs(process.argv.slice(2))
if (args.help) {
printHelp()
return
}
const repo = args.repo
const releaseId = args["release-id"]
const tag = args.tag
const timeoutSeconds = Number(args["timeout-seconds"] || 900)
const pollIntervalSeconds = Number(args["poll-interval-seconds"] || 15)
if (!repo || !releaseId || !tag) {
throw new Error("--repo, --release-id, and --tag are required")
}
const version = normalizeVersionFromTag(tag)
const matcher = buildMatcher({
version,
assetNameTemplate: args["asset-name-template"],
assetRegex: args["asset-regex"],
})
const deadline = Date.now() + timeoutSeconds * 1000
const releaseUrl = `https://api.github.com/repos/${repo}/releases/${releaseId}`
const token = args.token || process.env.GITHUB_TOKEN || ""
let release
let asset
while (Date.now() <= deadline) {
release = await githubJson(releaseUrl, token)
const matches = (release.assets || []).filter(
(candidate) => matcher.regex.test(candidate.name) && candidate.state === "uploaded" && candidate.size > 0,
)
if (matches.length === 1) {
asset = matches[0]
break
}
if (matches.length > 1) {
throw new Error(`Matched multiple assets for ${matcher.description}: ${matches.map((item) => item.name).join(", ")}`)
}
const visibleAssets = (release.assets || []).map((item) => item.name).join(", ") || "no assets"
console.error(`Waiting for release asset ${matcher.description} on ${repo}@${tag}; currently saw ${visibleAssets}`)
await sleep(pollIntervalSeconds * 1000)
}
if (!asset || !release) {
throw new Error(`Timed out after ${timeoutSeconds}s waiting for release asset ${matcher.description}`)
}
const sha256 = await downloadAndHash(asset.browser_download_url)
const result = {
asset_name: asset.name,
asset_regex: matcher.regex.source,
asset_sha256: sha256,
asset_url: asset.browser_download_url,
release_url: release.html_url,
tag_name: release.tag_name,
version,
}
if (args["github-output"]) {
const lines = Object.entries(result).map(([key, value]) => `${key}=${value}`)
await fs.promises.appendFile(args["github-output"], `${lines.join("\n")}\n`, "utf8")
}
if (args.json) {
console.log(JSON.stringify(result, null, 2))
} else {
console.error(`Resolved ${result.asset_name} (${result.asset_sha256})`)
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
})