Skip to content

fix(update-system): use releases API as source of truth for remote version#345

Open
benjamindus wants to merge 1 commit intosantifer:mainfrom
benjamindus:fix/update-checker-use-releases-api
Open

fix(update-system): use releases API as source of truth for remote version#345
benjamindus wants to merge 1 commit intosantifer:mainfrom
benjamindus:fix/update-checker-use-releases-api

Conversation

@benjamindus
Copy link
Copy Markdown

@benjamindus benjamindus commented Apr 17, 2026

Summary

  • update-system.mjs check currently pulls the remote version from raw.githubusercontent.com/santifer/career-ops/main/VERSION and treats that as authoritative. But release-please (with release-type: simple and the .release-please-manifest.json setup in .github/workflows/release.yml) doesn't touch the root VERSION file by default — so when a release tag ships, VERSION on main can stay behind.
  • Observed today: releases v1.4.0 (2026-04-13) and v1.5.0 (2026-04-14) are tagged and published, but VERSION on main still reads 1.3.0. Users on 1.3.0 run check and get "status": "up-to-date" — silently missing --min-score, the dashboard progress analytics, the roleMatch stopword fix, etc.
  • Flip the lookup order: the GitHub releases API is now primary (tag_name is the authoritative published version, with ^v stripped), and the raw VERSION file is the fallback for offline or private-fork scenarios. Also folds the changelog fetch into the same call — one HTTP request when an update is available instead of two.
  • apply is unchanged: it already pulls via git fetch … main, which is always the right thing regardless of file-state drift.

Alternative fix would be to list VERSION under extra-files in .github/release-please-config.json so release-please keeps it in sync. That's a valid patch too, but it leaves the checker brittle to future config mistakes. Reading from the releases API is self-correcting.

Test plan

  • node update-system.mjs check against current main now returns {"status":"update-available","local":"1.3.0","remote":"1.5.0", ...} — matches ground truth
  • Releases-API-down path still falls through to VERSION file (unchanged behavior)
  • Both paths still return {"status":"offline", …} when everything is unreachable
  • (reviewer) run check from a private fork without published releases to confirm the fallback kicks in as expected

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Refactor
    • Optimized the update checking mechanism to more efficiently retrieve version information and release notes. Enhanced fallback handling ensures the system can access updates reliably even if primary sources are temporarily unavailable, with improved error management for offline scenarios.

…rsion

The raw VERSION file on main can lag behind release tags when
release-please isn't configured to bump it (it manages
.release-please-manifest.json by default). Example: v1.4.0 and
v1.5.0 were tagged on 2026-04-13/14 but VERSION on main is still
`1.3.0`, so `update-system.mjs check` returns "up-to-date" for
users two versions behind.

Flip the order: use the releases API first (tag_name is the
authoritative published version), fall back to the raw VERSION
file only if releases are unavailable (offline, private fork,
etc.). Also folds the changelog fetch into the same call — one
HTTP request instead of two when an update is available.

No change to `apply`: it already pulls via git fetch from main,
which is what should happen regardless of version-file state.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 17, 2026

📝 Walkthrough

Walkthrough

The check() function in update-system.mjs is refactored to fetch version and changelog information from the GitHub Releases API first, with a fallback to a raw VERSION file. Changelog initialization is moved earlier and consolidated with remote version retrieval logic.

Changes

Cohort / File(s) Summary
Update source control flow
update-system.mjs
Refactored check() function to initialize changelog early, fetch remote version and changelog from GitHub Releases API (parsing from release.tag_name and release.body), with fallback to raw VERSION file if the primary path fails or tag_name is missing. Consolidated changelog retrieval into the same logic that resolves remote, removing the separate post-comparison fetch block. Offline logging now only triggers if both fetch attempts fail.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Possibly related issues

  • #316: Directly addresses this issue by implementing the GitHub Releases API as the primary source for deriving remote version and changelog, with VERSION file fallback, exactly as specified in the issue requirements.
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: using the GitHub Releases API as the authoritative source for the remote version, which directly addresses the primary objective of the PR.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Welcome to career-ops, @benjamindus! Thanks for your first PR.

A few things to know:

  • Tests will run automatically — check the status below
  • Make sure you've linked a related issue (required for features)
  • Read CONTRIBUTING.md if you haven't

We'll review your PR soon. Join our Discord if you have questions.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@update-system.mjs`:
- Around line 146-164: The two fetch calls (fetch(RELEASES_API, ...) and
fetch(RAW_VERSION_URL)) can hang indefinitely; wrap each fetch with an
AbortController timeout: create an AbortController, start a setTimeout to call
controller.abort() after a short timeout (e.g., 3–5s), pass controller.signal
into fetch options, and clear the timeout after the fetch completes; ensure the
existing try/catch still treats an aborted request the same so the code falls
through to the offline path in the check() flow.
- Line 151: The current tag parsing line uses remote = (release.tag_name ||
'').replace(/^v/, '').trim(); which allows prerelease/metadata like "1.5.0-rc.1"
to reach compareVersions and produce NaN; change the parsing to validate or
normalize to a strict semver core before comparing: either use a semver parser
(e.g., semver.parse or similar) to extract only major.minor.patch from
release.tag_name (or fail loudly if not a clean semver) or strip any
prerelease/metadata with a regex like capturing /^v?(\d+\.\d+\.\d+)/ and assign
that capture to remote; then ensure compareVersions receives only numeric
dot-separated segments (or fall back to a visible error/warning) so remote and
local comparisons are deterministic. Include references to release.tag_name,
remote, and compareVersions when updating the code.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9df734ef-40a3-430a-87eb-f1e4b0507e7d

📥 Commits

Reviewing files that changed from the base of the PR and between 2051beb and 58b6df6.

📒 Files selected for processing (1)
  • update-system.mjs

Comment thread update-system.mjs
Comment on lines +146 to 164
const res = await fetch(RELEASES_API, {
headers: { 'Accept': 'application/vnd.github.v3+json' }
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
remote = (await res.text()).trim();
const release = await res.json();
remote = (release.tag_name || '').replace(/^v/, '').trim();
changelog = release.body || '';
if (!remote) throw new Error('no tag_name in latest release');
} catch {
console.log(JSON.stringify({ status: 'offline', local }));
return;
// Fallback: raw VERSION file (e.g., private fork without releases)
try {
const res = await fetch(RAW_VERSION_URL);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
remote = (await res.text()).trim();
} catch {
console.log(JSON.stringify({ status: 'offline', local }));
return;
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Add a network timeout to both fetch() calls to preserve the "offline" path.

Neither fetch(RELEASES_API, …) nor fetch(RAW_VERSION_URL) passes a signal/timeout, so a stalled TCP connection (captive portal, DNS blackhole, GitHub outage routing weirdness) can hang the check command indefinitely instead of falling through to status: "offline". Since check() is invoked silently on every session start per the repo's CLAUDE.md workflow, a hang here blocks the first response.

🔧 Suggested fix
   try {
-    const res = await fetch(RELEASES_API, {
-      headers: { 'Accept': 'application/vnd.github.v3+json' }
-    });
+    const res = await fetch(RELEASES_API, {
+      headers: { 'Accept': 'application/vnd.github.v3+json' },
+      signal: AbortSignal.timeout(5000),
+    });
     if (!res.ok) throw new Error(`HTTP ${res.status}`);
     const release = await res.json();
     remote = (release.tag_name || '').replace(/^v/, '').trim();
     changelog = release.body || '';
     if (!remote) throw new Error('no tag_name in latest release');
   } catch {
     // Fallback: raw VERSION file (e.g., private fork without releases)
     try {
-      const res = await fetch(RAW_VERSION_URL);
+      const res = await fetch(RAW_VERSION_URL, { signal: AbortSignal.timeout(5000) });
       if (!res.ok) throw new Error(`HTTP ${res.status}`);
       remote = (await res.text()).trim();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const res = await fetch(RELEASES_API, {
headers: { 'Accept': 'application/vnd.github.v3+json' }
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
remote = (await res.text()).trim();
const release = await res.json();
remote = (release.tag_name || '').replace(/^v/, '').trim();
changelog = release.body || '';
if (!remote) throw new Error('no tag_name in latest release');
} catch {
console.log(JSON.stringify({ status: 'offline', local }));
return;
// Fallback: raw VERSION file (e.g., private fork without releases)
try {
const res = await fetch(RAW_VERSION_URL);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
remote = (await res.text()).trim();
} catch {
console.log(JSON.stringify({ status: 'offline', local }));
return;
}
}
const res = await fetch(RELEASES_API, {
headers: { 'Accept': 'application/vnd.github.v3+json' },
signal: AbortSignal.timeout(5000),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const release = await res.json();
remote = (release.tag_name || '').replace(/^v/, '').trim();
changelog = release.body || '';
if (!remote) throw new Error('no tag_name in latest release');
} catch {
// Fallback: raw VERSION file (e.g., private fork without releases)
try {
const res = await fetch(RAW_VERSION_URL, { signal: AbortSignal.timeout(5000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
remote = (await res.text()).trim();
} catch {
console.log(JSON.stringify({ status: 'offline', local }));
return;
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@update-system.mjs` around lines 146 - 164, The two fetch calls
(fetch(RELEASES_API, ...) and fetch(RAW_VERSION_URL)) can hang indefinitely;
wrap each fetch with an AbortController timeout: create an AbortController,
start a setTimeout to call controller.abort() after a short timeout (e.g.,
3–5s), pass controller.signal into fetch options, and clear the timeout after
the fetch completes; ensure the existing try/catch still treats an aborted
request the same so the code falls through to the offline path in the check()
flow.

Comment thread update-system.mjs
if (!res.ok) throw new Error(`HTTP ${res.status}`);
remote = (await res.text()).trim();
const release = await res.json();
remote = (release.tag_name || '').replace(/^v/, '').trim();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Non-semver tag suffixes silently report "up-to-date".

Only the leading v is stripped, so a tag like v1.5.0-rc.1 (or any future prerelease/metadata tag) flows into compareVersions, where Number('5-rc')NaN, then (NaN || 0) collapses the segment to 0. The result is local >= remote, so the user is told they're up-to-date when a newer release exists. /releases/latest filters out prereleases today, so risk is low, but a strict validation (or localeCompare with { numeric: true } on sanitized segments) would fail loudly instead of silently masking updates.

Minimal guard:

-    remote = (release.tag_name || '').replace(/^v/, '').trim();
+    remote = (release.tag_name || '').replace(/^v/, '').trim();
+    if (!/^\d+\.\d+\.\d+$/.test(remote)) throw new Error(`unexpected tag_name: ${release.tag_name}`);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
remote = (release.tag_name || '').replace(/^v/, '').trim();
remote = (release.tag_name || '').replace(/^v/, '').trim();
if (!/^\d+\.\d+\.\d+$/.test(remote)) throw new Error(`unexpected tag_name: ${release.tag_name}`);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@update-system.mjs` at line 151, The current tag parsing line uses remote =
(release.tag_name || '').replace(/^v/, '').trim(); which allows
prerelease/metadata like "1.5.0-rc.1" to reach compareVersions and produce NaN;
change the parsing to validate or normalize to a strict semver core before
comparing: either use a semver parser (e.g., semver.parse or similar) to extract
only major.minor.patch from release.tag_name (or fail loudly if not a clean
semver) or strip any prerelease/metadata with a regex like capturing
/^v?(\d+\.\d+\.\d+)/ and assign that capture to remote; then ensure
compareVersions receives only numeric dot-separated segments (or fall back to a
visible error/warning) so remote and local comparisons are deterministic.
Include references to release.tag_name, remote, and compareVersions when
updating the code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant