From b12f31273a8918425c6e38616ca599ea41a84f19 Mon Sep 17 00:00:00 2001 From: Mason Date: Fri, 29 May 2026 01:35:54 +0300 Subject: [PATCH 01/17] Spec: RPM/Fedora support design for DNF repository on gh-pages --- .../2026-05-29-rpm-fedora-support-design.md | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-29-rpm-fedora-support-design.md diff --git a/docs/superpowers/specs/2026-05-29-rpm-fedora-support-design.md b/docs/superpowers/specs/2026-05-29-rpm-fedora-support-design.md new file mode 100644 index 00000000..e3c31ce1 --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-rpm-fedora-support-design.md @@ -0,0 +1,70 @@ +# RPM/Fedora Support for NeverWrite Desktop + +## Problem + +NeverWrite desktop publishes `.deb` packages and hosts an APT repository for +Ubuntu/Debian users, but has no support for Fedora/RHEL users who need `.rpm` +packages. + +## Scope + +Add full Fedora/RHEL support to the NeverWrite release pipeline: + +1. Build `.rpm` packages for `x86_64` and `aarch64` architectures +2. Publish RPMs as GitHub Release assets +3. Host a DNF repository on `gh-pages` with signed metadata +4. Validate RPM packages and DNF repo metadata in CI + +## Design + +### Package naming + +Same convention as the Debian packages, using RPM architecture names: + +- `NeverWrite-{version}-x86_64.rpm` (amd64 host) +- `NeverWrite-{version}-aarch64.rpm` (arm64 host) + +No distribution tag (`.fc40`, `.el10`) for cross-distro compatibility. + +### DNF repository layout on `gh-pages` + +Only metadata is stored on `gh-pages` — RPM binaries stay on GitHub Releases, +mirroring the APT "remote packages" pattern: + +``` +dnf/ + neverwrite-archive-keyring.asc + neverwrite.repo.example + repodata/ + repomd.xml + repomd.xml.asc + primary.xml.gz + filelists.xml.gz + other.xml.gz +``` + +The `` in `primary.xml` for each package is an absolute GitHub +Release download URL: +``` +https://github.com/jsgrrchg/NeverWrite/releases/download/vX.Y.Z/NeverWrite-X.Y.Z-x86_64.rpm +``` + +### User repository configuration + +```ini +[neverwrite] +name=NeverWrite +baseurl=https://jsgrrchg.github.io/NeverWrite/dnf +enabled=1 +gpgcheck=1 +gpgkey=https://jsgrrchg.github.io/NeverWrite/dnf/neverwrite-archive-keyring.asc +``` + +### GPG signing + +The DNF repository uses the same GPG signing key (`APT_REPO_GPG_PRIVATE_KEY`) +as the APT repository. The detached signature is `repomd.xml.asc`. + +### Implementation plan + +See implementation-plan.md for the detailed step-by-step breakdown. From bd19c673997ac6746e557973a5737456df988895 Mon Sep 17 00:00:00 2001 From: Mason Date: Fri, 29 May 2026 01:39:04 +0300 Subject: [PATCH 02/17] feat(rpm): add RPM naming conventions for build targets --- scripts/appcast-lib.mjs | 22 +++++++++++++++++ scripts/appcast.test.mjs | 41 ++++++++++++++++++++++++++++++++ scripts/electron-release-lib.mjs | 6 +++++ 3 files changed, 69 insertions(+) diff --git a/scripts/appcast-lib.mjs b/scripts/appcast-lib.mjs index 99b1d3dd..141a3d8d 100644 --- a/scripts/appcast-lib.mjs +++ b/scripts/appcast-lib.mjs @@ -143,6 +143,28 @@ export function buildDebianPackageAssetName(version, buildTarget) { return `${PUBLIC_PRODUCT_NAME}-${normalizedVersion}-${debianArchForBuildTarget(buildTarget)}.deb`; } +export function rpmArchForBuildTarget(buildTarget) { + switch (buildTarget) { + case "aarch64-unknown-linux-gnu": + return "aarch64"; + case "x86_64-unknown-linux-gnu": + return "x86_64"; + default: + throw new Error( + `RPM packages are only supported for Linux build targets, received "${buildTarget}".`, + ); + } +} + +export function buildRpmPackageAssetName(version, buildTarget) { + const normalizedVersion = normalizeReleaseVersion(version); + return `${PUBLIC_PRODUCT_NAME}-${normalizedVersion}-${rpmArchForBuildTarget(buildTarget)}.rpm`; +} + +export function describeRpmPackage(buildTarget) { + return `RPM package (.rpm) for ${rpmArchForBuildTarget(buildTarget)}`; +} + export function getCanonicalAppBundleName() { return `${PUBLIC_PRODUCT_NAME}.app`; } diff --git a/scripts/appcast.test.mjs b/scripts/appcast.test.mjs index 8521ebba..0a368c31 100644 --- a/scripts/appcast.test.mjs +++ b/scripts/appcast.test.mjs @@ -5,17 +5,20 @@ import { CANONICAL_RELEASE_PAGES_BASE_URL, CANONICAL_RELEASE_REPO_SLUG, buildDebianPackageAssetName, + buildRpmPackageAssetName, buildUpdaterReleaseAssetName, buildChannelAppcastUrl, buildGitHubPagesBaseUrl, buildPublicReleaseAssetName, createStaticAppcastManifest, + describeRpmPackage, describeUpdaterArtifactKind, getCanonicalAppBundleName, getBundledUpdaterArtifactName, getAppcastPublishPath, getSignatureAssetName, normalizePlatformEntries, + rpmArchForBuildTarget, } from "./appcast-lib.mjs"; test("buildGitHubPagesBaseUrl returns the project pages base URL", () => { @@ -70,6 +73,44 @@ test("buildDebianPackageAssetName uses Debian architecture names", () => { ); }); +test("buildRpmPackageAssetName uses RPM architecture names", () => { + assert.equal( + buildRpmPackageAssetName("0.3.0", "x86_64-unknown-linux-gnu"), + "NeverWrite-0.3.0-x86_64.rpm", + ); + assert.equal( + buildRpmPackageAssetName("0.3.0", "aarch64-unknown-linux-gnu"), + "NeverWrite-0.3.0-aarch64.rpm", + ); +}); + +test("rpmArchForBuildTarget uses RPM conventions", () => { + assert.equal(rpmArchForBuildTarget("x86_64-unknown-linux-gnu"), "x86_64"); + assert.equal(rpmArchForBuildTarget("aarch64-unknown-linux-gnu"), "aarch64"); +}); + +test("describeRpmPackage returns human-readable RPM description", () => { + assert.equal( + describeRpmPackage("x86_64-unknown-linux-gnu"), + "RPM package (.rpm) for x86_64", + ); + assert.equal( + describeRpmPackage("aarch64-unknown-linux-gnu"), + "RPM package (.rpm) for aarch64", + ); +}); + +test("rpmArchForBuildTarget rejects non-Linux build targets", () => { + assert.throws( + () => rpmArchForBuildTarget("universal-apple-darwin"), + /RPM packages are only supported/i, + ); + assert.throws( + () => rpmArchForBuildTarget("x86_64-pc-windows-msvc"), + /RPM packages are only supported/i, + ); +}); + test("describeUpdaterArtifactKind documents updater archive families", () => { assert.equal( describeUpdaterArtifactKind("universal-apple-darwin"), diff --git a/scripts/electron-release-lib.mjs b/scripts/electron-release-lib.mjs index a737cc1e..c4f77d7a 100644 --- a/scripts/electron-release-lib.mjs +++ b/scripts/electron-release-lib.mjs @@ -4,9 +4,12 @@ import { buildDebianPackageAssetName, buildGitHubReleaseAssetUrl, buildPublicReleaseAssetName, + buildRpmPackageAssetName, debianArchForBuildTarget, + describeRpmPackage, normalizeAppcastChannel, normalizeReleaseVersion, + rpmArchForBuildTarget, } from "./appcast-lib.mjs"; export { @@ -15,9 +18,12 @@ export { buildDebianPackageAssetName, buildGitHubReleaseAssetUrl, buildPublicReleaseAssetName, + buildRpmPackageAssetName, debianArchForBuildTarget, + describeRpmPackage, normalizeAppcastChannel, normalizeReleaseVersion, + rpmArchForBuildTarget, }; export const ELECTRON_BUILD_TARGETS = [ From 6f3fb434016e9859fa3a8cc5794e6411a09b9a18 Mon Sep 17 00:00:00 2001 From: Mason Date: Fri, 29 May 2026 01:44:56 +0300 Subject: [PATCH 03/17] feat(rpm): add RPM to electron-builder Linux targets --- apps/desktop/electron-builder.config.mjs | 7 ++++++- apps/desktop/scripts/electron-builder-config.test.mjs | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index dcf1f4d8..8bf3c2b8 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -208,7 +208,7 @@ export default { }, linux: { icon: path.join("build", "icons", "icon.png"), - target: ["AppImage", "deb"], + target: ["AppImage", "deb", "rpm"], category: "Utility", executableName: "neverwrite", artifactName: "${productName}-${version}-${arch}.AppImage", @@ -227,4 +227,9 @@ export default { artifactName: "${productName}-${version}-${arch}.deb", publish: null, }, + rpm: { + packageName: "neverwrite", + artifactName: "${productName}-${version}-${arch}.rpm", + publish: null, + }, }; diff --git a/apps/desktop/scripts/electron-builder-config.test.mjs b/apps/desktop/scripts/electron-builder-config.test.mjs index 060a03a2..496b5129 100644 --- a/apps/desktop/scripts/electron-builder-config.test.mjs +++ b/apps/desktop/scripts/electron-builder-config.test.mjs @@ -51,7 +51,7 @@ test("desktop app icons are wired for all packaged platforms", () => { assert.equal(config.mac.icon, "build/icons/icon.icns"); assert.equal(config.win.icon, "build/icons/icon.ico"); assert.equal(config.linux.icon, "build/icons/icon.png"); - assert.deepEqual(config.linux.target, ["AppImage", "deb"]); + assert.deepEqual(config.linux.target, ["AppImage", "deb", "rpm"]); assert.equal(config.nsis.installerIcon, "build/icons/icon.ico"); assert.equal(config.nsis.uninstallerIcon, "build/icons/icon.ico"); assert.equal(config.nsis.installerHeaderIcon, "build/icons/icon.ico"); From 4fb4cb3cc40dce08a9553bbb07d3ab598d920ed8 Mon Sep 17 00:00:00 2001 From: Mason Date: Fri, 29 May 2026 01:46:13 +0300 Subject: [PATCH 04/17] feat(rpm): add RPM package validation script --- .../scripts/validate-linux-rpm-package.mjs | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 apps/desktop/scripts/validate-linux-rpm-package.mjs diff --git a/apps/desktop/scripts/validate-linux-rpm-package.mjs b/apps/desktop/scripts/validate-linux-rpm-package.mjs new file mode 100644 index 00000000..043a94a2 --- /dev/null +++ b/apps/desktop/scripts/validate-linux-rpm-package.mjs @@ -0,0 +1,111 @@ +import childProcess from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { + buildRpmPackageAssetName, + normalizeReleaseVersion, + rpmArchForBuildTarget, +} from "../../../scripts/electron-release-lib.mjs"; + +function parseArgs(argv) { + const args = { + stagedAssetsDir: null, + buildTarget: null, + version: null, + skipInstall: false, + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const next = argv[index + 1] ?? null; + if (arg === "--staged-assets-dir") { + args.stagedAssetsDir = path.resolve(next); + index += 1; + continue; + } + if (arg === "--target") { + args.buildTarget = next; + index += 1; + continue; + } + if (arg === "--version") { + args.version = next; + index += 1; + continue; + } + if (arg === "--skip-install") { + args.skipInstall = true; + continue; + } + throw new Error(`Unknown argument: ${arg}`); + } + if (!args.stagedAssetsDir) throw new Error("Missing --staged-assets-dir"); + if (!args.buildTarget) throw new Error("Missing --target"); + if (!args.version) throw new Error("Missing --version"); + return { ...args, version: normalizeReleaseVersion(args.version) }; +} + +function findRpmPackage(stagedAssetsDir, assetName) { + const matches = []; + for (const entry of fs.readdirSync(stagedAssetsDir, { withFileTypes: true })) { + if (entry.isFile() && entry.name === assetName) { + matches.push(path.join(stagedAssetsDir, entry.name)); + } + } + if (matches.length !== 1) { + throw new Error(`Expected exactly one RPM package named ${assetName}, found ${matches.length}.`); + } + return matches[0]; +} + +function runCommand(command, args, options = {}) { + const result = childProcess.spawnSync(command, args, { + encoding: "utf8", + maxBuffer: 1024 * 1024 * 16, + ...options, + }); + if (result.status !== 0) { + throw new Error( + `Command failed: ${command} ${args.join(" ")}\n${result.stderr?.trim() || result.stdout?.trim()}`, + ); + } + return result.stdout ?? ""; +} + +function assertRpmAvailable() { + runCommand("rpm", ["--version"]); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + assertRpmAvailable(); + + const expectedAssetName = buildRpmPackageAssetName(args.version, args.buildTarget); + const rpmPath = findRpmPackage(args.stagedAssetsDir, expectedAssetName); + + const expectedArch = rpmArchForBuildTarget(args.buildTarget); + + runCommand("rpm", ["-K", rpmPath]); + const info = runCommand("rpm", ["-qip", rpmPath]); + const files = runCommand("rpm", ["-qlp", rpmPath]); + + const nameMatch = info.match(/^Name\s*:\s*(\S+)/m); + if (!nameMatch || nameMatch[1] !== "neverwrite") { + throw new Error(`RPM package name is not "neverwrite": ${info}`); + } + + const archMatch = info.match(/^Architecture\s*:\s*(\S+)/m); + if (!archMatch || archMatch[1] !== expectedArch) { + throw new Error( + `RPM architecture mismatch: expected ${expectedArch}, got ${archMatch ? archMatch[1] : "missing"}`, + ); + } + + console.log(`RPM package validated: ${rpmPath}`); + console.log(` Name: neverwrite`); + console.log(` Architecture: ${expectedArch}`); + console.log(` Version: ${args.version}`); + console.log(` Package info:\n${info}`); + console.log(` File list:\n${files}`); +} + +main(); From 8185633a372d1a4febce549f138ddaff3d2fc39d Mon Sep 17 00:00:00 2001 From: Mason Date: Fri, 29 May 2026 01:46:44 +0300 Subject: [PATCH 05/17] feat(rpm): enforce RPM package presence in platform validation --- scripts/platform-validation-lib.mjs | 14 ++++++++++ scripts/platform-validation.test.mjs | 40 +++++++++++++++++----------- 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/scripts/platform-validation-lib.mjs b/scripts/platform-validation-lib.mjs index 59b6f99a..d19a43b7 100644 --- a/scripts/platform-validation-lib.mjs +++ b/scripts/platform-validation-lib.mjs @@ -7,6 +7,7 @@ import { ELECTRON_BUILD_TARGETS, buildPublishedFeedUrl, buildDebianPackageAssetName, + buildRpmPackageAssetName, describeBuildTarget, describeUpdaterArtifactKind, feedTargetForBuildTarget, @@ -160,6 +161,19 @@ function validateAdditionalManualAssets(entry, resolved) { `Target metadata for ${resolved.buildTarget} must include Debian package ${expectedDebAssetName} in additionalManualAssets.`, ); } + + const expectedRpmAssetName = buildRpmPackageAssetName( + entry.version, + resolved.buildTarget, + ); + const hasRpmAsset = assets.some( + (asset) => asset.kind === "rpm" && asset.assetName === expectedRpmAssetName, + ); + if (!hasRpmAsset) { + throw new Error( + `Target metadata for ${resolved.buildTarget} must include RPM package ${expectedRpmAssetName} in additionalManualAssets.`, + ); + } } return assets; diff --git a/scripts/platform-validation.test.mjs b/scripts/platform-validation.test.mjs index 9036b79b..11ec75d3 100644 --- a/scripts/platform-validation.test.mjs +++ b/scripts/platform-validation.test.mjs @@ -62,11 +62,8 @@ function buildMetadataEntries() { updaterUrl: "https://github.com/jsgrrchg/NeverWrite/releases/download/v0.2.0/NeverWrite-0.2.0-arm64.AppImage", additionalManualAssets: [ - { - kind: "deb", - assetName: "NeverWrite-0.2.0-arm64.deb", - sizeBytes: 456, - }, + { kind: "deb", assetName: "NeverWrite-0.2.0-arm64.deb", sizeBytes: 456 }, + { kind: "rpm", assetName: "NeverWrite-0.2.0-aarch64.rpm", sizeBytes: 789 }, ], }, { @@ -81,11 +78,8 @@ function buildMetadataEntries() { updaterUrl: "https://github.com/jsgrrchg/NeverWrite/releases/download/v0.2.0/NeverWrite-0.2.0-x64.AppImage", additionalManualAssets: [ - { - kind: "deb", - assetName: "NeverWrite-0.2.0-amd64.deb", - sizeBytes: 123, - }, + { kind: "deb", assetName: "NeverWrite-0.2.0-amd64.deb", sizeBytes: 123 }, + { kind: "rpm", assetName: "NeverWrite-0.2.0-x86_64.rpm", sizeBytes: 456 }, ], }, ]; @@ -156,11 +150,8 @@ test("buildPlatformValidationMatrix aligns feed URLs with target metadata", () = "NeverWrite-0.2.0-x64.AppImage", ); assert.deepEqual(rows[4].additionalManualAssets, [ - { - kind: "deb", - assetName: "NeverWrite-0.2.0-amd64.deb", - sizeBytes: 123, - }, + { kind: "deb", assetName: "NeverWrite-0.2.0-amd64.deb", sizeBytes: 123 }, + { kind: "rpm", assetName: "NeverWrite-0.2.0-x86_64.rpm", sizeBytes: 456 }, ]); }); @@ -202,13 +193,30 @@ test("renderPlatformValidationChecklist includes invalid-checksum fixtures", () markdown, /Additional manual asset \(deb\): `NeverWrite-0\.2\.0-amd64\.deb`/, ); + assert.match( + markdown, + /Additional manual asset \(rpm\): `NeverWrite-0\.2\.0-x86_64\.rpm`/, + ); +}); + +test("validateTargetMetadataEntries requires Debian and RPM package metadata for Linux", () => { + const entries = buildMetadataEntries(); + entries[4] = { + ...entries[4], + additionalManualAssets: [{ kind: "deb", assetName: "NeverWrite-0.2.0-amd64.deb", sizeBytes: 123 }], + }; + + assert.throws( + () => validateTargetMetadataEntries(entries), + /must include RPM package NeverWrite-0\.2\.0-x86_64\.rpm/i, + ); }); test("validateTargetMetadataEntries requires Debian package metadata for Linux", () => { const entries = buildMetadataEntries(); entries[4] = { ...entries[4], - additionalManualAssets: [], + additionalManualAssets: [{ kind: "rpm", assetName: "NeverWrite-0.2.0-x86_64.rpm", sizeBytes: 456 }], }; assert.throws( From 6d85d684481285500c0374f5578453c7a1495077 Mon Sep 17 00:00:00 2001 From: Mason Date: Fri, 29 May 2026 01:46:53 +0300 Subject: [PATCH 06/17] feat(rpm): include RPM packages in release download table --- scripts/release-assets-lib.mjs | 62 +++++++++++++++++++++++++-------- scripts/release-assets.test.mjs | 17 +++++++++ 2 files changed, 64 insertions(+), 15 deletions(-) diff --git a/scripts/release-assets-lib.mjs b/scripts/release-assets-lib.mjs index 99ac00a7..d268f763 100644 --- a/scripts/release-assets-lib.mjs +++ b/scripts/release-assets-lib.mjs @@ -4,8 +4,10 @@ import path from "node:path"; import { BUILD_TARGET_TO_APPCAST_KEY, buildDebianPackageAssetName, + buildRpmPackageAssetName, buildUpdaterReleaseAssetName, debianArchForBuildTarget, + rpmArchForBuildTarget, buildGitHubReleaseAssetUrl, buildPublicReleaseAssetName, getCanonicalAppBundleName, @@ -40,6 +42,16 @@ export const PUBLIC_DOWNLOAD_VARIANTS = [ platformLabel: "Linux", architectureLabel: "x64", }, + { + buildTarget: "aarch64-unknown-linux-gnu", + platformLabel: "Linux Fedora/RHEL", + architectureLabel: "ARM64", + }, + { + buildTarget: "x86_64-unknown-linux-gnu", + platformLabel: "Linux Fedora/RHEL", + architectureLabel: "x64", + }, ]; export const WEB_CLIPPER_RELEASE_BROWSERS = ["chrome", "firefox"]; @@ -244,28 +256,39 @@ export function buildManualDownloadRows(version) { const normalizedVersion = normalizeReleaseVersion(version); return PUBLIC_DOWNLOAD_VARIANTS.map((variant) => ({ ...variant, - ...(targetPlatformFamily(variant.buildTarget) === "linux" + ...(variant.platformLabel === "Linux Fedora/RHEL" ? { - platformLabel: "Linux Ubuntu/Debian", - architectureLabel: debianArchForBuildTarget( - variant.buildTarget, - ), - recommendedAssetName: buildDebianPackageAssetName( - normalizedVersion, + architectureLabel: rpmArchForBuildTarget( variant.buildTarget, ), - portableAssetName: buildPublicReleaseAssetName( - normalizedVersion, - variant.buildTarget, - ), - } - : { - recommendedAssetName: buildPublicReleaseAssetName( + recommendedAssetName: buildRpmPackageAssetName( normalizedVersion, variant.buildTarget, ), portableAssetName: null, - }), + } + : targetPlatformFamily(variant.buildTarget) === "linux" + ? { + platformLabel: "Linux Ubuntu/Debian", + architectureLabel: debianArchForBuildTarget( + variant.buildTarget, + ), + recommendedAssetName: buildDebianPackageAssetName( + normalizedVersion, + variant.buildTarget, + ), + portableAssetName: buildPublicReleaseAssetName( + normalizedVersion, + variant.buildTarget, + ), + } + : { + recommendedAssetName: buildPublicReleaseAssetName( + normalizedVersion, + variant.buildTarget, + ), + portableAssetName: null, + }), })); } @@ -318,6 +341,15 @@ export function buildReleaseBody(version, releaseNotes) { "sudo apt install neverwrite", "```", "", + "For Fedora/RHEL, use the `.rpm` package directly or configure the NeverWrite DNF repository for future system updates.", + "DNF repository setup:", + "", + "```bash", + "sudo dnf config-manager --add-repo https://jsgrrchg.github.io/NeverWrite/dnf/neverwrite.repo.example", + "sudo rpm --import https://jsgrrchg.github.io/NeverWrite/dnf/neverwrite-archive-keyring.asc", + "sudo dnf install neverwrite", + "```", + "", "For other Linux distributions or portable use, download the AppImage.", "", "Updater artifacts are also attached to the release for in-app updates.", diff --git a/scripts/release-assets.test.mjs b/scripts/release-assets.test.mjs index b8f8903e..a731796f 100644 --- a/scripts/release-assets.test.mjs +++ b/scripts/release-assets.test.mjs @@ -105,6 +105,20 @@ test("buildManualDownloadRows exposes the public installer set for humans", () = recommendedAssetName: "NeverWrite-0.2.0-amd64.deb", portableAssetName: "NeverWrite-0.2.0-x64.AppImage", }, + { + buildTarget: "aarch64-unknown-linux-gnu", + platformLabel: "Linux Fedora/RHEL", + architectureLabel: "aarch64", + recommendedAssetName: "NeverWrite-0.2.0-aarch64.rpm", + portableAssetName: null, + }, + { + buildTarget: "x86_64-unknown-linux-gnu", + platformLabel: "Linux Fedora/RHEL", + architectureLabel: "x86_64", + recommendedAssetName: "NeverWrite-0.2.0-x86_64.rpm", + portableAssetName: null, + }, ]); }); @@ -118,6 +132,9 @@ test("buildReleaseBody distinguishes manual installers from internal updater ass assert.match(body, /NeverWrite_0.2.0_Windows_x64_Setup\.exe/); assert.match(body, /NeverWrite-0.2.0-amd64\.deb/); assert.match(body, /NeverWrite-0.2.0-x64\.AppImage/); + assert.match(body, /NeverWrite-0\.2\.0-x86_64\.rpm/); + assert.match(body, /configure the NeverWrite DNF repository/); + assert.match(body, /dnf config-manager --add-repo/); assert.match(body, /configure the NeverWrite APT repository/); assert.match(body, /neverwrite-archive-keyring\.asc/); assert.match(body, /internal updater assets/i); From 85b536d032c927734d7f7701da8287a743c22330 Mon Sep 17 00:00:00 2001 From: Mason Date: Fri, 29 May 2026 01:47:00 +0300 Subject: [PATCH 07/17] feat(rpm): validate RPM configuration in electron-builder checks --- scripts/release-metadata-lib.mjs | 27 ++++++++++++++++ scripts/release-metadata.test.mjs | 51 +++++++++++++++++++++++++++++-- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/scripts/release-metadata-lib.mjs b/scripts/release-metadata-lib.mjs index d54533c6..43091b31 100644 --- a/scripts/release-metadata-lib.mjs +++ b/scripts/release-metadata-lib.mjs @@ -275,6 +275,11 @@ export function collectElectronBuildIssues(config) { 'electron-builder.config.mjs linux.target must include "deb".', ); } + if (!linuxTargets.has("rpm")) { + issues.push( + 'electron-builder.config.mjs linux.target must include "rpm".', + ); + } if (linuxTargets.has("deb") || config.deb != null) { if (config.deb?.packageName !== "neverwrite") { issues.push( @@ -302,6 +307,28 @@ export function collectElectronBuildIssues(config) { } } + if (linuxTargets.has("rpm") || config.rpm != null) { + if (config.rpm?.packageName !== "neverwrite") { + issues.push( + 'electron-builder.config.mjs rpm.packageName must be "neverwrite".', + ); + } + if ( + typeof config.rpm?.artifactName !== "string" || + !config.rpm.artifactName.endsWith(".rpm") || + !config.rpm.artifactName.includes("${arch}") + ) { + issues.push( + 'electron-builder.config.mjs rpm.artifactName must include "${arch}" and end with ".rpm".', + ); + } + if (config.rpm?.publish !== null) { + issues.push( + "electron-builder.config.mjs rpm.publish must be null because RPM packages are manual-only in this release phase.", + ); + } + } + return issues; } diff --git a/scripts/release-metadata.test.mjs b/scripts/release-metadata.test.mjs index aa211910..01d00952 100644 --- a/scripts/release-metadata.test.mjs +++ b/scripts/release-metadata.test.mjs @@ -121,7 +121,7 @@ test("collectElectronBuildIssues validates the Electron release contract", () => target: [{ target: "nsis" }], }, linux: { - target: [{ target: "AppImage" }, { target: "deb" }], + target: [{ target: "AppImage" }, { target: "deb" }, { target: "rpm" }], }, deb: { packageName: "neverwrite", @@ -129,6 +129,11 @@ test("collectElectronBuildIssues validates the Electron release contract", () => priority: "optional", publish: null, }, + rpm: { + packageName: "neverwrite", + artifactName: "${productName}-${version}-${arch}.rpm", + publish: null, + }, }), [], ); @@ -158,11 +163,12 @@ test("collectElectronBuildIssues validates the Electron release contract", () => 'electron-builder.config.mjs mac.target must include "zip".', 'electron-builder.config.mjs win.target must include "nsis".', 'electron-builder.config.mjs linux.target must include "deb".', + 'electron-builder.config.mjs linux.target must include "rpm".', ], ); }); -test("collectElectronBuildIssues validates Debian package metadata", () => { +test("collectElectronBuildIssues validates Debian and RPM package metadata", () => { assert.deepEqual( collectElectronBuildIssues({ artifactName: "${productName}-${version}-${os}-${arch}.${ext}", @@ -182,7 +188,37 @@ test("collectElectronBuildIssues validates Debian package metadata", () => { target: ["nsis"], }, linux: { - target: ["AppImage", "deb"], + target: ["AppImage", "deb", "rpm"], + }, + deb: { + packageName: "neverwrite", + artifactName: "${productName}-${version}-${arch}.deb", + priority: "optional", + publish: null, + }, + rpm: { + packageName: "neverwrite", + artifactName: "${productName}-${version}-${arch}.rpm", + publish: null, + }, + }), + [], + ); + + assert.deepEqual( + collectElectronBuildIssues({ + artifactName: "${productName}-${version}.${ext}", + protocols: [], + extraResources: [], + mac: { + minimumSystemVersion: "11.0", + target: ["dmg"], + }, + win: { + target: [], + }, + linux: { + target: ["AppImage"], }, deb: { packageName: "NeverWrite", @@ -192,6 +228,15 @@ test("collectElectronBuildIssues validates Debian package metadata", () => { }, }), [ + 'electron-builder.config.mjs must register the "neverwrite" protocol.', + 'electron-builder.config.mjs must stage "out/native-backend" into the packaged "native-backend" resources directory.', + 'electron-builder.config.mjs mac.minimumSystemVersion must be "12.0".', + 'electron-builder.config.mjs artifactName must include "${arch}" to avoid multi-architecture asset collisions.', + "electron-builder.config.mjs must configure afterPack bundle verification.", + 'electron-builder.config.mjs mac.target must include "zip".', + 'electron-builder.config.mjs win.target must include "nsis".', + 'electron-builder.config.mjs linux.target must include "deb".', + 'electron-builder.config.mjs linux.target must include "rpm".', 'electron-builder.config.mjs deb.packageName must be "neverwrite".', 'electron-builder.config.mjs deb.artifactName must include "${arch}" and end with ".deb".', 'electron-builder.config.mjs deb.priority must be "optional".', From f42fb916df1a9ece7aaab9ccbad237d7c2fb05cd Mon Sep 17 00:00:00 2001 From: Mason Date: Fri, 29 May 2026 01:48:52 +0300 Subject: [PATCH 08/17] feat(rpm): stage RPM artifacts alongside DEB in release flow --- .../scripts/stage-electron-release-assets.mjs | 28 ++++-- .../stage-electron-release-assets.test.mjs | 88 ++++++++++++++++--- 2 files changed, 101 insertions(+), 15 deletions(-) diff --git a/apps/desktop/scripts/stage-electron-release-assets.mjs b/apps/desktop/scripts/stage-electron-release-assets.mjs index 0e28911c..4a8e0d39 100644 --- a/apps/desktop/scripts/stage-electron-release-assets.mjs +++ b/apps/desktop/scripts/stage-electron-release-assets.mjs @@ -9,7 +9,9 @@ import { buildElectronUpdaterAssetName, buildGitHubReleaseAssetUrl, buildPublicReleaseAssetName, + buildRpmPackageAssetName, debianArchForBuildTarget, + describeRpmPackage, feedTargetForBuildTarget, metadataFileNameForBuildTarget, } from "../../../scripts/electron-release-lib.mjs"; @@ -211,6 +213,12 @@ function collectArtifacts(distDir, buildTarget, version) { (filePath) => path.basename(filePath) === expectedDebAssetName, `${debianArchForBuildTarget(buildTarget)} Debian package named ${expectedDebAssetName}`, ); + const expectedRpmAssetName = buildRpmPackageAssetName(version, buildTarget); + const rpmPackagePath = findSingleFile( + distDir, + (filePath) => path.basename(filePath) === expectedRpmAssetName, + `${describeRpmPackage(buildTarget)} named ${expectedRpmAssetName}`, + ); return { feedPath, @@ -218,10 +226,8 @@ function collectArtifacts(distDir, buildTarget, version) { updaterAssetPath: appImagePath, blockmapPath, additionalManualArtifacts: [ - { - kind: "deb", - sourcePath: debPackagePath, - }, + { kind: "deb", sourcePath: debPackagePath }, + { kind: "rpm", sourcePath: rpmPackagePath }, ], }; } @@ -490,7 +496,8 @@ function shouldKeepFeedArtifact(sourceValue, buildTarget) { if ( buildTarget.endsWith("-unknown-linux-gnu") && typeof sourceValue === "string" && - sourceValue.toLowerCase().endsWith(".deb") + (sourceValue.toLowerCase().endsWith(".deb") || + sourceValue.toLowerCase().endsWith(".rpm")) ) { return false; } @@ -530,6 +537,17 @@ function stageAdditionalManualAssets({ outputDir, }) { return artifacts.additionalManualArtifacts.map((artifact) => { + if (artifact.kind === "rpm") { + const assetName = buildRpmPackageAssetName(version, target); + const destinationPath = path.join(outputDir, assetName); + copyIfNeeded(artifact.sourcePath, destinationPath); + return { + kind: artifact.kind, + assetName, + sizeBytes: fileSizeInBytes(destinationPath), + }; + } + if (artifact.kind !== "deb") { throw new Error( `Unsupported additional manual artifact kind "${artifact.kind}".`, diff --git a/apps/desktop/scripts/stage-electron-release-assets.test.mjs b/apps/desktop/scripts/stage-electron-release-assets.test.mjs index 65264afd..35715303 100644 --- a/apps/desktop/scripts/stage-electron-release-assets.test.mjs +++ b/apps/desktop/scripts/stage-electron-release-assets.test.mjs @@ -278,6 +278,10 @@ test("stage-electron-release-assets stages Linux AppImage feeds", () => { path.join(distDir, "NeverWrite-0.2.0-amd64.deb"), "deb package", ); + writeFile( + path.join(distDir, "NeverWrite-0.2.0-x86_64.rpm"), + "rpm package", + ); writeFile( path.join(distDir, "latest-linux.yml"), [ @@ -332,11 +336,8 @@ test("stage-electron-release-assets stages Linux AppImage feeds", () => { assert.equal(metadata.manualAssetName, "NeverWrite-0.2.0-x64.AppImage"); assert.equal(metadata.updaterAssetName, "NeverWrite-0.2.0-x64.AppImage"); assert.deepEqual(metadata.additionalManualAssets, [ - { - kind: "deb", - assetName: "NeverWrite-0.2.0-amd64.deb", - sizeBytes: 11, - }, + { kind: "deb", assetName: "NeverWrite-0.2.0-amd64.deb", sizeBytes: 11 }, + { kind: "rpm", assetName: "NeverWrite-0.2.0-x86_64.rpm", sizeBytes: 11 }, ]); assert.equal(metadata.updaterBlockmapAssetName, null); assert.equal(metadata.updaterBlockmapSizeBytes, 0); @@ -346,6 +347,10 @@ test("stage-electron-release-assets stages Linux AppImage feeds", () => { fs.existsSync(path.join(outputDir, "NeverWrite-0.2.0-amd64.deb")), true, ); + assert.equal( + fs.existsSync(path.join(outputDir, "NeverWrite-0.2.0-x86_64.rpm")), + true, + ); assert.equal( fs.existsSync( path.join(outputDir, "NeverWrite-0.2.0-x64.AppImage.blockmap"), @@ -377,6 +382,10 @@ test("stage-electron-release-assets restores AppImage files when Linux feeds onl path.join(distDir, "NeverWrite-0.2.0-amd64.deb"), "deb package", ); + writeFile( + path.join(distDir, "NeverWrite-0.2.0-x86_64.rpm"), + "rpm package", + ); writeFile( path.join(distDir, "latest-linux.yml"), [ @@ -454,6 +463,10 @@ test("stage-electron-release-assets synthesizes missing Linux AppImage feeds", ( path.join(distDir, "NeverWrite-0.2.0-arm64.deb"), "arm64 deb", ); + writeFile( + path.join(distDir, "NeverWrite-0.2.0-aarch64.rpm"), + "arm64 rpm", + ); execFileSync( process.execPath, @@ -491,11 +504,8 @@ test("stage-electron-release-assets synthesizes missing Linux AppImage feeds", ( assert.equal(metadata.manualAssetName, "NeverWrite-0.2.0-arm64.AppImage"); assert.equal(metadata.updaterAssetName, "NeverWrite-0.2.0-arm64.AppImage"); assert.deepEqual(metadata.additionalManualAssets, [ - { - kind: "deb", - assetName: "NeverWrite-0.2.0-arm64.deb", - sizeBytes: 9, - }, + { kind: "deb", assetName: "NeverWrite-0.2.0-arm64.deb", sizeBytes: 9 }, + { kind: "rpm", assetName: "NeverWrite-0.2.0-aarch64.rpm", sizeBytes: 9 }, ]); assert.equal(metadata.updaterBlockmapAssetName, null); assert.equal(metadata.updaterBlockmapSizeBytes, 0); @@ -543,6 +553,10 @@ test("stage-electron-release-assets requires generated Linux x64 feeds", () => { path.join(distDir, "NeverWrite-0.2.0-amd64.deb"), "x64 deb", ); + writeFile( + path.join(distDir, "NeverWrite-0.2.0-x86_64.rpm"), + "x64 rpm", + ); assert.throws( () => @@ -589,6 +603,10 @@ test("stage-electron-release-assets requires Linux Debian packages", () => { path.join(distDir, "NeverWrite-0.2.0-arm64.deb"), "wrong arch deb", ); + writeFile( + path.join(distDir, "NeverWrite-0.2.0-x86_64.rpm"), + "x64 rpm", + ); writeFile( path.join(distDir, "latest-linux.yml"), [ @@ -632,3 +650,53 @@ test("stage-electron-release-assets requires Linux Debian packages", () => { ); }); }); + +test("stage-electron-release-assets requires Linux RPM packages", () => { + withTempDir((tempDir) => { + const distDir = path.join(tempDir, "dist"); + const outputDir = path.join(tempDir, "staged"); + const metadataOut = path.join(tempDir, "metadata", "linux-arm64.json"); + + writeFile( + path.join(distDir, "NeverWrite-0.2.0-arm64.AppImage"), + "arm64 appimage", + ); + writeFile( + path.join(distDir, "NeverWrite-0.2.0-arm64.deb"), + "arm64 deb", + ); + writeFile( + path.join(distDir, "NeverWrite-0.2.0-x86_64.rpm"), + "wrong arch rpm", + ); + + assert.throws( + () => + execFileSync( + process.execPath, + [ + stageScriptPath, + "--dist-dir", + distDir, + "--target", + "aarch64-unknown-linux-gnu", + "--version", + "0.2.0", + "--tag", + "v0.2.0", + "--repo", + "jsgrrchg/NeverWrite", + "--output-dir", + outputDir, + "--metadata-out", + metadataOut, + ], + { + cwd: repoRoot, + stdio: "pipe", + }, + ), + /Expected exactly one RPM package/, + ); + }); +}); From b2926245cb6db70c0f2db5ff164d655d900dc67d Mon Sep 17 00:00:00 2001 From: Mason Date: Fri, 29 May 2026 01:49:56 +0300 Subject: [PATCH 09/17] feat(rpm): add DNF repository library with XML generation --- scripts/dnf-repo-lib.mjs | 186 ++++++++++++++++++++++++++++++++++++++ scripts/dnf-repo.test.mjs | 77 ++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 scripts/dnf-repo-lib.mjs create mode 100644 scripts/dnf-repo.test.mjs diff --git a/scripts/dnf-repo-lib.mjs b/scripts/dnf-repo-lib.mjs new file mode 100644 index 00000000..d9a8c463 --- /dev/null +++ b/scripts/dnf-repo-lib.mjs @@ -0,0 +1,186 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import zlib from "node:zlib"; + +import { + CANONICAL_RELEASE_PAGES_BASE_URL, + normalizeReleaseVersion, + buildRpmPackageAssetName, + buildGitHubReleaseAssetUrl, +} from "./appcast-lib.mjs"; + +export const DNF_REPOSITORY_RELATIVE_ROOT = "dnf"; +export const DNF_PACKAGE_NAME = "neverwrite"; +export const DNF_SUPPORTED_ARCHITECTURES = ["x86_64", "aarch64"]; + +export const BUILD_TARGET_BY_RPM_ARCHITECTURE = { + x86_64: "x86_64-unknown-linux-gnu", + aarch64: "aarch64-unknown-linux-gnu", +}; + +export function normalizeRpmArchitecture(value) { + const normalized = String(value ?? "").trim().toLowerCase(); + if (!DNF_SUPPORTED_ARCHITECTURES.includes(normalized)) { + throw new Error( + `Unsupported RPM architecture "${value}". Supported: ${DNF_SUPPORTED_ARCHITECTURES.join(", ")}.`, + ); + } + return normalized; +} + +export function buildDnfRepoRoot(pagesDir) { + if (typeof pagesDir !== "string" || !pagesDir.trim()) { + throw new Error("pagesDir must be a non-empty string."); + } + return path.join(pagesDir, DNF_REPOSITORY_RELATIVE_ROOT); +} + +export function buildRpmReleaseAssetName(version, rpmArchitecture) { + const arch = normalizeRpmArchitecture(rpmArchitecture); + const buildTarget = BUILD_TARGET_BY_RPM_ARCHITECTURE[arch]; + return buildRpmPackageAssetName(normalizeReleaseVersion(version), buildTarget); +} + +export function buildGitHubReleaseRpmUrl(repoSlug, tag, version, rpmArchitecture) { + const normalizedTag = tag.startsWith("v") ? tag : `v${normalizeReleaseVersion(tag)}`; + const assetName = buildRpmReleaseAssetName(version, rpmArchitecture); + return buildGitHubReleaseAssetUrl(repoSlug, normalizedTag, assetName); +} + +export const DNF_DEFAULT_BASE_URL = `${CANONICAL_RELEASE_PAGES_BASE_URL}/${DNF_REPOSITORY_RELATIVE_ROOT}`; +export const DNF_PUBLIC_KEY_FILE_NAME = "neverwrite-archive-keyring.asc"; +export const DNF_REPO_EXAMPLE_FILE_NAME = "neverwrite.repo.example"; + +export function buildNeverWriteRepoExample(baseUrl = DNF_DEFAULT_BASE_URL) { + const normalizedUrl = baseUrl.replace(/\/+$/, ""); + return [ + "[neverwrite]", + "name=NeverWrite", + `baseurl=${normalizedUrl}`, + "enabled=1", + "gpgcheck=1", + `gpgkey=${normalizedUrl}/${DNF_PUBLIC_KEY_FILE_NAME}`, + "", + ].join("\n"); +} + +const HASH_READ_BUFFER_SIZE_BYTES = 1024 * 1024; + +export function hashFile(filePath, algorithm) { + const hash = crypto.createHash(algorithm); + const buffer = Buffer.allocUnsafe(HASH_READ_BUFFER_SIZE_BYTES); + const fd = fs.openSync(filePath, "r"); + try { + let bytesRead = 0; + do { + bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null); + if (bytesRead > 0) { + hash.update(buffer.subarray(0, bytesRead)); + } + } while (bytesRead > 0); + } finally { + fs.closeSync(fd); + } + return hash.digest("hex"); +} + +export function getFileHashes(filePath) { + return { + md5: hashFile(filePath, "md5"), + sha1: hashFile(filePath, "sha1"), + sha256: hashFile(filePath, "sha256"), + }; +} + +export function gzipContent(input) { + return zlib.gzipSync(Buffer.from(input, "utf8"), { level: 9, mtime: 0 }); +} + +export function xmlEscape(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export function buildPrimaryXml({ packages }) { + const now = new Date().toUTCString(); + const entries = packages.map((pkg) => { + return ` + ${xmlEscape(pkg.name)} + ${xmlEscape(pkg.arch)} + + ${pkg.hashes.sha256} + NeverWrite desktop knowledge workspace + NeverWrite is a local-first knowledge workspace for power users. + NeverWrite Team + https://neverwrite.app + `; + }); + + return ` + +${entries.join("\n")} +`; +} + +export function buildFilelistsXml({ packages }) { + const entries = packages.map((pkg) => ` + + `); + + return ` + +${entries.join("\n")} +`; +} + +export function buildOtherXml({ packages }) { + const entries = packages.map((pkg) => ` + + `); + + return ` + +${entries.join("\n")} +`; +} + +export function buildRepomdXml({ files }) { + const now = new Date().toUTCString(); + const entries = files.map((file) => { + const typeMap = { + "primary.xml.gz": "primary", + "filelists.xml.gz": "filelists", + "other.xml.gz": "other", + }; + const type = typeMap[file.relativePath] || "primary"; + return ` + ${file.hashes.sha256} + ${file.hashes.sha256} + + ${Math.floor(Date.now() / 1000)} + ${file.sizeBytes} + ${file.sizeBytes} + `; + }); + + return ` + +${entries.join("\n")} +`; +} diff --git a/scripts/dnf-repo.test.mjs b/scripts/dnf-repo.test.mjs new file mode 100644 index 00000000..d9aa2941 --- /dev/null +++ b/scripts/dnf-repo.test.mjs @@ -0,0 +1,77 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + DNF_DEFAULT_BASE_URL, + buildRpmReleaseAssetName, + buildGitHubReleaseRpmUrl, + buildNeverWriteRepoExample, + buildPrimaryXml, + buildRepomdXml, + normalizeRpmArchitecture, +} from "./dnf-repo-lib.mjs"; + +test("RPM release asset names use RPM architecture naming", () => { + assert.equal( + buildRpmReleaseAssetName("0.3.0", "x86_64"), + "NeverWrite-0.3.0-x86_64.rpm", + ); + assert.equal( + buildRpmReleaseAssetName("0.3.0", "aarch64"), + "NeverWrite-0.3.0-aarch64.rpm", + ); +}); + +test("buildGitHubReleaseRpmUrl builds correct GitHub URL", () => { + const url = buildGitHubReleaseRpmUrl( + "jsgrrchg/NeverWrite", "v0.3.0", "0.3.0", "x86_64", + ); + assert.equal( + url, + "https://github.com/jsgrrchg/NeverWrite/releases/download/v0.3.0/NeverWrite-0.3.0-x86_64.rpm", + ); +}); + +test("buildNeverWriteRepoExample uses the public DNF endpoint", () => { + const example = buildNeverWriteRepoExample(); + assert.match(example, /baseurl=https:\/\/jsgrrchg\.github\.io\/NeverWrite\/dnf/); + assert.match(example, /gpgcheck=1/); + assert.match(example, /\[neverwrite\]/); +}); + +test("normalizeRpmArchitecture accepts valid RPM architectures", () => { + assert.equal(normalizeRpmArchitecture("x86_64"), "x86_64"); + assert.equal(normalizeRpmArchitecture("aarch64"), "aarch64"); + assert.throws(() => normalizeRpmArchitecture("amd64"), /Unsupported/); + assert.throws(() => normalizeRpmArchitecture("arm64"), /Unsupported/); +}); + +test("buildPrimaryXml generates valid XML with package metadata", () => { + const packages = [ + { + name: "neverwrite", + arch: "x86_64", + version: "0.3.0", + locationUrl: "https://github.com/jsgrrchg/NeverWrite/releases/download/v0.3.0/NeverWrite-0.3.0-x86_64.rpm", + sizeBytes: 1000000, + hashes: { sha256: "a".repeat(64) }, + }, + ]; + const xml = buildPrimaryXml({ packages }); + assert.match(xml, //); + assert.match(xml, /neverwrite<\/name>/); + assert.match(xml, /x86_64<\/arch>/); + assert.match(xml, / { + const files = [ + { + relativePath: "primary.xml.gz", + sizeBytes: 100, + hashes: { sha256: "b".repeat(64) }, + }, + ]; + const xml = buildRepomdXml({ files }); + assert.match(xml, //); +}); From af0de7f89609286ae393d09d48be6f4edc34f61c Mon Sep 17 00:00:00 2001 From: Mason Date: Fri, 29 May 2026 01:50:31 +0300 Subject: [PATCH 10/17] feat(rpm): add DNF repository build script with repodata generation --- scripts/build-dnf-repository.mjs | 165 +++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 scripts/build-dnf-repository.mjs diff --git a/scripts/build-dnf-repository.mjs b/scripts/build-dnf-repository.mjs new file mode 100644 index 00000000..43521639 --- /dev/null +++ b/scripts/build-dnf-repository.mjs @@ -0,0 +1,165 @@ +import childProcess from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +import { + DNF_REPOSITORY_RELATIVE_ROOT, + DNF_SUPPORTED_ARCHITECTURES, + DNF_REPO_EXAMPLE_FILE_NAME, + buildDnfRepoRoot, + buildRpmReleaseAssetName, + buildGitHubReleaseRpmUrl, + buildNeverWriteRepoExample, + buildPrimaryXml, + buildFilelistsXml, + buildOtherXml, + buildRepomdXml, + getFileHashes, + gzipContent, + xmlEscape, +} from "./dnf-repo-lib.mjs"; +import { parseGitHubRepoSlug } from "./appcast-lib.mjs"; +import { normalizeReleaseVersion } from "./appcast-lib.mjs"; + +function parseArgs(argv) { + const args = { + version: null, + tag: null, + releaseAssetsDir: null, + pagesDir: null, + repoSlug: null, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const next = argv[index + 1] ?? null; + + if (arg === "--version") { + args.version = next; + index += 1; + continue; + } + if (arg === "--tag") { + args.tag = next; + index += 1; + continue; + } + if (arg === "--release-assets-dir") { + args.releaseAssetsDir = path.resolve(next); + index += 1; + continue; + } + if (arg === "--pages-dir") { + args.pagesDir = path.resolve(next); + index += 1; + continue; + } + if (arg === "--repo-slug") { + args.repoSlug = next; + index += 1; + continue; + } + throw new Error( + `Unknown argument "${arg}". Supported: --version, --tag, --release-assets-dir, --pages-dir, --repo-slug.`, + ); + } + + if (!args.version) throw new Error("Missing --version"); + if (!args.tag) throw new Error("Missing --tag"); + if (!args.releaseAssetsDir) throw new Error("Missing --release-assets-dir"); + if (!args.pagesDir) throw new Error("Missing --pages-dir"); + if (!args.repoSlug) throw new Error("Missing --repo-slug"); + + parseGitHubRepoSlug(args.repoSlug); + + return { + ...args, + version: normalizeReleaseVersion(args.version), + }; +} + +function findSingleReleaseAsset(releaseAssetsDir, assetName) { + const matches = []; + for (const entry of fs.readdirSync(releaseAssetsDir, { withFileTypes: true })) { + if (entry.isFile() && entry.name === assetName) { + matches.push(path.join(releaseAssetsDir, entry.name)); + } + } + if (matches.length !== 1) { + throw new Error(`Expected exactly one release asset named ${assetName}, found ${matches.length}.`); + } + return matches[0]; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + + const dnfDir = buildDnfRepoRoot(args.pagesDir); + fs.mkdirSync(dnfDir, { recursive: true }); + + // Collect RPM packages + const packages = []; + for (const arch of DNF_SUPPORTED_ARCHITECTURES) { + const assetName = buildRpmReleaseAssetName(args.version, arch); + const source = findSingleReleaseAsset(args.releaseAssetsDir, assetName); + const locationUrl = buildGitHubReleaseRpmUrl( + args.repoSlug, args.tag, args.version, arch, + ); + const sizeBytes = fs.statSync(source).size; + const hashes = getFileHashes(source); + + packages.push({ + name: "neverwrite", + arch, + version: args.version, + locationUrl, + sourcePath: source, + sizeBytes, + hashes, + }); + } + + // Build repodata + const repodataDir = path.join(dnfDir, "repodata"); + fs.mkdirSync(repodataDir, { recursive: true }); + + const primaryXml = buildPrimaryXml({ packages }); + const primaryGzPath = path.join(repodataDir, "primary.xml.gz"); + fs.writeFileSync(primaryGzPath, gzipContent(primaryXml)); + + const filelistsXml = buildFilelistsXml({ packages }); + const filelistsGzPath = path.join(repodataDir, "filelists.xml.gz"); + fs.writeFileSync(filelistsGzPath, gzipContent(filelistsXml)); + + const otherXml = buildOtherXml({ packages }); + const otherGzPath = path.join(repodataDir, "other.xml.gz"); + fs.writeFileSync(otherGzPath, gzipContent(otherXml)); + + // Build repomd.xml + const metadataFiles = [ + { relativePath: "primary.xml.gz", absolutePath: primaryGzPath }, + { relativePath: "filelists.xml.gz", absolutePath: filelistsGzPath }, + { relativePath: "other.xml.gz", absolutePath: otherGzPath }, + ].map((file) => ({ + relativePath: file.relativePath, + sizeBytes: fs.statSync(file.absolutePath).size, + hashes: getFileHashes(file.absolutePath), + })); + + const repomdXml = buildRepomdXml({ files: metadataFiles }); + const repomdPath = path.join(repodataDir, "repomd.xml"); + fs.writeFileSync(repomdPath, repomdXml, "utf8"); + + // Write repo example file + fs.writeFileSync( + path.join(dnfDir, DNF_REPO_EXAMPLE_FILE_NAME), + buildNeverWriteRepoExample(), + "utf8", + ); + + console.log(`DNF repository built at ${dnfDir}`); + console.log(`Packages indexed: ${packages.map((p) => `${p.name}-${p.version}.${p.arch}`).join(", ")}`); + console.log(`repodata: repomd.xml, primary.xml.gz, filelists.xml.gz, other.xml.gz`); +} + +main(); From 5a4f3dda9b774812acfaab890a888295233786bd Mon Sep 17 00:00:00 2001 From: Mason Date: Fri, 29 May 2026 01:50:37 +0300 Subject: [PATCH 11/17] feat(rpm): add DNF repository signing script --- scripts/sign-dnf-repository.mjs | 62 +++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 scripts/sign-dnf-repository.mjs diff --git a/scripts/sign-dnf-repository.mjs b/scripts/sign-dnf-repository.mjs new file mode 100644 index 00000000..f33592f5 --- /dev/null +++ b/scripts/sign-dnf-repository.mjs @@ -0,0 +1,62 @@ +import childProcess from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +import { buildDnfRepoRoot, DNF_PUBLIC_KEY_FILE_NAME } from "./dnf-repo-lib.mjs"; + +function parseArgs(argv) { + const args = { dnfDir: null, keyId: null }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const next = argv[index + 1] ?? null; + if (arg === "--dnf-dir") { args.dnfDir = path.resolve(next); index += 1; continue; } + if (arg === "--key-id") { args.keyId = next?.trim(); index += 1; continue; } + throw new Error(`Unknown argument "${arg}".`); + } + if (!args.dnfDir) throw new Error("Missing --dnf-dir"); + if (!args.keyId) throw new Error("Missing --key-id"); + return args; +} + +function runGpg(args, { input = null, stdoutFile = null } = {}) { + const result = childProcess.spawnSync("gpg", args, { + input, encoding: stdoutFile ? null : "utf8", maxBuffer: 1024 * 1024 * 16, + }); + if (result.status !== 0) { + throw new Error(`gpg failed: ${result.stderr?.toString().trim()}`); + } + if (stdoutFile) fs.writeFileSync(stdoutFile, result.stdout); + return result.stdout; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const repomdPath = path.join(args.dnfDir, "repodata", "repomd.xml"); + const repomdAscPath = path.join(args.dnfDir, "repodata", "repomd.xml.asc"); + const publicKeyPath = path.join(args.dnfDir, DNF_PUBLIC_KEY_FILE_NAME); + const passphrase = process.env.APT_REPO_GPG_PASSPHRASE ?? ""; + + if (!fs.existsSync(repomdPath)) { + throw new Error(`Missing repomd.xml: ${repomdPath}`); + } + + const gpgArgs = [ + "--batch", "--yes", + "--export-options", "export-minimal", "--armor", "--export", args.keyId, + ]; + runGpg(gpgArgs, { stdoutFile: publicKeyPath }); + + const signArgs = [ + "--batch", "--yes", "--armor", "--detach-sign", + ...(passphrase ? ["--pinentry-mode", "loopback", "--passphrase-fd", "0"] : ["--pinentry-mode", "loopback"]), + "--local-user", args.keyId, + "--output", repomdAscPath, + repomdPath, + ]; + runGpg(signArgs, { input: passphrase ? `${passphrase}\n` : null }); + + console.log(`Signed DNF repository: ${repomdAscPath}`); + console.log(`Wrote public key: ${publicKeyPath}`); +} + +main(); From 266b6787ab40e22f6384c69579174a2903b73398 Mon Sep 17 00:00:00 2001 From: Mason Date: Fri, 29 May 2026 01:50:49 +0300 Subject: [PATCH 12/17] docs(rpm): add DNF repository documentation --- release/dnf/README.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 release/dnf/README.md diff --git a/release/dnf/README.md b/release/dnf/README.md new file mode 100644 index 00000000..03132934 --- /dev/null +++ b/release/dnf/README.md @@ -0,0 +1,42 @@ +# NeverWrite DNF Repository + +NeverWrite publishes a signed DNF repository for Fedora/RHEL packages at: + +```text +https://jsgrrchg.github.io/NeverWrite/dnf +``` + +RPM packages are hosted on GitHub Releases; the DNF repository contains only +package metadata. + +## User Install + +```bash +sudo dnf config-manager --add-repo https://jsgrrchg.github.io/NeverWrite/dnf/neverwrite.repo.example +sudo rpm --import https://jsgrrchg.github.io/NeverWrite/dnf/neverwrite-archive-keyring.asc +sudo dnf install neverwrite +``` + +## Published Layout + +```text +dnf/ + neverwrite-archive-keyring.asc + neverwrite.repo.example + repodata/ + repomd.xml + repomd.xml.asc + primary.xml.gz + filelists.xml.gz + other.xml.gz +``` + +## Validation + +Manual post-release checks: + +```bash +curl -fsSL https://jsgrrchg.github.io/NeverWrite/dnf/repodata/repomd.xml | head +curl -fsSL https://jsgrrchg.github.io/NeverWrite/dnf/neverwrite.repo.example +dnf info neverwrite +``` From 0a946fe9f085410217e3996e5558e32645a31b20 Mon Sep 17 00:00:00 2001 From: Mason Date: Fri, 29 May 2026 01:50:53 +0300 Subject: [PATCH 13/17] feat(rpm): add DNF repository validation script --- scripts/validate-dnf-repository.mjs | 140 ++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 scripts/validate-dnf-repository.mjs diff --git a/scripts/validate-dnf-repository.mjs b/scripts/validate-dnf-repository.mjs new file mode 100644 index 00000000..ace13bf9 --- /dev/null +++ b/scripts/validate-dnf-repository.mjs @@ -0,0 +1,140 @@ +import childProcess from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import zlib from "node:zlib"; + +import { + DNF_PUBLIC_KEY_FILE_NAME, + DNF_REPO_EXAMPLE_FILE_NAME, + DNF_SUPPORTED_ARCHITECTURES, + DNF_PACKAGE_NAME, + hashFile, +} from "./dnf-repo-lib.mjs"; +import { normalizeReleaseVersion } from "./appcast-lib.mjs"; + +function parseArgs(argv) { + const args = { dnfDir: null, version: null, skipSignatureCheck: false }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const next = argv[index + 1] ?? null; + if (arg === "--dnf-dir") { args.dnfDir = path.resolve(next); index += 1; continue; } + if (arg === "--version") { args.version = next; index += 1; continue; } + if (arg === "--skip-signature-check") { args.skipSignatureCheck = true; continue; } + throw new Error(`Unknown argument "${arg}".`); + } + if (!args.dnfDir) throw new Error("Missing --dnf-dir"); + return { ...args, version: args.version ? normalizeReleaseVersion(args.version) : null }; +} + +function assertFileExists(filePath, label) { + if (!fs.existsSync(filePath)) throw new Error(`Missing ${label}: ${filePath}`); +} + +function validateRepomd(dnfDir) { + const repomdPath = path.join(dnfDir, "repodata", "repomd.xml"); + assertFileExists(repomdPath, "repomd.xml"); + + const content = fs.readFileSync(repomdPath, "utf8"); + if (!content.includes('')) { + throw new Error("repomd.xml missing primary data reference"); + } + + const locationMatch = content.match(/([a-f0-9]{64})<\/checksum>/); + if (!checksumMatch) { + throw new Error("repomd.xml missing SHA256 checksum"); + } + + return content; +} + +function validateRepomdSignature(dnfDir) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "neverwrite-dnf-gpg-")); + const keyringPath = path.join(tempDir, "neverwrite-keyring.gpg"); + const publicKeyPath = path.join(dnfDir, DNF_PUBLIC_KEY_FILE_NAME); + const repomdPath = path.join(dnfDir, "repodata", "repomd.xml"); + const repomdAscPath = path.join(dnfDir, "repodata", "repomd.xml.asc"); + + try { + childProcess.spawnSync("gpg", ["--batch", "--yes", "--no-default-keyring", "--keyring", keyringPath, "--import", publicKeyPath], { encoding: "utf8" }); + const verifyResult = childProcess.spawnSync("gpg", ["--batch", "--no-default-keyring", "--keyring", keyringPath, "--verify", repomdAscPath, repomdPath], { encoding: "utf8" }); + if (verifyResult.status !== 0) { + throw new Error(`GPG signature verification failed:\n${verifyResult.stderr}`); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function validatePrimaryXml(dnfDir, version) { + const primaryGzPath = path.join(dnfDir, "repodata", "primary.xml.gz"); + assertFileExists(primaryGzPath, "primary.xml.gz"); + + const inflated = zlib.gunzipSync(fs.readFileSync(primaryGzPath)); + const content = inflated.toString("utf8"); + + if (!content.includes('')) { + throw new Error("primary.xml missing package entries"); + } + if (!content.includes(`${DNF_PACKAGE_NAME}`)) { + throw new Error(`primary.xml missing package name "${DNF_PACKAGE_NAME}"`); + } + + const archMatch = content.match(/([^<]+)<\/arch>/); + if (!archMatch || !DNF_SUPPORTED_ARCHITECTURES.includes(archMatch[1])) { + throw new Error(`primary.xml has unsupported architecture: ${archMatch ? archMatch[1] : "missing"}`); + } + + const locationMatch = content.match(/ Date: Fri, 29 May 2026 01:51:14 +0300 Subject: [PATCH 14/17] feat(rpm): add RPM validation and DNF repo build to CI workflow --- .github/workflows/release-desktop.yml | 74 ++++++++++++++++++- apps/desktop/electron-builder.config.mjs | 1 + .../scripts/electron-builder-config.test.mjs | 14 ++++ 3 files changed, 85 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 696d79a9..d18be6b5 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -167,7 +167,7 @@ jobs: EOF fi sudo apt-get update - sudo apt-get install -y pkg-config libcap-dev + sudo apt-get install -y pkg-config libcap-dev rpm if [[ "${{ matrix.target }}" == "aarch64-unknown-linux-gnu" ]]; then sudo apt-get install -y gcc-aarch64-linux-gnu libcap-dev:arm64 libssl-dev:arm64 echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV" @@ -369,6 +369,15 @@ jobs: --version "${{ needs.prepare.outputs.version }}" \ --install + - name: Validate Linux RPM package + if: matrix.platform == 'linux' + shell: bash + run: | + node apps/desktop/scripts/validate-linux-rpm-package.mjs \ + --staged-assets-dir "$RUNNER_TEMP/release-assets" \ + --target ${{ matrix.target }} \ + --version "${{ needs.prepare.outputs.version }}" + - name: Upload release asset artifact uses: actions/upload-artifact@v4 with: @@ -777,8 +786,8 @@ jobs: } EOF - - name: Install APT repository tools - run: sudo apt-get update && sudo apt-get install -y dpkg gnupg gzip + - name: Install repository tools + run: sudo apt-get update && sudo apt-get install -y dpkg gnupg gzip rpm - name: Build APT repository shell: bash @@ -794,6 +803,18 @@ jobs: --component main \ --retain-versions 3 + - name: Build DNF repository + shell: bash + env: + PAGES_DIR: ${{ runner.temp }}/gh-pages + run: | + node scripts/build-dnf-repository.mjs \ + --version "${{ needs.prepare.outputs.version }}" \ + --tag "${{ needs.prepare.outputs.tag }}" \ + --release-assets-dir .artifacts/release-assets \ + --pages-dir "$PAGES_DIR" \ + --repo-slug "${{ needs.prepare.outputs.repo_slug }}" + - name: Sign APT repository shell: bash env: @@ -820,6 +841,21 @@ jobs: --key-id "$APT_REPO_GPG_KEY_ID" \ --suite stable + - name: Sign DNF repository + shell: bash + env: + APT_REPO_GPG_PASSPHRASE: ${{ secrets.APT_REPO_GPG_PASSPHRASE }} + PAGES_DIR: ${{ runner.temp }}/gh-pages + run: | + export GNUPGHOME + GNUPGHOME="$(mktemp -d)" + trap 'rm -rf "$GNUPGHOME"' EXIT + chmod 700 "$GNUPGHOME" + printf '%s' "${{ secrets.APT_REPO_GPG_PRIVATE_KEY }}" | gpg --batch --import + node scripts/sign-dnf-repository.mjs \ + --dnf-dir "$PAGES_DIR/dnf" \ + --key-id "${{ secrets.APT_REPO_GPG_KEY_ID }}" + - name: Validate APT repository shell: bash env: @@ -831,6 +867,15 @@ jobs: --suite stable \ --component main + - name: Validate DNF repository + shell: bash + env: + PAGES_DIR: ${{ runner.temp }}/gh-pages + run: | + node scripts/validate-dnf-repository.mjs \ + --dnf-dir "$PAGES_DIR/dnf" \ + --version "${{ needs.prepare.outputs.version }}" + - name: Validate APT repository with apt shell: bash env: @@ -856,6 +901,27 @@ jobs: apt-cache policy neverwrite | grep -F "$VERSION" ' + - name: Validate DNF repository with dnf + shell: bash + env: + PAGES_DIR: ${{ runner.temp }}/gh-pages + VERSION: ${{ needs.prepare.outputs.version }} + run: | + docker run --rm \ + -e VERSION="$VERSION" \ + -v "$PAGES_DIR/dnf:/repo:ro" \ + fedora:40 \ + bash -lc ' + set -euo pipefail + cp /repo/neverwrite-archive-keyring.asc /etc/pki/rpm-gpg/ + dnf config-manager --add-repo file:///repo/neverwrite.repo.example + sed -i "s|baseurl=.*|baseurl=file:///repo|" /etc/yum.repos.d/neverwrite.repo + dnf makecache + dnf info neverwrite + dnf info neverwrite | grep -F "$VERSION" + echo "DNF repository validated successfully." + ' + - name: Publish pages artifacts to gh-pages shell: bash env: @@ -866,7 +932,7 @@ jobs: cd "$PAGES_DIR" git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add "${CHANNEL}" apt .nojekyll + git add "${CHANNEL}" apt dnf .nojekyll if git diff --cached --quiet; then echo "No pages artifact changes to publish." else diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index 8bf3c2b8..aed7d888 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -229,6 +229,7 @@ export default { }, rpm: { packageName: "neverwrite", + maintainer: "NeverWrite Maintainers ", artifactName: "${productName}-${version}-${arch}.rpm", publish: null, }, diff --git a/apps/desktop/scripts/electron-builder-config.test.mjs b/apps/desktop/scripts/electron-builder-config.test.mjs index 496b5129..a0bb77c8 100644 --- a/apps/desktop/scripts/electron-builder-config.test.mjs +++ b/apps/desktop/scripts/electron-builder-config.test.mjs @@ -62,7 +62,21 @@ test("Debian package metadata is stable for Ubuntu/Debian releases", () => { assert.equal(config.deb.packageName, "neverwrite"); assert.equal(config.deb.packageCategory, "utils"); assert.equal(config.deb.priority, "optional"); + assert.equal( + config.deb.maintainer, + "NeverWrite Maintainers ", + ); assert.equal(config.deb.artifactName, "${productName}-${version}-${arch}.deb"); assert.equal(config.deb.publish, null); assert.equal(config.deb.synopsis, "AI-powered writing workspace"); }); + +test("RPM package metadata is stable for Fedora/RHEL releases", () => { + assert.equal(config.rpm.packageName, "neverwrite"); + assert.equal( + config.rpm.maintainer, + "NeverWrite Maintainers ", + ); + assert.equal(config.rpm.artifactName, "${productName}-${version}-${arch}.rpm"); + assert.equal(config.rpm.publish, null); +}); From b5c6a8cb003c9600c7b7958d9a20e9c0c8dd95e2 Mon Sep 17 00:00:00 2001 From: jsgerrchg Date: Sat, 30 May 2026 07:59:18 -0400 Subject: [PATCH 15/17] fix(rpm): sign packages before DNF publish --- .github/workflows/release-desktop.yml | 43 +++++- .../scripts/validate-linux-rpm-package.mjs | 18 +++ release/dnf/README.md | 4 +- scripts/build-dnf-repository.mjs | 55 ++++--- scripts/dnf-repo-lib.mjs | 14 +- scripts/dnf-repo.test.mjs | 13 ++ scripts/sign-rpm-packages.mjs | 141 ++++++++++++++++++ scripts/validate-dnf-repository.mjs | 1 + 8 files changed, 257 insertions(+), 32 deletions(-) create mode 100644 scripts/sign-rpm-packages.mjs diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 345e792e..7708662a 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -86,7 +86,9 @@ jobs: build-target: name: Build ${{ matrix.target }} - needs: prepare + needs: + - prepare + - apt-repository-preflight runs-on: ${{ matrix.runner }} strategy: fail-fast: false @@ -167,7 +169,7 @@ jobs: EOF fi sudo apt-get update - sudo apt-get install -y pkg-config libcap-dev rpm + sudo apt-get install -y pkg-config libcap-dev gnupg rpm if [[ "${{ matrix.target }}" == "aarch64-unknown-linux-gnu" ]]; then sudo apt-get install -y gcc-aarch64-linux-gnu libcap-dev:arm64 libssl-dev:arm64 echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV" @@ -346,6 +348,33 @@ jobs: verify_universal "$APP_PATH/Contents/Resources/native-backend/binaries/codex-acp" verify_universal "$APP_PATH/Contents/Resources/native-backend/embedded/node/bin/node" + - name: Sign Linux RPM package + if: matrix.platform == 'linux' + shell: bash + env: + APT_REPO_GPG_PRIVATE_KEY: ${{ secrets.APT_REPO_GPG_PRIVATE_KEY }} + APT_REPO_GPG_PASSPHRASE: ${{ secrets.APT_REPO_GPG_PASSPHRASE }} + APT_REPO_GPG_KEY_ID: ${{ secrets.APT_REPO_GPG_KEY_ID }} + run: | + for name in APT_REPO_GPG_PRIVATE_KEY APT_REPO_GPG_PASSPHRASE APT_REPO_GPG_KEY_ID; do + if [[ -z "${!name}" ]]; then + echo "Missing required GitHub Actions secret: ${name}" >&2 + exit 1 + fi + done + + export GNUPGHOME + GNUPGHOME="$(mktemp -d)" + trap 'rm -rf "$GNUPGHOME"' EXIT + chmod 700 "$GNUPGHOME" + printf '%s' "$APT_REPO_GPG_PRIVATE_KEY" | gpg --batch --import + gpg --batch --armor --export "$APT_REPO_GPG_KEY_ID" > "$RUNNER_TEMP/neverwrite-rpm-signing-key.asc" + sudo rpm --import "$RUNNER_TEMP/neverwrite-rpm-signing-key.asc" + + node scripts/sign-rpm-packages.mjs \ + --rpm-dir "$RUNNER_TEMP/electron-dist" \ + --key-id "$APT_REPO_GPG_KEY_ID" + - name: Stage release assets and target metadata shell: bash run: | @@ -376,7 +405,8 @@ jobs: node apps/desktop/scripts/validate-linux-rpm-package.mjs \ --staged-assets-dir "$RUNNER_TEMP/release-assets" \ --target ${{ matrix.target }} \ - --version "${{ needs.prepare.outputs.version }}" + --version "${{ needs.prepare.outputs.version }}" \ + --require-signature - name: Upload release asset artifact uses: actions/upload-artifact@v4 @@ -962,11 +992,14 @@ jobs: bash -lc ' set -euo pipefail cp /repo/neverwrite-archive-keyring.asc /etc/pki/rpm-gpg/ - dnf config-manager --add-repo file:///repo/neverwrite.repo.example + rpm --import /repo/neverwrite-archive-keyring.asc + cp /repo/neverwrite.repo.example /etc/yum.repos.d/neverwrite.repo sed -i "s|baseurl=.*|baseurl=file:///repo|" /etc/yum.repos.d/neverwrite.repo - dnf makecache + sed -i "s|gpgkey=.*|gpgkey=file:///repo/neverwrite-archive-keyring.asc|" /etc/yum.repos.d/neverwrite.repo + dnf -y makecache dnf info neverwrite dnf info neverwrite | grep -F "$VERSION" + dnf install -y --downloadonly neverwrite echo "DNF repository validated successfully." ' diff --git a/apps/desktop/scripts/validate-linux-rpm-package.mjs b/apps/desktop/scripts/validate-linux-rpm-package.mjs index 043a94a2..fe5cecb0 100644 --- a/apps/desktop/scripts/validate-linux-rpm-package.mjs +++ b/apps/desktop/scripts/validate-linux-rpm-package.mjs @@ -13,6 +13,7 @@ function parseArgs(argv) { buildTarget: null, version: null, skipInstall: false, + requireSignature: false, }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; @@ -36,6 +37,10 @@ function parseArgs(argv) { args.skipInstall = true; continue; } + if (arg === "--require-signature") { + args.requireSignature = true; + continue; + } throw new Error(`Unknown argument: ${arg}`); } if (!args.stagedAssetsDir) throw new Error("Missing --staged-assets-dir"); @@ -75,6 +80,16 @@ function assertRpmAvailable() { runCommand("rpm", ["--version"]); } +function assertRpmSignature(rpmPath) { + const output = runCommand("rpm", ["-Kv", rpmPath]); + if (!/signature/i.test(output)) { + throw new Error(`RPM package is not signed: ${rpmPath}\n${output}`); + } + if (/(not ok|nokey|nottrusted|missing keys|bad)/i.test(output)) { + throw new Error(`RPM package signature check failed: ${rpmPath}\n${output}`); + } +} + function main() { const args = parseArgs(process.argv.slice(2)); assertRpmAvailable(); @@ -85,6 +100,9 @@ function main() { const expectedArch = rpmArchForBuildTarget(args.buildTarget); runCommand("rpm", ["-K", rpmPath]); + if (args.requireSignature) { + assertRpmSignature(rpmPath); + } const info = runCommand("rpm", ["-qip", rpmPath]); const files = runCommand("rpm", ["-qlp", rpmPath]); diff --git a/release/dnf/README.md b/release/dnf/README.md index 03132934..c4e790ff 100644 --- a/release/dnf/README.md +++ b/release/dnf/README.md @@ -7,7 +7,8 @@ https://jsgrrchg.github.io/NeverWrite/dnf ``` RPM packages are hosted on GitHub Releases; the DNF repository contains only -package metadata. +package metadata. The repository enables both RPM package signature checks and +repository metadata signature checks. ## User Install @@ -39,4 +40,5 @@ Manual post-release checks: curl -fsSL https://jsgrrchg.github.io/NeverWrite/dnf/repodata/repomd.xml | head curl -fsSL https://jsgrrchg.github.io/NeverWrite/dnf/neverwrite.repo.example dnf info neverwrite +sudo dnf install --downloadonly neverwrite ``` diff --git a/scripts/build-dnf-repository.mjs b/scripts/build-dnf-repository.mjs index 43521639..1bd6bd29 100644 --- a/scripts/build-dnf-repository.mjs +++ b/scripts/build-dnf-repository.mjs @@ -1,9 +1,7 @@ -import childProcess from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { - DNF_REPOSITORY_RELATIVE_ROOT, DNF_SUPPORTED_ARCHITECTURES, DNF_REPO_EXAMPLE_FILE_NAME, buildDnfRepoRoot, @@ -14,9 +12,9 @@ import { buildFilelistsXml, buildOtherXml, buildRepomdXml, + getContentHashes, getFileHashes, gzipContent, - xmlEscape, } from "./dnf-repo-lib.mjs"; import { parseGitHubRepoSlug } from "./appcast-lib.mjs"; import { normalizeReleaseVersion } from "./appcast-lib.mjs"; @@ -91,6 +89,20 @@ function findSingleReleaseAsset(releaseAssetsDir, assetName) { return matches[0]; } +function writeCompressedMetadata(repodataDir, relativePath, content) { + const absolutePath = path.join(repodataDir, relativePath); + fs.writeFileSync(absolutePath, gzipContent(content)); + + return { + relativePath, + absolutePath, + sizeBytes: fs.statSync(absolutePath).size, + openSizeBytes: Buffer.byteLength(content, "utf8"), + hashes: getFileHashes(absolutePath), + openHashes: getContentHashes(content), + }; +} + function main() { const args = parseArgs(process.argv.slice(2)); @@ -123,28 +135,23 @@ function main() { const repodataDir = path.join(dnfDir, "repodata"); fs.mkdirSync(repodataDir, { recursive: true }); - const primaryXml = buildPrimaryXml({ packages }); - const primaryGzPath = path.join(repodataDir, "primary.xml.gz"); - fs.writeFileSync(primaryGzPath, gzipContent(primaryXml)); - - const filelistsXml = buildFilelistsXml({ packages }); - const filelistsGzPath = path.join(repodataDir, "filelists.xml.gz"); - fs.writeFileSync(filelistsGzPath, gzipContent(filelistsXml)); - - const otherXml = buildOtherXml({ packages }); - const otherGzPath = path.join(repodataDir, "other.xml.gz"); - fs.writeFileSync(otherGzPath, gzipContent(otherXml)); - - // Build repomd.xml const metadataFiles = [ - { relativePath: "primary.xml.gz", absolutePath: primaryGzPath }, - { relativePath: "filelists.xml.gz", absolutePath: filelistsGzPath }, - { relativePath: "other.xml.gz", absolutePath: otherGzPath }, - ].map((file) => ({ - relativePath: file.relativePath, - sizeBytes: fs.statSync(file.absolutePath).size, - hashes: getFileHashes(file.absolutePath), - })); + writeCompressedMetadata( + repodataDir, + "primary.xml.gz", + buildPrimaryXml({ packages }), + ), + writeCompressedMetadata( + repodataDir, + "filelists.xml.gz", + buildFilelistsXml({ packages }), + ), + writeCompressedMetadata( + repodataDir, + "other.xml.gz", + buildOtherXml({ packages }), + ), + ]; const repomdXml = buildRepomdXml({ files: metadataFiles }); const repomdPath = path.join(repodataDir, "repomd.xml"); diff --git a/scripts/dnf-repo-lib.mjs b/scripts/dnf-repo-lib.mjs index d9a8c463..3a90af24 100644 --- a/scripts/dnf-repo-lib.mjs +++ b/scripts/dnf-repo-lib.mjs @@ -60,6 +60,7 @@ export function buildNeverWriteRepoExample(baseUrl = DNF_DEFAULT_BASE_URL) { `baseurl=${normalizedUrl}`, "enabled=1", "gpgcheck=1", + "repo_gpgcheck=1", `gpgkey=${normalizedUrl}/${DNF_PUBLIC_KEY_FILE_NAME}`, "", ].join("\n"); @@ -93,6 +94,15 @@ export function getFileHashes(filePath) { }; } +export function getContentHashes(content) { + const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content); + return { + md5: crypto.createHash("md5").update(buffer).digest("hex"), + sha1: crypto.createHash("sha1").update(buffer).digest("hex"), + sha256: crypto.createHash("sha256").update(buffer).digest("hex"), + }; +} + export function gzipContent(input) { return zlib.gzipSync(Buffer.from(input, "utf8"), { level: 9, mtime: 0 }); } @@ -171,11 +181,11 @@ export function buildRepomdXml({ files }) { const type = typeMap[file.relativePath] || "primary"; return ` ${file.hashes.sha256} - ${file.hashes.sha256} + ${file.openHashes.sha256} ${Math.floor(Date.now() / 1000)} ${file.sizeBytes} - ${file.sizeBytes} + ${file.openSizeBytes} `; }); diff --git a/scripts/dnf-repo.test.mjs b/scripts/dnf-repo.test.mjs index d9aa2941..5e0f55eb 100644 --- a/scripts/dnf-repo.test.mjs +++ b/scripts/dnf-repo.test.mjs @@ -7,6 +7,7 @@ import { buildNeverWriteRepoExample, buildPrimaryXml, buildRepomdXml, + getContentHashes, normalizeRpmArchitecture, } from "./dnf-repo-lib.mjs"; @@ -35,6 +36,7 @@ test("buildNeverWriteRepoExample uses the public DNF endpoint", () => { const example = buildNeverWriteRepoExample(); assert.match(example, /baseurl=https:\/\/jsgrrchg\.github\.io\/NeverWrite\/dnf/); assert.match(example, /gpgcheck=1/); + assert.match(example, /repo_gpgcheck=1/); assert.match(example, /\[neverwrite\]/); }); @@ -68,10 +70,21 @@ test("buildRepomdXml generates valid repomd XML", () => { { relativePath: "primary.xml.gz", sizeBytes: 100, + openSizeBytes: 250, hashes: { sha256: "b".repeat(64) }, + openHashes: { sha256: "c".repeat(64) }, }, ]; const xml = buildRepomdXml({ files }); assert.match(xml, //); + assert.match(xml, new RegExp(`${"c".repeat(64)}`)); + assert.match(xml, /250<\/open-size>/); +}); + +test("getContentHashes hashes uncompressed metadata content", () => { + assert.equal( + getContentHashes("neverwrite").sha256, + "ba11db9f5638d9c98918b6f05a7388d4fe4996ec40bc2a4c1f4451c6bcf095a2", + ); }); diff --git a/scripts/sign-rpm-packages.mjs b/scripts/sign-rpm-packages.mjs new file mode 100644 index 00000000..f11124a2 --- /dev/null +++ b/scripts/sign-rpm-packages.mjs @@ -0,0 +1,141 @@ +import childProcess from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +function parseArgs(argv) { + const args = { + rpmDir: null, + keyId: null, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const next = argv[index + 1] ?? null; + + if (arg === "--rpm-dir") { + args.rpmDir = path.resolve(next); + index += 1; + continue; + } + if (arg === "--key-id") { + args.keyId = next?.trim(); + index += 1; + continue; + } + throw new Error(`Unknown argument "${arg}".`); + } + + if (!args.rpmDir) throw new Error("Missing --rpm-dir"); + if (!args.keyId) throw new Error("Missing --key-id"); + + return args; +} + +function listRpmFiles(rootDir) { + const files = []; + const queue = [rootDir]; + + while (queue.length > 0) { + const current = queue.pop(); + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const absolutePath = path.join(current, entry.name); + if (entry.isDirectory()) { + queue.push(absolutePath); + } else if (entry.isFile() && entry.name.endsWith(".rpm")) { + files.push(absolutePath); + } + } + } + + return files.sort(); +} + +function runCommand(command, args, options = {}) { + const result = childProcess.spawnSync(command, args, { + encoding: "utf8", + maxBuffer: 1024 * 1024 * 16, + ...options, + }); + if (result.status !== 0) { + throw new Error( + `Command failed: ${command} ${args.join(" ")}\n${result.stderr?.trim() || result.stdout?.trim()}`, + ); + } + return `${result.stdout ?? ""}${result.stderr ?? ""}`; +} + +function writePassphraseFile(tempDir) { + const passphrase = process.env.APT_REPO_GPG_PASSPHRASE ?? ""; + if (!passphrase) { + return null; + } + + const passphrasePath = path.join(tempDir, "rpm-signing-passphrase"); + fs.writeFileSync(passphrasePath, passphrase, { mode: 0o600 }); + return passphrasePath; +} + +function buildRpmSignDefines({ keyId, passphrasePath }) { + const extraGpgArgs = [ + "--batch", + "--pinentry-mode", + "loopback", + ...(passphrasePath ? ["--passphrase-file", passphrasePath] : []), + ].join(" "); + + return [ + "--define", + `_gpg_name ${keyId}`, + "--define", + `_gpg_path ${process.env.GNUPGHOME}`, + "--define", + "_signature gpg", + "--define", + `_gpg_sign_cmd_extra_args ${extraGpgArgs}`, + ]; +} + +function assertRpmSignature(rpmPath) { + const output = runCommand("rpm", ["-Kv", rpmPath]); + if (!/signature/i.test(output)) { + throw new Error(`RPM package is not signed: ${rpmPath}\n${output}`); + } + if (/(not ok|nokey|nottrusted|missing keys|bad)/i.test(output)) { + throw new Error(`RPM package signature check failed: ${rpmPath}\n${output}`); + } +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const rpmFiles = listRpmFiles(args.rpmDir); + if (rpmFiles.length === 0) { + throw new Error(`No RPM packages found in ${args.rpmDir}.`); + } + + runCommand("rpmsign", ["--version"]); + runCommand("rpm", ["--version"]); + + if (!process.env.GNUPGHOME) { + throw new Error("GNUPGHOME must point to the imported release signing keyring."); + } + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "neverwrite-rpm-sign-")); + try { + const passphrasePath = writePassphraseFile(tempDir); + const rpmSignDefines = buildRpmSignDefines({ + keyId: args.keyId, + passphrasePath, + }); + + for (const rpmPath of rpmFiles) { + runCommand("rpmsign", [...rpmSignDefines, "--addsign", rpmPath]); + assertRpmSignature(rpmPath); + console.log(`Signed RPM package: ${rpmPath}`); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +main(); diff --git a/scripts/validate-dnf-repository.mjs b/scripts/validate-dnf-repository.mjs index ace13bf9..d2853019 100644 --- a/scripts/validate-dnf-repository.mjs +++ b/scripts/validate-dnf-repository.mjs @@ -116,6 +116,7 @@ function validateRepoExample(dnfDir) { const content = fs.readFileSync(examplePath, "utf8"); if (!content.includes("[neverwrite]")) throw new Error("repo example missing [neverwrite] header"); if (!content.includes("gpgcheck=1")) throw new Error("repo example missing gpgcheck=1"); + if (!content.includes("repo_gpgcheck=1")) throw new Error("repo example missing repo_gpgcheck=1"); } function main() { From 3e55216b7bb2962f68cf7e4c8b08756bcd8b4b90 Mon Sep 17 00:00:00 2001 From: jsgerrchg Date: Sat, 30 May 2026 08:07:41 -0400 Subject: [PATCH 16/17] fix(rpm): generate DNF metadata from packages --- .github/workflows/release-desktop.yml | 2 +- release/dnf/README.md | 5 +- scripts/build-dnf-repository.mjs | 143 ++++++++++++++------------ scripts/dnf-repo-lib.mjs | 143 ++------------------------ scripts/dnf-repo.test.mjs | 53 ++-------- scripts/validate-dnf-repository.mjs | 25 ++++- 6 files changed, 120 insertions(+), 251 deletions(-) diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 7708662a..348c9902 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -855,7 +855,7 @@ jobs: EOF - name: Install Linux repository tools - run: sudo apt-get update && sudo apt-get install -y dpkg gnupg gzip rpm + run: sudo apt-get update && sudo apt-get install -y createrepo-c dpkg gnupg gzip rpm - name: Build APT repository shell: bash diff --git a/release/dnf/README.md b/release/dnf/README.md index c4e790ff..1abfc6a9 100644 --- a/release/dnf/README.md +++ b/release/dnf/README.md @@ -7,8 +7,9 @@ https://jsgrrchg.github.io/NeverWrite/dnf ``` RPM packages are hosted on GitHub Releases; the DNF repository contains only -package metadata. The repository enables both RPM package signature checks and -repository metadata signature checks. +package metadata generated from the signed RPM headers with `createrepo_c`. The +repository enables both RPM package signature checks and repository metadata +signature checks. ## User Install diff --git a/scripts/build-dnf-repository.mjs b/scripts/build-dnf-repository.mjs index 1bd6bd29..97a1363f 100644 --- a/scripts/build-dnf-repository.mjs +++ b/scripts/build-dnf-repository.mjs @@ -1,4 +1,6 @@ +import childProcess from "node:child_process"; import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { @@ -6,15 +8,8 @@ import { DNF_REPO_EXAMPLE_FILE_NAME, buildDnfRepoRoot, buildRpmReleaseAssetName, - buildGitHubReleaseRpmUrl, + buildGitHubReleaseRpmLocationPrefix, buildNeverWriteRepoExample, - buildPrimaryXml, - buildFilelistsXml, - buildOtherXml, - buildRepomdXml, - getContentHashes, - getFileHashes, - gzipContent, } from "./dnf-repo-lib.mjs"; import { parseGitHubRepoSlug } from "./appcast-lib.mjs"; import { normalizeReleaseVersion } from "./appcast-lib.mjs"; @@ -89,75 +84,89 @@ function findSingleReleaseAsset(releaseAssetsDir, assetName) { return matches[0]; } -function writeCompressedMetadata(repodataDir, relativePath, content) { - const absolutePath = path.join(repodataDir, relativePath); - fs.writeFileSync(absolutePath, gzipContent(content)); +function runCommand(command, args) { + const result = childProcess.spawnSync(command, args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + const stderr = result.stderr?.trim(); + const stdout = result.stdout?.trim(); + throw new Error( + [ + `${command} failed with exit code ${result.status}.`, + stderr ? `stderr:\n${stderr}` : null, + stdout ? `stdout:\n${stdout}` : null, + ].filter(Boolean).join("\n"), + ); + } + return result.stdout; +} - return { - relativePath, - absolutePath, - sizeBytes: fs.statSync(absolutePath).size, - openSizeBytes: Buffer.byteLength(content, "utf8"), - hashes: getFileHashes(absolutePath), - openHashes: getContentHashes(content), - }; +function assertCreaterepoAvailable() { + try { + runCommand("createrepo_c", ["--version"]); + } catch (error) { + throw new Error( + `createrepo_c is required to build DNF metadata from real RPM headers.\n${error.message}`, + ); + } +} + +function buildCreaterepoArgs({ repositoryDir, locationPrefix }) { + return [ + "--checksum", "sha256", + "--general-compress-type", "gz", + "--no-database", + "--simple-md-filenames", + "--location-prefix", locationPrefix, + repositoryDir, + ]; +} + +function copyGeneratedRepodata(sourceRepositoryDir, dnfDir) { + const sourceRepodataDir = path.join(sourceRepositoryDir, "repodata"); + const targetRepodataDir = path.join(dnfDir, "repodata"); + fs.rmSync(targetRepodataDir, { recursive: true, force: true }); + fs.cpSync(sourceRepodataDir, targetRepodataDir, { recursive: true }); } function main() { const args = parseArgs(process.argv.slice(2)); + assertCreaterepoAvailable(); const dnfDir = buildDnfRepoRoot(args.pagesDir); + fs.rmSync(dnfDir, { recursive: true, force: true }); fs.mkdirSync(dnfDir, { recursive: true }); - // Collect RPM packages - const packages = []; - for (const arch of DNF_SUPPORTED_ARCHITECTURES) { - const assetName = buildRpmReleaseAssetName(args.version, arch); - const source = findSingleReleaseAsset(args.releaseAssetsDir, assetName); - const locationUrl = buildGitHubReleaseRpmUrl( - args.repoSlug, args.tag, args.version, arch, - ); - const sizeBytes = fs.statSync(source).size; - const hashes = getFileHashes(source); - - packages.push({ - name: "neverwrite", - arch, - version: args.version, - locationUrl, - sourcePath: source, - sizeBytes, - hashes, - }); - } + const tempRepositoryDir = fs.mkdtempSync(path.join(os.tmpdir(), "neverwrite-dnf-repo-")); + const indexedPackages = []; - // Build repodata - const repodataDir = path.join(dnfDir, "repodata"); - fs.mkdirSync(repodataDir, { recursive: true }); - - const metadataFiles = [ - writeCompressedMetadata( - repodataDir, - "primary.xml.gz", - buildPrimaryXml({ packages }), - ), - writeCompressedMetadata( - repodataDir, - "filelists.xml.gz", - buildFilelistsXml({ packages }), - ), - writeCompressedMetadata( - repodataDir, - "other.xml.gz", - buildOtherXml({ packages }), - ), - ]; + try { + // createrepo_c reads the RPM headers and payload file list from local packages, + // then we prefix package locations so DNF downloads the published GitHub assets. + const locationPrefix = buildGitHubReleaseRpmLocationPrefix(args.repoSlug, args.tag); + + for (const arch of DNF_SUPPORTED_ARCHITECTURES) { + const assetName = buildRpmReleaseAssetName(args.version, arch); + const source = findSingleReleaseAsset(args.releaseAssetsDir, assetName); + fs.copyFileSync(source, path.join(tempRepositoryDir, assetName)); + indexedPackages.push(`${assetName} (${arch})`); + } - const repomdXml = buildRepomdXml({ files: metadataFiles }); - const repomdPath = path.join(repodataDir, "repomd.xml"); - fs.writeFileSync(repomdPath, repomdXml, "utf8"); + runCommand("createrepo_c", buildCreaterepoArgs({ + repositoryDir: tempRepositoryDir, + locationPrefix, + })); + + copyGeneratedRepodata(tempRepositoryDir, dnfDir); + } finally { + fs.rmSync(tempRepositoryDir, { recursive: true, force: true }); + } - // Write repo example file fs.writeFileSync( path.join(dnfDir, DNF_REPO_EXAMPLE_FILE_NAME), buildNeverWriteRepoExample(), @@ -165,8 +174,8 @@ function main() { ); console.log(`DNF repository built at ${dnfDir}`); - console.log(`Packages indexed: ${packages.map((p) => `${p.name}-${p.version}.${p.arch}`).join(", ")}`); - console.log(`repodata: repomd.xml, primary.xml.gz, filelists.xml.gz, other.xml.gz`); + console.log(`Packages indexed from RPM headers: ${indexedPackages.join(", ")}`); + console.log("repodata generated by createrepo_c"); } main(); diff --git a/scripts/dnf-repo-lib.mjs b/scripts/dnf-repo-lib.mjs index 3a90af24..3343ff6f 100644 --- a/scripts/dnf-repo-lib.mjs +++ b/scripts/dnf-repo-lib.mjs @@ -1,13 +1,10 @@ -import crypto from "node:crypto"; -import fs from "node:fs"; import path from "node:path"; -import zlib from "node:zlib"; import { CANONICAL_RELEASE_PAGES_BASE_URL, normalizeReleaseVersion, buildRpmPackageAssetName, - buildGitHubReleaseAssetUrl, + parseGitHubRepoSlug, } from "./appcast-lib.mjs"; export const DNF_REPOSITORY_RELATIVE_ROOT = "dnf"; @@ -42,10 +39,15 @@ export function buildRpmReleaseAssetName(version, rpmArchitecture) { return buildRpmPackageAssetName(normalizeReleaseVersion(version), buildTarget); } -export function buildGitHubReleaseRpmUrl(repoSlug, tag, version, rpmArchitecture) { +export function buildGitHubReleaseRpmLocationPrefix(repoSlug, tag) { + parseGitHubRepoSlug(repoSlug); const normalizedTag = tag.startsWith("v") ? tag : `v${normalizeReleaseVersion(tag)}`; + return `https://github.com/${repoSlug}/releases/download/${normalizedTag}/`; +} + +export function buildGitHubReleaseRpmUrl(repoSlug, tag, version, rpmArchitecture) { const assetName = buildRpmReleaseAssetName(version, rpmArchitecture); - return buildGitHubReleaseAssetUrl(repoSlug, normalizedTag, assetName); + return `${buildGitHubReleaseRpmLocationPrefix(repoSlug, tag)}${encodeURIComponent(assetName)}`; } export const DNF_DEFAULT_BASE_URL = `${CANONICAL_RELEASE_PAGES_BASE_URL}/${DNF_REPOSITORY_RELATIVE_ROOT}`; @@ -65,132 +67,3 @@ export function buildNeverWriteRepoExample(baseUrl = DNF_DEFAULT_BASE_URL) { "", ].join("\n"); } - -const HASH_READ_BUFFER_SIZE_BYTES = 1024 * 1024; - -export function hashFile(filePath, algorithm) { - const hash = crypto.createHash(algorithm); - const buffer = Buffer.allocUnsafe(HASH_READ_BUFFER_SIZE_BYTES); - const fd = fs.openSync(filePath, "r"); - try { - let bytesRead = 0; - do { - bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null); - if (bytesRead > 0) { - hash.update(buffer.subarray(0, bytesRead)); - } - } while (bytesRead > 0); - } finally { - fs.closeSync(fd); - } - return hash.digest("hex"); -} - -export function getFileHashes(filePath) { - return { - md5: hashFile(filePath, "md5"), - sha1: hashFile(filePath, "sha1"), - sha256: hashFile(filePath, "sha256"), - }; -} - -export function getContentHashes(content) { - const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content); - return { - md5: crypto.createHash("md5").update(buffer).digest("hex"), - sha1: crypto.createHash("sha1").update(buffer).digest("hex"), - sha256: crypto.createHash("sha256").update(buffer).digest("hex"), - }; -} - -export function gzipContent(input) { - return zlib.gzipSync(Buffer.from(input, "utf8"), { level: 9, mtime: 0 }); -} - -export function xmlEscape(value) { - return String(value) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -export function buildPrimaryXml({ packages }) { - const now = new Date().toUTCString(); - const entries = packages.map((pkg) => { - return ` - ${xmlEscape(pkg.name)} - ${xmlEscape(pkg.arch)} - - ${pkg.hashes.sha256} - NeverWrite desktop knowledge workspace - NeverWrite is a local-first knowledge workspace for power users. - NeverWrite Team - https://neverwrite.app - `; - }); - - return ` - -${entries.join("\n")} -`; -} - -export function buildFilelistsXml({ packages }) { - const entries = packages.map((pkg) => ` - - `); - - return ` - -${entries.join("\n")} -`; -} - -export function buildOtherXml({ packages }) { - const entries = packages.map((pkg) => ` - - `); - - return ` - -${entries.join("\n")} -`; -} - -export function buildRepomdXml({ files }) { - const now = new Date().toUTCString(); - const entries = files.map((file) => { - const typeMap = { - "primary.xml.gz": "primary", - "filelists.xml.gz": "filelists", - "other.xml.gz": "other", - }; - const type = typeMap[file.relativePath] || "primary"; - return ` - ${file.hashes.sha256} - ${file.openHashes.sha256} - - ${Math.floor(Date.now() / 1000)} - ${file.sizeBytes} - ${file.openSizeBytes} - `; - }); - - return ` - -${entries.join("\n")} -`; -} diff --git a/scripts/dnf-repo.test.mjs b/scripts/dnf-repo.test.mjs index 5e0f55eb..112be520 100644 --- a/scripts/dnf-repo.test.mjs +++ b/scripts/dnf-repo.test.mjs @@ -3,11 +3,9 @@ import assert from "node:assert/strict"; import { DNF_DEFAULT_BASE_URL, buildRpmReleaseAssetName, + buildGitHubReleaseRpmLocationPrefix, buildGitHubReleaseRpmUrl, buildNeverWriteRepoExample, - buildPrimaryXml, - buildRepomdXml, - getContentHashes, normalizeRpmArchitecture, } from "./dnf-repo-lib.mjs"; @@ -22,6 +20,13 @@ test("RPM release asset names use RPM architecture naming", () => { ); }); +test("buildGitHubReleaseRpmLocationPrefix builds GitHub release asset prefix", () => { + assert.equal( + buildGitHubReleaseRpmLocationPrefix("jsgrrchg/NeverWrite", "v0.3.0"), + "https://github.com/jsgrrchg/NeverWrite/releases/download/v0.3.0/", + ); +}); + test("buildGitHubReleaseRpmUrl builds correct GitHub URL", () => { const url = buildGitHubReleaseRpmUrl( "jsgrrchg/NeverWrite", "v0.3.0", "0.3.0", "x86_64", @@ -46,45 +51,3 @@ test("normalizeRpmArchitecture accepts valid RPM architectures", () => { assert.throws(() => normalizeRpmArchitecture("amd64"), /Unsupported/); assert.throws(() => normalizeRpmArchitecture("arm64"), /Unsupported/); }); - -test("buildPrimaryXml generates valid XML with package metadata", () => { - const packages = [ - { - name: "neverwrite", - arch: "x86_64", - version: "0.3.0", - locationUrl: "https://github.com/jsgrrchg/NeverWrite/releases/download/v0.3.0/NeverWrite-0.3.0-x86_64.rpm", - sizeBytes: 1000000, - hashes: { sha256: "a".repeat(64) }, - }, - ]; - const xml = buildPrimaryXml({ packages }); - assert.match(xml, //); - assert.match(xml, /neverwrite<\/name>/); - assert.match(xml, /x86_64<\/arch>/); - assert.match(xml, / { - const files = [ - { - relativePath: "primary.xml.gz", - sizeBytes: 100, - openSizeBytes: 250, - hashes: { sha256: "b".repeat(64) }, - openHashes: { sha256: "c".repeat(64) }, - }, - ]; - const xml = buildRepomdXml({ files }); - assert.match(xml, //); - assert.match(xml, new RegExp(`${"c".repeat(64)}`)); - assert.match(xml, /250<\/open-size>/); -}); - -test("getContentHashes hashes uncompressed metadata content", () => { - assert.equal( - getContentHashes("neverwrite").sha256, - "ba11db9f5638d9c98918b6f05a7388d4fe4996ec40bc2a4c1f4451c6bcf095a2", - ); -}); diff --git a/scripts/validate-dnf-repository.mjs b/scripts/validate-dnf-repository.mjs index d2853019..a9f926af 100644 --- a/scripts/validate-dnf-repository.mjs +++ b/scripts/validate-dnf-repository.mjs @@ -9,7 +9,6 @@ import { DNF_REPO_EXAMPLE_FILE_NAME, DNF_SUPPORTED_ARCHITECTURES, DNF_PACKAGE_NAME, - hashFile, } from "./dnf-repo-lib.mjs"; import { normalizeReleaseVersion } from "./appcast-lib.mjs"; @@ -87,6 +86,15 @@ function validatePrimaryXml(dnfDir, version) { if (!content.includes(`${DNF_PACKAGE_NAME}`)) { throw new Error(`primary.xml missing package name "${DNF_PACKAGE_NAME}"`); } + if (!content.includes("")) { + throw new Error("primary.xml missing RPM provides metadata from package headers"); + } + if (!content.includes("")) { + throw new Error("primary.xml missing RPM requires metadata from package headers"); + } + if (!content.includes("([^<]+)<\/arch>/); if (!archMatch || !DNF_SUPPORTED_ARCHITECTURES.includes(archMatch[1])) { @@ -110,6 +118,20 @@ function validatePrimaryXml(dnfDir, version) { } } +function validateFilelistsXml(dnfDir) { + const filelistsGzPath = path.join(dnfDir, "repodata", "filelists.xml.gz"); + assertFileExists(filelistsGzPath, "filelists.xml.gz"); + + const inflated = zlib.gunzipSync(fs.readFileSync(filelistsGzPath)); + const content = inflated.toString("utf8"); + if (!content.includes(`name="${DNF_PACKAGE_NAME}"`)) { + throw new Error(`filelists.xml missing package name "${DNF_PACKAGE_NAME}"`); + } + if (!/)/.test(content)) { + throw new Error("filelists.xml missing installed file entries from RPM payload"); + } +} + function validateRepoExample(dnfDir) { const examplePath = path.join(dnfDir, DNF_REPO_EXAMPLE_FILE_NAME); assertFileExists(examplePath, "repo example file"); @@ -130,6 +152,7 @@ function main() { validateRepoExample(args.dnfDir); validateRepomd(args.dnfDir); validatePrimaryXml(args.dnfDir, args.version); + validateFilelistsXml(args.dnfDir); if (!args.skipSignatureCheck) { validateRepomdSignature(args.dnfDir); From 895f6100d8037a0e03f33d7008655b335784a6e3 Mon Sep 17 00:00:00 2001 From: jsgerrchg Date: Sat, 30 May 2026 08:24:36 -0400 Subject: [PATCH 17/17] fix(rpm): avoid dnf config-manager in install docs --- release/dnf/README.md | 10 +++++++++- scripts/release-assets-lib.mjs | 10 +++++++++- scripts/release-assets.test.mjs | 3 ++- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/release/dnf/README.md b/release/dnf/README.md index 1abfc6a9..ec9b32df 100644 --- a/release/dnf/README.md +++ b/release/dnf/README.md @@ -14,7 +14,15 @@ signature checks. ## User Install ```bash -sudo dnf config-manager --add-repo https://jsgrrchg.github.io/NeverWrite/dnf/neverwrite.repo.example +sudo tee /etc/yum.repos.d/neverwrite.repo >/dev/null <<'EOF' +[neverwrite] +name=NeverWrite +baseurl=https://jsgrrchg.github.io/NeverWrite/dnf +enabled=1 +gpgcheck=1 +repo_gpgcheck=1 +gpgkey=https://jsgrrchg.github.io/NeverWrite/dnf/neverwrite-archive-keyring.asc +EOF sudo rpm --import https://jsgrrchg.github.io/NeverWrite/dnf/neverwrite-archive-keyring.asc sudo dnf install neverwrite ``` diff --git a/scripts/release-assets-lib.mjs b/scripts/release-assets-lib.mjs index d268f763..1aea841a 100644 --- a/scripts/release-assets-lib.mjs +++ b/scripts/release-assets-lib.mjs @@ -345,7 +345,15 @@ export function buildReleaseBody(version, releaseNotes) { "DNF repository setup:", "", "```bash", - "sudo dnf config-manager --add-repo https://jsgrrchg.github.io/NeverWrite/dnf/neverwrite.repo.example", + "sudo tee /etc/yum.repos.d/neverwrite.repo >/dev/null <<'EOF'", + "[neverwrite]", + "name=NeverWrite", + "baseurl=https://jsgrrchg.github.io/NeverWrite/dnf", + "enabled=1", + "gpgcheck=1", + "repo_gpgcheck=1", + "gpgkey=https://jsgrrchg.github.io/NeverWrite/dnf/neverwrite-archive-keyring.asc", + "EOF", "sudo rpm --import https://jsgrrchg.github.io/NeverWrite/dnf/neverwrite-archive-keyring.asc", "sudo dnf install neverwrite", "```", diff --git a/scripts/release-assets.test.mjs b/scripts/release-assets.test.mjs index a731796f..a0d29e19 100644 --- a/scripts/release-assets.test.mjs +++ b/scripts/release-assets.test.mjs @@ -134,7 +134,8 @@ test("buildReleaseBody distinguishes manual installers from internal updater ass assert.match(body, /NeverWrite-0.2.0-x64\.AppImage/); assert.match(body, /NeverWrite-0\.2\.0-x86_64\.rpm/); assert.match(body, /configure the NeverWrite DNF repository/); - assert.match(body, /dnf config-manager --add-repo/); + assert.match(body, /sudo tee \/etc\/yum\.repos\.d\/neverwrite\.repo/); + assert.match(body, /repo_gpgcheck=1/); assert.match(body, /configure the NeverWrite APT repository/); assert.match(body, /neverwrite-archive-keyring\.asc/); assert.match(body, /internal updater assets/i);