Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,23 @@ jobs:
if: matrix.platform == 'macos'
env:
APPLE_SIGNING_IDENTITY: "-"
TAURI_BUNDLER_DMG_IGNORE_CI: "true"
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ""
run: make bundles TARGET=${{ matrix.target }} CONFIG=src-tauri/tauri.updater.conf.json
- name: Verify macOS DMG layout
if: matrix.platform == 'macos'
env:
RELEASE_TARGET: ${{ matrix.target }}
run: |
set -euo pipefail
shopt -s nullglob
dmg_paths=(target/"$RELEASE_TARGET"/release/bundle/dmg/*.dmg)
if [[ ${#dmg_paths[@]} -ne 1 ]]; then
echo "Expected exactly one macOS DMG for layout verification." >&2
exit 1
fi
./scripts/verify-macos-dmg.sh "${dmg_paths[0]}"
- name: Build Linux bundles
if: matrix.platform == 'linux'
env:
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Initial public release for bounded cross-platform and Agent compatibility.
- Optional age-encrypted backups for complete plans with a supported restore route.
- Atomic operation journal, startup recovery, post-mutation rescans, and all-or-nothing restore preflight and rollback.
- Unsigned macOS, Linux, and Windows packaging with SHA-256 checksums and build metadata.
- A guided macOS DMG install layout with CI checks for its Finder metadata and tracked background.
- User-initiated Tauri updates with a pinned public key and signed updater artifacts.

### Safety boundary
Expand Down
Binary file modified assets/branding/dmg-background.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
37 changes: 37 additions & 0 deletions assets/branding/dmg-background.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions docs/releases/v0.1.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ This checklist is the evidence record for the first CleanerX release. Do not pub
- [ ] The release workflow accepts the SemVer tag and confirms every version surface matches.
- [ ] Product CI reruns successfully from the exact tag.
- [ ] macOS arm64, macOS x86_64, Linux x86_64, and Windows x86_64 release builds succeed.
- [ ] Both macOS DMGs retain the tracked guided-install background, configured Finder window, large icon positions, and exact `/Applications` link.
- [ ] Every expected manual installer, updater payload, `.sig`, `latest.json`, lockfile archive, build-metadata file, and checksum file appears exactly once in the draft.
- [ ] `latest.json` contains version `0.1.0`, the four intended updater targets, HTTPS URLs for the exact tag, and non-empty signatures.
- [ ] Every staged asset matches `CleanerX_0.1.0_SHA256SUMS.txt`.
Expand Down
2 changes: 2 additions & 0 deletions docs/update-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ The fixed stable endpoint intentionally excludes GitHub prereleases. A future be

Normal developer bundle commands remain unsigned and do not require the updater private key. Tagged releases add `src-tauri/tauri.updater.conf.json`, set `TAURI_SIGNING_PRIVATE_KEY` from GitHub Actions secrets, and ask Tauri to create update artifacts and `.sig` files. The release-draft job gathers every architecture, runs `scripts/generate-update-manifest.mjs`, and fails closed on a missing/empty signature before staging `latest.json` and checksums in a draft GitHub Release. A maintainer verifies the complete draft before publishing it.

[Tauri normally skips Finder cosmetic metadata when it detects `CI=true`](https://github.com/tauri-apps/tauri/blob/tauri-cli-v2.11.4/crates/tauri-bundler/src/bundle/macos/dmg/mod.rs#L160-L168), which would silently reduce a configured DMG to Finder's default icon view. The macOS release jobs explicitly set Tauri's supported `TAURI_BUNDLER_DMG_IGNORE_CI=true` override, then mount each completed DMG read-only and require the tracked field-manual background, a non-empty `.DS_Store` selecting that background and the configured window size, the application bundle, and the exact `/Applications` link. A missing or stale layout fails the release before assets are staged.

WiX/MSI does not accept alphanumeric Semantic Versioning prerelease identifiers. The Windows release job therefore uses `scripts/generate-windows-release-config.mjs` to derive a numeric MSI-only version that sorts below the eventual stable version (`0.1.0-alpha.1` becomes `0.0.65535.10001`). The application, tag, updater manifest, NSIS installer, and release asset names retain the public Semantic Versioning value. Unsupported prerelease shapes fail during tag validation before platform builds begin.

Tauri's signature establishes continuity with the public key embedded in an installed CleanerX build. It does not establish operating-system publisher identity. Artifact names and release warnings therefore continue to say that current binaries are unsigned and not notarized.
102 changes: 102 additions & 0 deletions scripts/verify-macos-dmg-layout.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { createHash } from "node:crypto";
import { lstat, readFile, readlink } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";

const expectedWindowSizeMarker = "760, 440";

function digest(contents) {
return createHash("sha256").update(contents).digest("hex");
}

async function pathKind(target) {
try {
const stat = await lstat(target);
if (stat.isDirectory()) return "directory";
if (stat.isSymbolicLink()) return "symbolic-link";
if (stat.isFile()) return "file";
return "other";
} catch (error) {
if (error?.code === "ENOENT") return "missing";
throw error;
}
}

export function validateDmgLayout(snapshot) {
const problems = [];

if (snapshot.appKind !== "directory") {
problems.push("CleanerX.app must be a real directory");
}
if (snapshot.applicationsKind !== "symbolic-link") {
problems.push("Applications must be a symbolic link");
} else if (snapshot.applicationsTarget !== "/Applications") {
problems.push("Applications must link exactly to /Applications");
}
if (snapshot.dsStoreKind !== "file" || snapshot.dsStoreBytes < 512) {
problems.push(".DS_Store must be a non-empty Finder layout file");
}
if (!snapshot.dsStoreContainsBackground) {
problems.push(".DS_Store must select dmg-background.png");
}
if (!snapshot.dsStoreContainsWindowSize) {
problems.push(".DS_Store must retain the configured 760 x 440 window size");
}
if (snapshot.backgroundKind !== "file") {
problems.push("the DMG background must be a real file");
} else if (snapshot.backgroundDigest !== snapshot.expectedBackgroundDigest) {
problems.push("the staged DMG background must match the tracked background");
}

if (problems.length > 0) {
throw new Error(`Invalid macOS DMG layout:\n- ${problems.join("\n- ")}`);
}

return snapshot;
}

export async function inspectMountedDmg(mountRoot, expectedBackgroundPath) {
const dsStorePath = path.join(mountRoot, ".DS_Store");
const backgroundPath = path.join(mountRoot, ".background", "dmg-background.png");
const applicationsPath = path.join(mountRoot, "Applications");
const dsStoreKind = await pathKind(dsStorePath);
const backgroundKind = await pathKind(backgroundPath);
const dsStore = dsStoreKind === "file" ? await readFile(dsStorePath) : Buffer.alloc(0);
const background = backgroundKind === "file" ? await readFile(backgroundPath) : Buffer.alloc(0);
const expectedBackground = await readFile(expectedBackgroundPath);
const dsStoreText = dsStore.toString("latin1");
const applicationsKind = await pathKind(applicationsPath);

return validateDmgLayout({
appKind: await pathKind(path.join(mountRoot, "CleanerX.app")),
applicationsKind,
applicationsTarget:
applicationsKind === "symbolic-link" ? await readlink(applicationsPath) : null,
dsStoreKind,
dsStoreBytes: dsStore.byteLength,
dsStoreContainsBackground: dsStoreText.includes("dmg-background.png"),
dsStoreContainsWindowSize: dsStoreText.includes(expectedWindowSizeMarker),
backgroundKind,
backgroundDigest: digest(background),
expectedBackgroundDigest: digest(expectedBackground),
});
}

const isMain = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);

if (isMain) {
if (process.argv.length !== 4) {
console.error("Usage: node scripts/verify-macos-dmg-layout.mjs <mounted-dmg-root> <expected-background>");
process.exit(2);
}

try {
const snapshot = await inspectMountedDmg(process.argv[2], process.argv[3]);
console.log(
`Verified Finder layout (${snapshot.dsStoreBytes} byte .DS_Store, background ${snapshot.backgroundDigest}).`,
);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
40 changes: 40 additions & 0 deletions scripts/verify-macos-dmg-layout.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { validateDmgLayout } from "./verify-macos-dmg-layout.mjs";

const validSnapshot = {
appKind: "directory",
applicationsKind: "symbolic-link",
applicationsTarget: "/Applications",
dsStoreKind: "file",
dsStoreBytes: 10_240,
dsStoreContainsBackground: true,
dsStoreContainsWindowSize: true,
backgroundKind: "file",
backgroundDigest: "expected-digest",
expectedBackgroundDigest: "expected-digest",
};

describe("macOS DMG layout validation", () => {
it("accepts the intended Finder layout snapshot", () => {
expect(validateDmgLayout(validSnapshot)).toEqual(validSnapshot);
});

it("rejects the unstyled CI layout", () => {
expect(() => validateDmgLayout({
...validSnapshot,
dsStoreKind: "missing",
dsStoreBytes: 0,
dsStoreContainsBackground: false,
dsStoreContainsWindowSize: false,
})).toThrow(/Finder layout file[\s\S]*select dmg-background[\s\S]*760 x 440/);
});

it("rejects a changed background or unsafe Applications entry", () => {
expect(() => validateDmgLayout({
...validSnapshot,
applicationsKind: "directory",
applicationsTarget: null,
backgroundDigest: "different-digest",
})).toThrow(/symbolic link[\s\S]*match the tracked background/);
});
});
39 changes: 39 additions & 0 deletions scripts/verify-macos-dmg.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#!/usr/bin/env bash

set -euo pipefail

if [[ $(uname -s) != "Darwin" ]]; then
echo "verify-macos-dmg requires macOS." >&2
exit 2
fi
if [[ $# -ne 1 || ! -f $1 ]]; then
echo "Usage: scripts/verify-macos-dmg.sh <path-to-dmg>" >&2
exit 2
fi

script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repository_root="$(cd "$script_dir/.." && pwd)"
dmg_path="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"
mount_dir="$(mktemp -d -t cleanerx-dmg-verify)"
mounted=false

cleanup() {
if [[ $mounted == true ]]; then
hdiutil detach "$mount_dir" >/dev/null 2>&1 || true
fi
rmdir "$mount_dir" >/dev/null 2>&1 || true
}
trap cleanup EXIT

hdiutil verify "$dmg_path" >/dev/null
hdiutil attach -readonly -nobrowse -mountpoint "$mount_dir" "$dmg_path" >/dev/null
mounted=true

node "$repository_root/scripts/verify-macos-dmg-layout.mjs" \
"$mount_dir" \
"$repository_root/assets/branding/dmg-background.png"

hdiutil detach "$mount_dir" >/dev/null
mounted=false
rmdir "$mount_dir"
trap - EXIT