diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 40367c09..348c9902 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 + 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: | @@ -369,6 +398,16 @@ 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 }}" \ + --require-signature + - name: Upload release asset artifact uses: actions/upload-artifact@v4 with: @@ -815,8 +854,8 @@ jobs: } EOF - - name: Install APT repository tools - run: sudo apt-get update && sudo apt-get install -y dpkg gnupg gzip + - name: Install Linux repository tools + run: sudo apt-get update && sudo apt-get install -y createrepo-c dpkg gnupg gzip rpm - name: Build APT repository shell: bash @@ -831,6 +870,18 @@ jobs: --suite stable \ --component main + - 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: @@ -857,6 +908,30 @@ jobs: --key-id "$APT_REPO_GPG_KEY_ID" \ --suite stable + - name: Sign DNF repository + 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 }} + PAGES_DIR: ${{ runner.temp }}/gh-pages + 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 + node scripts/sign-dnf-repository.mjs \ + --dnf-dir "$PAGES_DIR/dnf" \ + --key-id "$APT_REPO_GPG_KEY_ID" + - name: Validate APT repository shell: bash env: @@ -868,6 +943,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: @@ -895,7 +979,31 @@ jobs: apt-get install -y --download-only "neverwrite=$VERSION" ' - - name: Publish APT repository to gh-pages + - 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/ + 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 + 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." + ' + + - name: Publish Linux repositories to gh-pages shell: bash env: PAGES_DIR: ${{ runner.temp }}/gh-pages @@ -904,11 +1012,11 @@ 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 apt .nojekyll + git add apt dnf .nojekyll if git diff --cached --quiet; then - echo "No APT repository changes to publish." + echo "No Linux repository changes to publish." else - git commit -m "Publish APT repository for ${{ needs.prepare.outputs.tag }}" + git commit -m "Publish Linux repositories for ${{ needs.prepare.outputs.tag }}" git push origin HEAD:gh-pages fi ) diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index dcf1f4d8..aed7d888 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,10 @@ export default { artifactName: "${productName}-${version}-${arch}.deb", publish: null, }, + 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 060a03a2..a0bb77c8 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"); @@ -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); +}); 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/, + ); + }); +}); 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..fe5cecb0 --- /dev/null +++ b/apps/desktop/scripts/validate-linux-rpm-package.mjs @@ -0,0 +1,129 @@ +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, + requireSignature: 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; + } + if (arg === "--require-signature") { + args.requireSignature = 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 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(); + + const expectedAssetName = buildRpmPackageAssetName(args.version, args.buildTarget); + const rpmPath = findRpmPackage(args.stagedAssetsDir, expectedAssetName); + + 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]); + + 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(); 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. diff --git a/release/dnf/README.md b/release/dnf/README.md new file mode 100644 index 00000000..ec9b32df --- /dev/null +++ b/release/dnf/README.md @@ -0,0 +1,53 @@ +# 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 generated from the signed RPM headers with `createrepo_c`. The +repository enables both RPM package signature checks and repository metadata +signature checks. + +## User Install + +```bash +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 +``` + +## 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 +sudo dnf install --downloadonly neverwrite +``` 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/build-dnf-repository.mjs b/scripts/build-dnf-repository.mjs new file mode 100644 index 00000000..97a1363f --- /dev/null +++ b/scripts/build-dnf-repository.mjs @@ -0,0 +1,181 @@ +import childProcess from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { + DNF_SUPPORTED_ARCHITECTURES, + DNF_REPO_EXAMPLE_FILE_NAME, + buildDnfRepoRoot, + buildRpmReleaseAssetName, + buildGitHubReleaseRpmLocationPrefix, + buildNeverWriteRepoExample, +} 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 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; +} + +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 }); + + const tempRepositoryDir = fs.mkdtempSync(path.join(os.tmpdir(), "neverwrite-dnf-repo-")); + const indexedPackages = []; + + 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})`); + } + + runCommand("createrepo_c", buildCreaterepoArgs({ + repositoryDir: tempRepositoryDir, + locationPrefix, + })); + + copyGeneratedRepodata(tempRepositoryDir, dnfDir); + } finally { + fs.rmSync(tempRepositoryDir, { recursive: true, force: true }); + } + + fs.writeFileSync( + path.join(dnfDir, DNF_REPO_EXAMPLE_FILE_NAME), + buildNeverWriteRepoExample(), + "utf8", + ); + + console.log(`DNF repository built at ${dnfDir}`); + 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 new file mode 100644 index 00000000..3343ff6f --- /dev/null +++ b/scripts/dnf-repo-lib.mjs @@ -0,0 +1,69 @@ +import path from "node:path"; + +import { + CANONICAL_RELEASE_PAGES_BASE_URL, + normalizeReleaseVersion, + buildRpmPackageAssetName, + parseGitHubRepoSlug, +} 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 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 `${buildGitHubReleaseRpmLocationPrefix(repoSlug, tag)}${encodeURIComponent(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", + "repo_gpgcheck=1", + `gpgkey=${normalizedUrl}/${DNF_PUBLIC_KEY_FILE_NAME}`, + "", + ].join("\n"); +} diff --git a/scripts/dnf-repo.test.mjs b/scripts/dnf-repo.test.mjs new file mode 100644 index 00000000..112be520 --- /dev/null +++ b/scripts/dnf-repo.test.mjs @@ -0,0 +1,53 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + DNF_DEFAULT_BASE_URL, + buildRpmReleaseAssetName, + buildGitHubReleaseRpmLocationPrefix, + buildGitHubReleaseRpmUrl, + buildNeverWriteRepoExample, + 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("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", + ); + 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, /repo_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/); +}); 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 = [ 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( diff --git a/scripts/release-assets-lib.mjs b/scripts/release-assets-lib.mjs index 99ac00a7..1aea841a 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,23 @@ 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 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", + "```", + "", "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..a0d29e19 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,10 @@ 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, /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); 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".', 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(); 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 new file mode 100644 index 00000000..a9f926af --- /dev/null +++ b/scripts/validate-dnf-repository.mjs @@ -0,0 +1,164 @@ +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, +} 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}"`); + } + 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])) { + throw new Error(`primary.xml has unsupported architecture: ${archMatch ? archMatch[1] : "missing"}`); + } + + const locationMatch = content.match(/)/.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"); + 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() { + const args = parseArgs(process.argv.slice(2)); + + assertFileExists(args.dnfDir, "DNF repository root"); + assertFileExists(path.join(args.dnfDir, "repodata"), "repodata directory"); + assertFileExists(path.join(args.dnfDir, "repodata", "repomd.xml"), "repomd.xml"); + assertFileExists(path.join(args.dnfDir, "repodata", "repomd.xml.asc"), "repomd.xml.asc"); + + validateRepoExample(args.dnfDir); + validateRepomd(args.dnfDir); + validatePrimaryXml(args.dnfDir, args.version); + validateFilelistsXml(args.dnfDir); + + if (!args.skipSignatureCheck) { + validateRepomdSignature(args.dnfDir); + } + + console.log(`DNF repository is valid${args.version ? ` for version ${args.version}` : ""}.`); +} + +main();