Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
16 changes: 12 additions & 4 deletions .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,12 +186,20 @@ git pull origin main
git push origin dev
```

> **Important**: This sync ensures dev has the merge commit from main. Without it,
> dev and main diverge. The CI's `update-homebrew` job pushes a formula commit
> to dev but does NOT sync the merge commit — this manual step is required.

The GitHub Release is distinct from the git tag — without it, the release won't appear on the repository's Releases page. Always create it.

If the user merges the PR themselves and comes back, still offer to tag, release, and sync.

### Step 10: Wait for Release Workflow and Update Homebrew Formula

> **Note**: The `update-homebrew` CI job in `.github/workflows/release.yml` runs automatically
> after the release job and handles Steps 10-11 (formula update + push to dev). These manual
> steps are only needed if the CI job fails. Check the Actions tab before running manually.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

After the tag is pushed, `.github/workflows/release.yml` builds platform binaries and uploads them to the GitHub release. This takes 5-10 minutes. The Homebrew formula SHA256 values cannot be known until these binaries exist.

**Wait for all assets to appear on the release:**
Expand All @@ -200,16 +208,16 @@ After the tag is pushed, `.github/workflows/release.yml` builds platform binarie
echo "Waiting for release workflow to finish uploading binaries..."
for i in {1..30}; do
ASSET_COUNT=$(gh release view "vx.y.z" --repo coleam00/Archon --json assets --jq '.assets | length')
# Expect 6 assets: 5 binaries (darwin-arm64, darwin-x64, linux-arm64, linux-x64, windows-x64.exe) + checksums.txt
if [ "$ASSET_COUNT" -ge 6 ]; then
# Expect 7 assets: 5 binaries (darwin-arm64, darwin-x64, linux-arm64, linux-x64, windows-x64.exe) + archon-web.tar.gz + checksums.txt
if [ "$ASSET_COUNT" -ge 7 ]; then
echo "All $ASSET_COUNT assets uploaded"
break
fi
echo " Assets so far: $ASSET_COUNT/6 — waiting 30s (attempt $i/30)..."
echo " Assets so far: $ASSET_COUNT/7 — waiting 30s (attempt $i/30)..."
sleep 30
done

if [ "$ASSET_COUNT" -lt 6 ]; then
if [ "$ASSET_COUNT" -lt 7 ]; then
echo "ERROR: Release workflow did not finish uploading assets after 15 minutes"
echo "Check https://github.com/coleam00/Archon/actions for the release workflow run"
exit 1
Expand Down
23 changes: 22 additions & 1 deletion packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,13 @@ import { setupCommand } from './commands/setup';
import { validateWorkflowsCommand, validateCommandsCommand } from './commands/validate';
import { serveCommand } from './commands/serve';
import { closeDatabase } from '@archon/core';
import { setLogLevel, createLogger } from '@archon/paths';
import {
setLogLevel,
createLogger,
checkForUpdate,
BUNDLED_IS_BINARY,
BUNDLED_VERSION,
} from '@archon/paths';
import * as git from '@archon/git';

/** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */
Expand Down Expand Up @@ -159,6 +165,20 @@ async function closeDb(): Promise<void> {
}
}

async function printUpdateNotice(quiet: boolean | undefined): Promise<void> {
try {
if (quiet || !BUNDLED_IS_BINARY) return;
const result = await checkForUpdate(BUNDLED_VERSION);
if (result?.updateAvailable) {
process.stderr.write(
`Update available: v${result.currentVersion} → v${result.latestVersion} — ${result.releaseUrl}\n`
);
}
} catch {
// Never fail the CLI command because of update check
}
}

/**
* Main CLI entry point
* Returns exit code (0 = success, non-zero = failure)
Expand Down Expand Up @@ -556,6 +576,7 @@ async function main(): Promise<number> {
printUsage();
return 1;
}
await printUpdateNotice(values.quiet as boolean | undefined);
return 0;
} catch (error) {
const err = error as Error;
Expand Down
9 changes: 9 additions & 0 deletions packages/paths/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,12 @@ export type { Logger } from './logger';

// Build-time constants (rewritten by scripts/build-binaries.sh)
export { BUNDLED_IS_BINARY, BUNDLED_VERSION, BUNDLED_GIT_COMMIT } from './bundled-build';

// Update check
export {
checkForUpdate,
getCachedUpdateCheck,
isNewerVersion,
parseLatestRelease,
} from './update-check';
export type { UpdateCheckResult } from './update-check';
238 changes: 238 additions & 0 deletions packages/paths/src/update-check.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import { describe, test, expect, spyOn, beforeEach, afterEach, mock } from 'bun:test';
import { join } from 'path';
import { mkdirSync, writeFileSync, existsSync, rmSync, unlinkSync } from 'fs';
import { tmpdir } from 'os';
import {
isNewerVersion,
parseLatestRelease,
checkForUpdate,
getCachedUpdateCheck,
} from './update-check';

// ─── isNewerVersion ──────────────────────────────────────────────────

describe('isNewerVersion', () => {
test('returns true when latest minor is higher', () => {
expect(isNewerVersion('0.3.2', '0.4.0')).toBe(true);
});

test('returns true when latest patch is higher', () => {
expect(isNewerVersion('0.3.2', '0.3.3')).toBe(true);
});

test('returns false when current is higher', () => {
expect(isNewerVersion('0.4.0', '0.3.9')).toBe(false);
});

test('returns false when versions are equal', () => {
expect(isNewerVersion('0.3.2', '0.3.2')).toBe(false);
});

test('handles major version differences', () => {
expect(isNewerVersion('0.99.99', '1.0.0')).toBe(true);
});

test('handles double-digit segments correctly (not string comparison)', () => {
expect(isNewerVersion('0.9.0', '0.10.0')).toBe(true);
});
});

// ─── parseLatestRelease ──────────────────────────────────────────────

describe('parseLatestRelease', () => {
test('parses valid response with v prefix', () => {
const result = parseLatestRelease({
tag_name: 'v0.4.0',
html_url: 'https://github.com/coleam00/Archon/releases/tag/v0.4.0',
});
expect(result).toEqual({
version: '0.4.0',
url: 'https://github.com/coleam00/Archon/releases/tag/v0.4.0',
});
});

test('parses tag_name without v prefix', () => {
const result = parseLatestRelease({
tag_name: '0.4.0',
html_url: 'https://example.com',
});
expect(result.version).toBe('0.4.0');
});

test('throws on missing tag_name', () => {
expect(() => parseLatestRelease({})).toThrow('Missing tag_name');
});

test('returns empty url when html_url is missing', () => {
const result = parseLatestRelease({ tag_name: 'v1.0.0' });
expect(result.url).toBe('');
});
});

// ─── checkForUpdate (with mocked fetch) ──────────────────────────────

describe('checkForUpdate', () => {
const testDir = join(tmpdir(), `archon-update-check-test-${Date.now()}`);
let originalArchonHome: string | undefined;

beforeEach(() => {
originalArchonHome = process.env.ARCHON_HOME;
process.env.ARCHON_HOME = testDir;
mkdirSync(testDir, { recursive: true });
});

afterEach(() => {
if (originalArchonHome !== undefined) {
process.env.ARCHON_HOME = originalArchonHome;
} else {
delete process.env.ARCHON_HOME;
}
try {
rmSync(testDir, { recursive: true, force: true });
} catch {
// ignore cleanup errors
}
});

test('returns result from fresh cache without fetching', async () => {
const cache = {
latestVersion: '0.5.0',
releaseUrl: 'https://github.com/coleam00/Archon/releases/tag/v0.5.0',
checkedAt: Date.now(),
};
writeFileSync(join(testDir, 'update-check.json'), JSON.stringify(cache));

const fetchSpy = spyOn(globalThis, 'fetch');
const result = await checkForUpdate('0.4.0');

expect(result).toEqual({
updateAvailable: true,
currentVersion: '0.4.0',
latestVersion: '0.5.0',
releaseUrl: 'https://github.com/coleam00/Archon/releases/tag/v0.5.0',
});
expect(fetchSpy).not.toHaveBeenCalled();
fetchSpy.mockRestore();
});

test('fetches from GitHub when no cache exists', async () => {
const fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({
tag_name: 'v0.5.0',
html_url: 'https://github.com/coleam00/Archon/releases/tag/v0.5.0',
}),
{ status: 200 }
)
);

const result = await checkForUpdate('0.4.0');

expect(result).toEqual({
updateAvailable: true,
currentVersion: '0.4.0',
latestVersion: '0.5.0',
releaseUrl: 'https://github.com/coleam00/Archon/releases/tag/v0.5.0',
});
expect(fetchSpy).toHaveBeenCalledTimes(1);

// Verify cache was written
expect(existsSync(join(testDir, 'update-check.json'))).toBe(true);
fetchSpy.mockRestore();
});

test('returns null on network error', async () => {
const fetchSpy = spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error'));

const result = await checkForUpdate('0.4.0');

expect(result).toBeNull();
fetchSpy.mockRestore();
});

test('returns updateAvailable: false when current matches latest', async () => {
const cache = {
latestVersion: '0.4.0',
releaseUrl: 'https://github.com/coleam00/Archon/releases/tag/v0.4.0',
checkedAt: Date.now(),
};
writeFileSync(join(testDir, 'update-check.json'), JSON.stringify(cache));

const result = await checkForUpdate('0.4.0');

expect(result?.updateAvailable).toBe(false);
});

test('fetches when cache is stale', async () => {
const staleCache = {
latestVersion: '0.4.0',
releaseUrl: 'https://example.com',
checkedAt: Date.now() - 25 * 60 * 60 * 1000, // 25 hours ago
};
writeFileSync(join(testDir, 'update-check.json'), JSON.stringify(staleCache));

const fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({
tag_name: 'v0.5.0',
html_url: 'https://github.com/coleam00/Archon/releases/tag/v0.5.0',
}),
{ status: 200 }
)
);

const result = await checkForUpdate('0.4.0');

expect(result?.latestVersion).toBe('0.5.0');
expect(fetchSpy).toHaveBeenCalledTimes(1);
fetchSpy.mockRestore();
});
});

// ─── getCachedUpdateCheck ────────────────────────────────────────────

describe('getCachedUpdateCheck', () => {
const testDir = join(tmpdir(), `archon-cached-check-test-${Date.now()}`);
let originalArchonHome: string | undefined;

beforeEach(() => {
originalArchonHome = process.env.ARCHON_HOME;
process.env.ARCHON_HOME = testDir;
mkdirSync(testDir, { recursive: true });
});

afterEach(() => {
if (originalArchonHome !== undefined) {
process.env.ARCHON_HOME = originalArchonHome;
} else {
delete process.env.ARCHON_HOME;
}
try {
rmSync(testDir, { recursive: true, force: true });
} catch {
// ignore cleanup errors
}
});

test('returns null when no cache file', () => {
expect(getCachedUpdateCheck('0.4.0')).toBeNull();
});

test('returns result from cache file', () => {
const cache = {
latestVersion: '0.5.0',
releaseUrl: 'https://example.com',
checkedAt: Date.now(),
};
writeFileSync(join(testDir, 'update-check.json'), JSON.stringify(cache));

const result = getCachedUpdateCheck('0.4.0');
expect(result?.updateAvailable).toBe(true);
expect(result?.latestVersion).toBe('0.5.0');
});

test('returns null for corrupt cache file', () => {
writeFileSync(join(testDir, 'update-check.json'), 'not json');
expect(getCachedUpdateCheck('0.4.0')).toBeNull();
});
});
Loading
Loading