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
35 changes: 35 additions & 0 deletions test-all.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1792,6 +1792,41 @@ try {
fail(`Batch runner MCP isolation test crashed: ${e.message}`);
}

// ── 16. UPDATE-SYSTEM SEMVER PARSING (#923) ─────────────────────

console.log('\n16. update-system SEMVER_RE');

try {
// Importing must not trigger the CLI (the import.meta.url guard); it
// exposes SEMVER_RE, which the releases-API fallback uses on release.tag_name.
const { SEMVER_RE } = await import(pathToFileURL(join(ROOT, 'update-system.mjs')).href);
const parse = (tag) => String(tag).trim().match(SEMVER_RE)?.[1] ?? null;

// Release Please tags carry the component prefix (career-ops-v1.9.0); the
// prefix must be stripped or the releases-API fallback is dead code (#923).
if (parse('career-ops-v1.9.0') === '1.9.0') {
pass('SEMVER_RE parses Release Please component-prefixed tag (career-ops-v1.9.0 → 1.9.0)');
} else {
fail(`SEMVER_RE failed on career-ops-v1.9.0 (got ${parse('career-ops-v1.9.0')}) — releases-API fallback is dead code (#923)`);
}

// No regression on plain tags.
if (parse('v1.9.0') === '1.9.0' && parse('1.9.0') === '1.9.0') {
pass('SEMVER_RE still parses plain v-prefixed and bare semver tags');
} else {
fail(`SEMVER_RE regressed on plain tags (v1.9.0 → ${parse('v1.9.0')}, 1.9.0 → ${parse('1.9.0')})`);
}

// Non-semver input must not match.
if (parse('career-ops') === null && parse('v1.9') === null) {
pass('SEMVER_RE rejects non-semver input');
} else {
fail(`SEMVER_RE matched non-semver input (career-ops → ${parse('career-ops')}, v1.9 → ${parse('v1.9')})`);
}
} catch (e) {
fail(`update-system SEMVER_RE test crashed: ${e.message}`);
}

// ── SUMMARY ─────────────────────────────────────────────────────

console.log('\n' + '='.repeat(50));
Expand Down
47 changes: 28 additions & 19 deletions update-system.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import { execFile, execFileSync, execSync } from 'child_process';
import { readFileSync, writeFileSync, existsSync, unlinkSync, rmSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { fileURLToPath, pathToFileURL } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = __dirname;
Expand All @@ -27,6 +27,12 @@ const CANONICAL_REPO = 'https://github.com/santifer/career-ops.git';
const RAW_VERSION_URL = 'https://raw.githubusercontent.com/santifer/career-ops/main/VERSION';
const RELEASES_API = 'https://api.github.com/repos/santifer/career-ops/releases/latest';

// Matches a semver, with or without a leading `v` and an optional
// Release Please component prefix (e.g. `career-ops-v1.9.0` → `1.9.0`).
// Anchoring on `(?:^|-)` lets the releases-API fallback parse our tags,
// which Release Please always prefixes with the component name.
export const SEMVER_RE = /(?:^|-)v?(\d+\.\d+\.\d+)$/i;

// System layer paths — ONLY these files get updated
const SYSTEM_PATHS = [
'modes/_shared.md',
Expand Down Expand Up @@ -265,8 +271,6 @@ async function check() {
// Use curl instead of fetch() so the check works inside the Claude Code
// sandbox (see curlGet() above for rationale). Two sources are tried;
// both failing is the only true-offline signal.
const SEMVER_RE = /^v?(\d+\.\d+\.\d+)$/i;

const [rawVersion, releaseRaw] = await Promise.all([
curlGet(RAW_VERSION_URL),
curlGet(RELEASES_API, [
Expand Down Expand Up @@ -577,22 +581,27 @@ function dismiss() {

// ── MAIN ────────────────────────────────────────────────────────

const cmd = process.argv[2] || 'check';
// Only run the CLI when executed directly, so importing this module
// (e.g. from test-all.mjs to exercise SEMVER_RE) does not trigger a
// live update check.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const cmd = process.argv[2] || 'check';

try {
switch (cmd) {
case 'check': await check(); break;
case 'apply': await apply(); break;
case 'rollback': rollback(); break;
case 'dismiss': dismiss(); break;
default:
console.log('Usage: node update-system.mjs [check|apply|rollback|dismiss]');
process.exit(1);
try {
switch (cmd) {
case 'check': await check(); break;
case 'apply': await apply(); break;
case 'rollback': rollback(); break;
case 'dismiss': dismiss(); break;
default:
console.log('Usage: node update-system.mjs [check|apply|rollback|dismiss]');
process.exit(1);
}
} catch (err) {
// Subcommands now `throw` on aborts so their outer `finally` blocks
// run (e.g. apply() must release `.update-lock`). Print a clean
// message here instead of letting Node spit out a stack trace.
console.error(err.message || err);
process.exit(1);
}
} catch (err) {
// Subcommands now `throw` on aborts so their outer `finally` blocks
// run (e.g. apply() must release `.update-lock`). Print a clean
// message here instead of letting Node spit out a stack trace.
console.error(err.message || err);
process.exit(1);
}
Loading