Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
13 changes: 12 additions & 1 deletion .github/RELEASE_CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Create a GitHub Environment named `release`. Add required reviewers if the repos
1. Confirm the intended commit is on `main`, CI is green, and `apps/desktop/package.json` contains a version that has never been released.
2. In GitHub Actions, run `Release macOS arm64` against `main`.
3. Confirm every workflow step passes and a draft release named `v<version>` exists.
4. Confirm the draft records the intended commit SHA and contains the DMG, its `.sha256` file, the ZIP, and `latest-mac.yml`.
4. Confirm the draft records the intended commit SHA and contains the DMG, CLI/TUI tarball, both `.sha256` files, the ZIP, and `latest-mac.yml`.

## Acceptance on another Apple Silicon Mac

Expand All @@ -31,4 +31,15 @@ Download the DMG and its `.sha256` file through the GitHub UI. This download pat
6. Install `ripgrep` with `brew install ripgrep`, then confirm a task using `Grep` works.
7. Confirm the known limitation is accurate: Computer Use is not included.

## CLI/TUI acceptance on another Apple Silicon Mac

Download `Maka-<version>-cli-mac-arm64.tar.gz` and its `.sha256` file through the GitHub UI.

1. Run `shasum -a 256 -c Maka-<version>-cli-mac-arm64.tar.gz.sha256`.
2. Extract the tarball and add its `bin` directory to `PATH` without installing the desktop app.
3. Confirm `maka --version` matches the desktop version and `maka-agent --version` reports the same value.
4. Confirm `maka --help` lists `run`, `eval`, and `inspect`.
5. Run `maka`, complete or cancel the first-run setup, and confirm the TUI renders correctly.
6. Run one representative non-interactive command and confirm it completes without a repository checkout or system Node.js installation.

Publish the draft only after all checks pass. If acceptance fails, keep the draft unpublished, fix the issue, increment the desktop version, and run the workflow again; do not replace an existing release identity.
10 changes: 10 additions & 0 deletions .github/workflows/release-macos-arm64.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ jobs:
dmg="apps/desktop/release/Maka-${version}-mac-arm64.dmg"
zip="apps/desktop/release/Maka-${version}-mac-arm64.zip"
update_yml="apps/desktop/release/latest-mac.yml"
cli="apps/desktop/release/Maka-${version}-cli-mac-arm64.tar.gz"

if git ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then
echo "Tag ${tag} already exists." >&2
Expand All @@ -71,6 +72,7 @@ jobs:
echo "dmg=${dmg}"
echo "zip=${zip}"
echo "update_yml=${update_yml}"
echo "cli=${cli}"
} >> "$GITHUB_OUTPUT"

- name: Package signed and notarized DMG
Expand All @@ -88,6 +90,12 @@ jobs:
- name: Verify the final DMG
run: npm run verify:macos-arm64 -- "${{ steps.release.outputs.dmg }}"

- name: Package standalone CLI and TUI
run: npm run package:macos-arm64-cli

- name: Verify the standalone CLI and TUI
run: npm run verify:macos-arm64-cli -- "${{ steps.release.outputs.cli }}"

- name: Create draft GitHub Release
env:
GH_TOKEN: ${{ github.token }}
Expand All @@ -106,6 +114,8 @@ jobs:
"${DMG_PATH}.sha256" \
"${{ steps.release.outputs.zip }}" \
"${{ steps.release.outputs.update_yml }}" \
"${{ steps.release.outputs.cli }}" \
"${{ steps.release.outputs.cli }}.sha256" \
--draft \
--target "$GITHUB_SHA" \
--title "Maka ${RELEASE_VERSION}" \
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@
"check:third-party-notices": "node scripts/generate-third-party-notices.mjs --check",
"check:release": "npm run check:stale && npm run check:third-party-notices && node scripts/check-dead-css.mjs --check",
"package:macos-arm64": "node scripts/package-macos-arm64.mjs",
"package:macos-arm64-cli": "node scripts/package-macos-arm64-cli.mjs",
"verify:macos-arm64": "node scripts/verify-macos-arm64-dmg.mjs",
"verify:macos-arm64-cli": "node scripts/verify-macos-arm64-cli.mjs",
"measure:session-bundle": "node scripts/measure-session-bundle.mjs",
"astryx:theme": "node scripts/build-astryx-theme.mjs",
"check:visual-contract": "node scripts/check-visual-contract.mjs",
Expand Down
49 changes: 48 additions & 1 deletion scripts/macos-arm64-release.test.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';

const signingEnvironment = {
Expand All @@ -23,7 +24,6 @@ test('release tooling fails closed on unsupported hosts, signing, and architectu
packageMacosArm64({ platform: 'darwin', arch: 'arm64', env: {} }),
/CSC_LINK/,
);

await assert.rejects(
verifyPackagedMacApp('/tmp/Maka.app', {
run: async (command, args) => {
Expand All @@ -42,3 +42,50 @@ test('release tooling fails closed on unsupported hosts, signing, and architectu
/arm64/,
);
});
test('standalone CLI release has stable paths, aliases, and an Apple Silicon host gate', async () => {
const {
assertMacosArm64CliHost,
macosArm64CliInstallArgs,
macosArm64CliWrapper,
packageMacosArm64Cli,
resolveMacosArm64CliArtifactPaths,
} = await import(new URL('package-macos-arm64-cli.mjs', import.meta.url));
const { verifyMacosArm64Cli } = await import(
new URL('verify-macos-arm64-cli.mjs', import.meta.url)
);

assert.doesNotThrow(() => assertMacosArm64CliHost('darwin', 'arm64'));
assert.throws(() => assertMacosArm64CliHost('darwin', 'x64'), /Apple Silicon macOS host/);
await assert.rejects(
packageMacosArm64Cli({ platform: 'linux', arch: 'x64' }),
/Apple Silicon macOS host/,
);
await assert.rejects(
verifyMacosArm64Cli('/missing', { platform: 'linux', arch: 'x64' }),
/Apple Silicon macOS host/,
);

const paths = resolveMacosArm64CliArtifactPaths('1.2.3');
const installArgs = macosArm64CliInstallArgs('/staging');
assert.ok(paths.archivePath.endsWith('/Maka-1.2.3-cli-mac-arm64.tar.gz'));
assert.equal(paths.checksumPath, `${paths.archivePath}.sha256`);
assert.equal(installArgs[0], 'ci');
assert.deepEqual(installArgs.slice(1, 3), ['--prefix', '/staging']);
assert.ok(installArgs.includes('--workspace'));
assert.ok(installArgs.includes('maka-agent'));
assert.match(macosArm64CliWrapper(), /libexec\/node\/bin\/node/);
assert.match(macosArm64CliWrapper(), /maka-agent\/dist\/cli\.js/);
assert.match(macosArm64CliWrapper(), /"\$@"/);
});

test('release workflow verifies and uploads both terminal artifacts', async () => {
const workflow = await readFile(
new URL('../.github/workflows/release-macos-arm64.yml', import.meta.url),
'utf8',
);

assert.match(workflow, /npm run package:macos-arm64-cli/);
assert.match(workflow, /npm run verify:macos-arm64-cli/);
assert.match(workflow, /steps\.release\.outputs\.cli/);
assert.match(workflow, /steps\.release\.outputs\.cli \}\}\.sha256/);
});
243 changes: 243 additions & 0 deletions scripts/package-macos-arm64-cli.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
import { spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import { createReadStream } from 'node:fs';
import {
access,
chmod,
copyFile,
cp,
mkdir,
mkdtemp,
readFile,
readdir,
rename,
rm,
writeFile,
} from 'node:fs/promises';
import { basename, dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';

const repoRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const releaseDirectory = join(repoRoot, 'apps', 'desktop', 'release');
const localPackageDirectories = [
join(repoRoot, 'packages', 'cli'),
join(repoRoot, 'packages', 'core'),
join(repoRoot, 'packages', 'storage'),
join(repoRoot, 'packages', 'runtime'),
join(repoRoot, 'packages', 'headless'),
];

function runCommand(command, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: options.cwd ?? repoRoot,
env: options.env ?? process.env,
stdio: 'inherit',
});
child.once('error', reject);
child.once('exit', (code, signal) => {
if (code === 0) {
resolve();
return;
}
reject(
new Error(
`${command} ${args.join(' ')} failed with ${
signal ? `signal ${signal}` : `exit code ${code}`
}`,
),
);
});
});
}

export function assertMacosArm64CliHost(platform = process.platform, arch = process.arch) {
if (platform !== 'darwin' || arch !== 'arm64') {
throw new Error('CLI release packaging requires an Apple Silicon macOS host.');
}
}

export function resolveMacosArm64CliArtifactPaths(version) {
const archiveName = `Maka-${version}-cli-mac-arm64.tar.gz`;
return {
archiveRootName: `Maka-${version}-cli-mac-arm64`,
archivePath: join(releaseDirectory, archiveName),
checksumPath: join(releaseDirectory, `${archiveName}.sha256`),
};
}

export function macosArm64CliWrapper() {
return `#!/bin/sh
set -eu
bin_dir=$(CDPATH= cd "$(dirname "$0")" && pwd)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Resolve the launcher itself before deriving the archive root. This uses dirname "$0", so invoking a normal external link such as /usr/local/bin/maka -> <archive>/bin/maka makes the wrapper look for /usr/local/libexec/node/bin/node and fail before --version can run. #1510's distribution contract explicitly requires both commands to work through symlinks outside the extracted tree for future Homebrew installation. Resolve the launcher's real path first and add verifier coverage that runs both aliases through external symlinks.

exec "$bin_dir/../libexec/node/bin/node" "$bin_dir/../libexec/node_modules/maka-agent/dist/cli.js" "$@"
`;
}

export function macosArm64CliInstallArgs(installRoot) {
return [
'ci',
'--prefix',
installRoot,
'--omit=dev',
'--workspace',
'maka-agent',
'--include-workspace-root=false',
'--ignore-scripts',
'--no-audit',
'--no-fund',
];
}

async function sha256File(path) {
const hash = createHash('sha256');
for await (const chunk of createReadStream(path)) hash.update(chunk);
return hash.digest('hex');
}

async function stageWorkspacePackages(installRoot) {
await Promise.all([
copyFile(join(repoRoot, 'package.json'), join(installRoot, 'package.json')),
copyFile(join(repoRoot, 'package-lock.json'), join(installRoot, 'package-lock.json')),
...localPackageDirectories.map(async (sourceDirectory) => {
const targetDirectory = join(installRoot, 'packages', basename(sourceDirectory));
await mkdir(targetDirectory, { recursive: true });
await Promise.all([
copyFile(join(sourceDirectory, 'package.json'), join(targetDirectory, 'package.json')),
cp(join(sourceDirectory, 'dist'), join(targetDirectory, 'dist'), { recursive: true }),
]);
}),
]);
}

async function retainOnlyDirectory(parent, retainedName) {
for (const entry of await readdir(parent)) {
if (entry !== retainedName) {
await rm(join(parent, entry), { recursive: true, force: true });
}
}
}

async function pruneNonTargetNativeBinaries(nodeModulesDirectory) {
await retainOnlyDirectory(join(nodeModulesDirectory, 'node-pty', 'prebuilds'), 'darwin-arm64');
await retainOnlyDirectory(
join(nodeModulesDirectory, 'fs-native-extensions', 'prebuilds'),
'darwin-arm64',
);
await retainOnlyDirectory(
join(nodeModulesDirectory, '@earendil-works', 'pi-tui', 'native', 'darwin', 'prebuilds'),
'darwin-arm64',
);
await rm(join(nodeModulesDirectory, '@earendil-works', 'pi-tui', 'native', 'win32'), {
recursive: true,
force: true,
});
}

async function rewriteCliVersion(installRoot, version) {
const manifestPath = join(installRoot, 'packages', 'cli', 'package.json');
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
manifest.version = version;
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
}

export async function packageMacosArm64Cli({
platform = process.platform,
arch = process.arch,
execPath = process.execPath,
env = process.env,
run = runCommand,
} = {}) {
assertMacosArm64CliHost(platform, arch);

const desktopManifest = JSON.parse(
await readFile(join(repoRoot, 'apps', 'desktop', 'package.json'), 'utf8'),
);
if (typeof desktopManifest.version !== 'string' || !desktopManifest.version.trim()) {
throw new Error('Desktop release version is missing.');
}

const version = desktopManifest.version;
const nodeRoot = dirname(dirname(execPath));
const nodeLicensePath = join(nodeRoot, 'LICENSE');
await Promise.all([
access(execPath),
access(nodeLicensePath),
access(join(repoRoot, 'LICENSE')),
access(join(repoRoot, 'NOTICE')),
...localPackageDirectories.map((path) => access(join(path, 'dist'))),
]);

const { archiveRootName, archivePath, checksumPath } = resolveMacosArm64CliArtifactPaths(version);
await mkdir(releaseDirectory, { recursive: true });
const stagingRoot = await mkdtemp(join(releaseDirectory, '.maka-cli-'));
let complete = false;

try {
const installRoot = join(stagingRoot, 'install');
await mkdir(installRoot, { recursive: true });
await stageWorkspacePackages(installRoot);
await run('npm', macosArm64CliInstallArgs(installRoot), { env });

const nodeModulesDirectory = join(installRoot, 'node_modules');
await rewriteCliVersion(installRoot, version);
await pruneNonTargetNativeBinaries(nodeModulesDirectory);

const archiveRoot = join(stagingRoot, archiveRootName);
const binDirectory = join(archiveRoot, 'bin');
const embeddedNodeDirectory = join(archiveRoot, 'libexec', 'node');
await Promise.all([
mkdir(binDirectory, { recursive: true }),
mkdir(join(embeddedNodeDirectory, 'bin'), { recursive: true }),
]);
await rename(nodeModulesDirectory, join(archiveRoot, 'libexec', 'node_modules'));
await rename(join(installRoot, 'packages'), join(archiveRoot, 'libexec', 'packages'));
await Promise.all([
copyFile(execPath, join(embeddedNodeDirectory, 'bin', 'node')),
copyFile(nodeLicensePath, join(embeddedNodeDirectory, 'LICENSE')),
copyFile(join(repoRoot, 'LICENSE'), join(archiveRoot, 'LICENSE')),
copyFile(join(repoRoot, 'NOTICE'), join(archiveRoot, 'NOTICE')),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Include notices for the packaged CLI dependency closure. The agreed #1510 artifact contract requires THIRD_PARTY_NOTICES.txt generated from the exact maka-agent production dependency closure, but this staging path copies only Maka's LICENSE/NOTICE and the embedded Node license; the verifier likewise never requires a CLI notice. The existing Desktop notice cannot establish the CLI closure. Please generate the closure-scoped notice, include it in the ZIP, and verify it against the packaged dependency metadata before publication.

writeFile(join(binDirectory, 'maka'), macosArm64CliWrapper(), 'utf8'),
writeFile(join(binDirectory, 'maka-agent'), macosArm64CliWrapper(), 'utf8'),
writeFile(
join(archiveRoot, 'README.txt'),
[
`Maka CLI/TUI ${version} for Apple Silicon macOS`,
'',
"Add this directory's bin folder to PATH, then run:",
' maka --help',
'',
'The archive includes its own Node.js runtime and does not require the Maka desktop app.',
'',
].join('\n'),
'utf8',
),
]);
await Promise.all([
chmod(join(embeddedNodeDirectory, 'bin', 'node'), 0o755),
chmod(join(binDirectory, 'maka'), 0o755),
chmod(join(binDirectory, 'maka-agent'), 0o755),
]);

await Promise.all([rm(archivePath, { force: true }), rm(checksumPath, { force: true })]);
await run('tar', ['-czf', archivePath, '-C', stagingRoot, archiveRootName], {
env: { ...env, COPYFILE_DISABLE: '1' },
});
const sha256 = await sha256File(archivePath);
await writeFile(checksumPath, `${sha256} ${basename(archivePath)}\n`, 'utf8');
complete = true;
return { archivePath, checksumPath, sha256, version };
} finally {
await rm(stagingRoot, { recursive: true, force: true });
if (!complete) {
const { archivePath, checksumPath } = resolveMacosArm64CliArtifactPaths(version);
await Promise.all([rm(archivePath, { force: true }), rm(checksumPath, { force: true })]);
}
}
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const result = await packageMacosArm64Cli();
console.log(`Created ${result.archivePath}`);
console.log(`SHA-256 ${result.sha256}`);
}
Loading
Loading