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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"name": "gajae-app",
"private": true,
"version": "2.0.0-beta.9",
"desktopVersion": "0.2.3",
"version": "2.0.0-beta.10",
"desktopVersion": "0.2.4",
"productName": "Gajae Code App",
"description": "A self-hosted web and desktop interface for GJC",
"type": "module",
Expand Down
188 changes: 188 additions & 0 deletions scripts/release/MANUAL-RELEASE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
# Draft-first manual, updater-disabled release

This is a separate, explicit release lane for a signed/notarized **manual
installation only** app. It does not complete or claim auto-install acceptance.
`local-release.mjs` and CI retain their updater key, archive/signature/manifest,
eight canonical assets, and full archive/DMG equivalence requirements unchanged.
Do not supply fabricated updater keys or publish updater metadata for this lane.

The parent owns source freeze, accepted builds from that exact commit, signing,
notarization, release notes, draft creation/upload, and actual publication.
Runtime, standard packaged-server smoke, separate data-survival smoke, GUI and
Linux acceptance remain parent prerequisites. For a test prerelease, record the
actual macOS version tested separately from the declared loader floor. Do not
claim macOS 13 execution from loader stamps or a macOS 26 test; real minimum-OS
qualification remains a separately disclosed pending result.
Independent SHA-256 pins identify the operator's accepted local artifacts;
they are not reproducible-build provenance. Never derive expected hashes from
the remote draft being checked.

## Exact assets

For beta.10, the required assets are:

- `gajae-app-desktop-2.0.0-beta.10-macos-arm64.dmg`
- `gajae-app-desktop-2.0.0-beta.10-macos-arm64.dmg.sha256`
- `gajae-app-server-2.0.0-beta.10-linux-x64-node22.tar.gz`
- `gajae-app-server-2.0.0-beta.10-linux-x64-node22.tar.gz.sha256`

Optional payloads are **only** the exact versioned
`gajae-app-desktop-2.0.0-beta.10-linux-x64.deb` and
`gajae-app-desktop-2.0.0-beta.10-linux-x64.AppImage`, each with its own independent
`--asset` pin and `.sha256` sidecar. Their installer/platform acceptance is
separate; this verifier checks their hashes and sidecars only.

Every checksum is exactly `LOWERCASE_SHA256 PAYLOAD_BASENAME` on one line.
Updater `.app.tar.gz`, `.sig`, `desktop-update.json`, unlisted payloads,
unlisted sidecars, duplicates, incomplete uploads and missing assets all block.
Existing assets are never removed or replaced to make the set pass.

## Verify the local DMG before parent-owned smoke/GUI acceptance

After signing/notarization/stapling are complete, use the helper with the
explicit manual flag. A separately versioned verifier-only correction does
not change the frozen app/Linux source or release-tag target. For beta.10 those
remain `a6b06a25bdcc8fc7e4980175fc757d93b28becc5`; record the verifier revision
separately, and read identity/config/manifest pins from that frozen source.

```js
import { createHash } from 'node:crypto';
import { mkdtemp, readFile, realpath } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { verifyMacosRelease } from './scripts/release/local-release-macos.mjs';

// Verify these source files against the frozen release commit first;
// verifier-only HEAD may differ and must be recorded separately.
const source = JSON.parse(await readFile('package.json', 'utf8'));
const config = JSON.parse(await readFile('src-tauri/tauri.conf.json', 'utf8'));
const runtimeManifestSha256 = createHash('sha256')
.update(await readFile('server/gjc-runtime-manifest.json')).digest('hex');
const root = await realpath(await mkdtemp(join(tmpdir(), 'gajae-manual-acceptance-')));
const verified = await verifyMacosRelease({
dmg: process.env.DMG, // Absolute path to the final accepted candidate image.
root, // Fresh, owner-only, outside any checkout/node_modules ancestor.
teamId: '5987KT43TJ',
version: source.version, // 2.0.0-beta.10
desktopVersion: source.desktopVersion, // 0.2.4
minimumSystemVersion: config.bundle.macOS.minimumSystemVersion, // 13.0
manualDisabled: true,
runtimeManifestSha256,
});
console.log(JSON.stringify({ root, copiedApp: verified.copiedApp,
buildInfo: verified.buildInfo, deployment: verified.deployment }, null, 2));
```

Do not pass `updaterArchivePath` with `manualDisabled: true`; even a null or
empty archive option is rejected. Omitting the manual flag **still requires**
a verified updater archive and complete archive equivalence.

The helper verifies the DMG and both mounted/copied apps: expected Developer ID
team, hardened app signatures, staples, Gatekeeper, package and desktop versions,
arm64 executables, and the pinned macOS minimum. Every inventoried regular file
is checked for Mach-O magic (including universal binaries and extensionless
helpers). macOS binaries must have supported macOS loader stamps no newer than
the declared minimum, including any MACOS binary placed in an iOS folder.
The sole non-Mac resource exception is beneath the canonical
`Contents/Resources/resources/server-payload/node_modules/` path:
`bare-*/prebuilds/ios-(arm64|x64)(-simulator)?/*.bare`. Each discovered resource
is still inspected with vtool and must have structurally valid, uniform `IOS`
stamps for device folders or `IOSSIMULATOR` stamps for simulator folders.
Unknown platforms, malformed/duplicate/mixed evidence, mismatched folders and
foreign binaries anywhere else fail. Named required runtimes/modules remain required.
`deployment.nonMacResourceCount` and `deployment.nonMacResources` record the
inspected exclusions (path, platform and minimum versions), separately from
macOS-only `deployment.stamps` and `maximumStampedMinimumSystemVersion`.
These resource exclusions are not macOS runtime qualification or a signature exemption.
Full mounted/copy inventories must match in bytes, modes and internal symlinks.

Only **after all copied-app validation** does the helper execute
`Contents/MacOS/gajae-app-desktop --desktop-build-info`, directly and without
UI/browser IPC. Timeout: 10 seconds. Stdout: an exclusively created, owner-only
file capped at 4 KiB while streaming. Stderr must be empty (the shared command
transport also has an 8 MiB total diagnostic-stream cap). The diagnostic must
exit successfully with exactly this eight-field JSON schema, without extras,
duplicates or missing fields:

```json
{
"schemaVersion": 1,
"packageName": "gajae-app",
"productVersion": "2.0.0-beta.10",
"desktopVersion": "0.2.4",
"debug": false,
"updateMode": "disabled",
"runtimeManifestSha256": "<SHA256 of the pinned source runtime manifest bytes>",
"payloadRuntimeManifestSha256": "<SHA256 of the finalized signed payload manifest>"
}
```

Finalization signs nested native modules and restamps their runtime manifests,
then rebuilds the desktop with that exact finalized manifest digest before the
outer app signature. The original source digest stays separate. The two values
must match their respective pinned source and verified signed payload bytes;
runtime startup still enforces an exact compiled payload digest (no fallback).

These are compile-time constants from the early CLI path, before app, QA,
profile, updater or lifecycle initialization. No absence-of-assets inference
is accepted. The copied app inventory is checked again after the diagnostic.

On success, the image is detached and `copiedApp` remains for the parent's
separate standard/data-survival/GUI acceptance. The API caller owns the temp
directory. If detachment cannot be confirmed, the helper throws with
`preserveDirectory: true` and reports the directory: inspect/detach before any
cleanup. Never recursively remove a directory that may contain a mounted image.

## Existing draft verification and explicit publication

The parent creates one unpublished draft with the exact full commit as its
target, reviewed manual/updater-disabled notes, correct prerelease status and
the accepted asset set. Do not use a branch target or clobber existing assets.
The manual CLI never signs, notarizes, installs, creates drafts, uploads files,
reads signing keys, or changes tags directly. It uses the parent's existing
authenticated `gh` session only when the parent invokes it for verification.

From the frozen release checkout, supply explicit reviewed values:

```sh
verify_args=(
--repo devswha/gajae-code-app --draft-id "$DRAFT_ID"
--tag v2.0.0-beta.10 --commit "$RELEASE_COMMIT" --team-id 5987KT43TJ
--asset "gajae-app-desktop-2.0.0-beta.10-macos-arm64.dmg=$DMG_SHA"
--asset "gajae-app-server-2.0.0-beta.10-linux-x64-node22.tar.gz=$SERVER_SHA"
)
node scripts/release/manual-release.mjs "${verify_args[@]}"
# Only after acceptance and review, with other draft/tag publishers stopped:
node scripts/release/manual-release.mjs "${verify_args[@]}" --publish
```

The first command is read-only on GitHub and reports `verified-draft`. The
second re-downloads and repeats every check before one numeric release-ID
`gh api ... --method PATCH --field draft=false`. Publication may cause GitHub
to create an absent tag at the exact pinned target; an existing lightweight or
annotated tag must already resolve to that commit. Title, notes, prerelease
status and the exact asset snapshot must be preserved in the response.

Every invocation verifies remote commit/package identity, raw source manifest
SHA-256, full independently pinned downloads and checksum sidecars, server
archive root package identity, strict Mac validation and positive disabled
binary evidence. Complete published desktop-version history must be proven,
and the candidate must advance beyond the history and the existing `0.2.3`
baseline. After local validation, private download bytes, history, draft
metadata/paginated assets, and tag are checked again. Missing or changed
evidence blocks publication. Download snapshots use exclusive file creation
inside private temp directories, inspected byte-count streaming caps and
read-only payload files. Existing shared payload/metadata limits apply.

CLI temp downloads and copied apps are cleaned normally; use the local API
above for a retained acceptance copy. API exports for orchestration/tests are
`manualReleaseOptions(values)`, `validateManualDraft(release, options)` and
`processManualRelease(options)`. No prior report bypasses verification.

Exit codes: 0 for success, 2 for invalid CLI arguments, 1 for verification or
publication errors. Before publication, errors report `blocked`. Any failure
after the publication request starts reports `publication-outcome-unknown`,
including unexpected responses and cleanup failures. Inspect the exact release
ID before any retry. There is no automatic retry, rollback or asset deletion.
The last recheck and PATCH are not atomic: single-publisher/no-concurrent-edits
discipline is required.
146 changes: 79 additions & 67 deletions scripts/release/finalize-macos-app.mjs
Original file line number Diff line number Diff line change
@@ -1,29 +1,19 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { existsSync, openSync, closeSync, readSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { existsSync, openSync, closeSync, readSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';

import { readAppRuntimeManifests, rebuildSignedMacosDesktop } from './rebuild-signed-macos-desktop.mjs';

const rootDir = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
const defaultApp = join(
rootDir,
'src-tauri/target/aarch64-apple-darwin/release/bundle/macos/Gajae Code App.app',
);
const appIndex = process.argv.indexOf('--app');
const appPath = appIndex >= 0 && process.argv[appIndex + 1] ? process.argv[appIndex + 1] : defaultApp;
const entitlements = join(rootDir, 'src-tauri/entitlements.plist');
/**
* Ad-hoc is the default so unsigned local builds keep working. Notarization
* requires a Developer ID identity and a secure timestamp on every Mach-O in
* the bundle, which ad-hoc signatures cannot carry.
*/
const identity = process.env.APPLE_SIGNING_IDENTITY?.trim() || '-';
const adhoc = identity === '-';
const timestamp = adhoc ? '--timestamp=none' : '--timestamp';

function run(command, args, { combined = false } = {}) {
const result = spawnSync(command, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
function run(command, args, { combined = false, cwd, env } = {}) {
const result = spawnSync(command, args, { cwd, env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 16 * 1024 * 1024 });
if (result.status !== 0) {
throw new Error(`${command} ${args.join(' ')} failed (${result.status}): ${result.stderr || result.stdout}`);
}
Expand Down Expand Up @@ -67,7 +57,7 @@ function executableClosure(directory) {
return files;
}

function signArguments(filePath) {
function signArguments(filePath, { identity, timestamp, entitlements }) {
const name = filePath.slice(filePath.lastIndexOf('/') + 1);
const hardened = ['--force', '--sign', identity, timestamp, '--options', 'runtime'];
return NATIVE_HOSTS.has(name) ? [...hardened, '--entitlements', entitlements] : hardened;
Expand Down Expand Up @@ -140,59 +130,81 @@ function restampRuntimeManifests(payloadDir, manifestPaths) {
return [...new Set(restamped)].sort();
}

if (process.platform !== 'darwin' || process.arch !== 'arm64') {
throw new Error(`macOS app finalization requires darwin-arm64; received ${process.platform}-${process.arch}.`);
}
if (!existsSync(appPath)) throw new Error(`App bundle not found: ${appPath}`);
if (!existsSync(entitlements)) throw new Error(`Entitlements file not found: ${entitlements}`);
if (!adhoc && !run('security', ['find-identity', '-v', '-p', 'codesigning']).includes(identity)) {
throw new Error(`Signing identity is not available in the keychain: ${identity}`);
}

const resources = join(appPath, 'Contents', 'Resources');
const payloadDir = join(resources, 'resources', 'server-payload');
const sidecar = join(appPath, 'Contents', 'MacOS', 'gajae-app-server');
const desktop = join(appPath, 'Contents', 'MacOS', 'gajae-app-desktop');
const nestedExecutables = executableClosure(resources).sort();
const manifestPaths = assertManifestProvenance(payloadDir);
/** Ad-hoc remains the default; Developer ID builds timestamp every signature. */
export async function finalizeMacosApp({
appPath = defaultApp, sourceRoot = rootDir, inheritedEnv = process.env,
execute = run, resolveTargetDirectory, platform = process.platform, arch = process.arch,
} = {}) {
const entitlements = join(sourceRoot, 'src-tauri/entitlements.plist');
const identity = inheritedEnv.APPLE_SIGNING_IDENTITY?.trim() || '-';
const adhoc = identity === '-';
const timestamp = adhoc ? '--timestamp=none' : '--timestamp';
const invoke = (command, args, options = {}) => execute(command, args, { env: inheritedEnv, ...options });
if (platform !== 'darwin' || arch !== 'arm64') {
throw new Error(`macOS app finalization requires darwin-arm64; received ${platform}-${arch}.`);
}
if (!existsSync(appPath)) throw new Error(`App bundle not found: ${appPath}`);
if (!existsSync(entitlements)) throw new Error(`Entitlements file not found: ${entitlements}`);
if (!adhoc && !(await invoke('security', ['find-identity', '-v', '-p', 'codesigning'])).includes(identity)) {
throw new Error(`Signing identity is not available in the keychain: ${identity}`);
}

for (const executable of nestedExecutables) {
run('codesign', [...signArguments(executable), executable]);
}
const resources = join(appPath, 'Contents', 'Resources');
const payloadDir = join(resources, 'resources', 'server-payload');
const sidecar = join(appPath, 'Contents', 'MacOS', 'gajae-app-server');
const desktop = join(appPath, 'Contents', 'MacOS', 'gajae-app-desktop');
const nestedExecutables = executableClosure(resources).sort();
await readAppRuntimeManifests(appPath);
const manifestPaths = assertManifestProvenance(payloadDir);

const restamped = restampRuntimeManifests(payloadDir, manifestPaths);

run('codesign', [
'--force', '--sign', identity, timestamp, '--options', 'runtime',
'--entitlements', entitlements, sidecar,
]);
run('codesign', [
'--force', '--sign', identity, timestamp, '--options', 'runtime',
'--entitlements', entitlements, appPath,
]);

for (const executable of nestedExecutables) run('codesign', ['--verify', '--strict', executable]);
run('codesign', ['--verify', '--strict', sidecar]);
run('codesign', ['--verify', '--strict', desktop]);
run('codesign', ['--verify', '--deep', '--strict', appPath]);
run('lipo', [desktop, '-verify_arch', 'arm64']);
run('lipo', [sidecar, '-verify_arch', 'arm64']);

const sidecarEntitlements = run('codesign', ['-d', '--entitlements', ':-', sidecar], { combined: true });
for (const entitlement of [
'com.apple.security.cs.allow-jit',
'com.apple.security.cs.allow-unsigned-executable-memory',
'com.apple.security.cs.disable-library-validation',
]) {
if (!sidecarEntitlements.includes(`<key>${entitlement}</key>`)) {
throw new Error(`Sidecar is missing required entitlement: ${entitlement}`);
for (const executable of nestedExecutables) {
await invoke('codesign', [...signArguments(executable, { identity, timestamp, entitlements }), executable]);
}
await invoke('codesign', [
'--force', '--sign', identity, timestamp, '--options', 'runtime',
'--entitlements', entitlements, sidecar,
]);

const restamped = restampRuntimeManifests(payloadDir, manifestPaths);
const rebuilt = await rebuildSignedMacosDesktop({ rootDir: sourceRoot, appPath, inheritedEnv },
{ execute, resolveTargetDirectory });

// All nested signing/restamping is complete. Seal only the outer app now.
await invoke('codesign', [
'--force', '--sign', identity, timestamp, '--options', 'runtime',
'--entitlements', entitlements, appPath,
]);

for (const executable of nestedExecutables) await invoke('codesign', ['--verify', '--strict', executable]);
await invoke('codesign', ['--verify', '--strict', sidecar]);
await invoke('codesign', ['--verify', '--strict', desktop]);
await invoke('codesign', ['--verify', '--deep', '--strict', appPath]);
await invoke('lipo', [desktop, '-verify_arch', 'arm64']);
await invoke('lipo', [sidecar, '-verify_arch', 'arm64']);

const sidecarEntitlements = await invoke('codesign', ['-d', '--entitlements', ':-', sidecar], { combined: true });
for (const entitlement of [
'com.apple.security.cs.allow-jit',
'com.apple.security.cs.allow-unsigned-executable-memory',
'com.apple.security.cs.disable-library-validation',
]) {
if (!sidecarEntitlements.includes(`<key>${entitlement}</key>`)) {
throw new Error(`Sidecar is missing required entitlement: ${entitlement}`);
}
}

return {
ok: true,
app: appPath,
nestedExecutables: nestedExecutables.map(filePath => relative(appPath, filePath)),
restampedNatives: restamped,
payloadRuntimeManifestSha256: rebuilt.payloadRuntimeManifestSha256,
signature: adhoc ? 'adhoc' : identity,
};
}

console.log(JSON.stringify({
ok: true,
app: appPath,
nestedExecutables: nestedExecutables.map(filePath => relative(appPath, filePath)),
restampedNatives: restamped,
signature: adhoc ? 'adhoc' : identity,
}, null, 2));
if (process.argv[1] && realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1])) {
const appIndex = process.argv.indexOf('--app');
const appPath = appIndex >= 0 && process.argv[appIndex + 1] ? process.argv[appIndex + 1] : defaultApp;
console.log(JSON.stringify(await finalizeMacosApp({ appPath }), null, 2));
}
Loading