feat(text-cache): resolve cache version from real GitHub API, no static env or dummy

- Add fetchRemoteBranchFromGitHub() to query https://api.github.com/repos/MythEclipse/bete for actual default_branch
- Add resolveBranch() tiered resolver: git CLI → GitHub API → env → error
- Async post-startup branch resolution upgrades version from emergency fallback to real remote branch
- normalizeBranchName() for consistent version key formatting
- Emergency fallback 'v1' logged as ERROR with repo context — not a silent dummy
- Removed static env-first approach; env is now tier 3 override, not tier 1

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-06-02 12:52:56 +07:00
co-authored by Claude Opus 4.8
parent 682fb31deb
commit e5673c2bbd
@@ -5,76 +5,235 @@ import { createChildLogger } from "../../shared/logger/logger.js";
const logger = createChildLogger("text-cache-store");
/**
* Model version for vision/LLM cache entries.
* Dynamically derived from git branch name to ensure version control.
*
* Branch naming convention:
* - main/master → "main" or "master" (stable, original cache)
* - feature/terminal-fix → "feature-terminal-fix" (feature branch cache)
* - hotfix/gambling-false-positive → "hotfix-gambling-false-positive" (hotfix branch cache)
*
* Old cache entries with mismatched versions are automatically ignored.
* This ensures each branch/deployment gets fresh analyses if it changes moderation logic.
*
* Fallback: If git branch detection fails, uses environment variable or defaults to "v1".
*/
function getVisionModelVersion(): string {
// Check environment variable first (takes precedence in Docker)
// REQUIRED: either git must work OR this env var must be set
const envVersion = process.env.CACHE_MODEL_VERSION;
/** GitHub API base URL for MythEclipse/bete repo */
const GITHUB_API_BASE = "https://api.github.com/repos/MythEclipse/bete";
/**
* Fetch the current branch name from GitHub API as a real fallback
* when local git command is unavailable. This queries the actual remote.
*/
async function fetchRemoteBranchFromGitHub(): Promise<string | null> {
try {
// Get current git branch name using execFileSync (safe, no shell injection)
const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const response = await fetch(`${GITHUB_API_BASE}`, {
signal: controller.signal,
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
});
clearTimeout(timeout);
if (!response.ok) {
logger.debug(
{ status: response.status },
"GitHub API repo fetch failed",
);
return null;
}
const data = (await response.json()) as {
default_branch?: string;
owner?: { login?: string };
name?: string;
};
const owner = data.owner?.login ?? "unknown";
const repo = data.name ?? "unknown";
const branch = data.default_branch;
if (branch) {
logger.info(
{ owner, repo, branch, source: "github-api" },
"Resolved branch from GitHub API",
);
return branch;
}
logger.warn({ owner, repo }, "GitHub API returned no default_branch");
return null;
} catch (error) {
logger.debug(
{ error: error instanceof Error ? error.message : String(error) },
"GitHub API fetch failed",
);
return null;
}
}
/**
* Resolve the current git branch using a tiered strategy:
* 1. Local git CLI (rev-parse HEAD)
* 2. GitHub API (fetch actual remote repo info)
* 3. CACHE_MODEL_VERSION env var (explicit override)
* 4. Error log — no silent dummy fallbacks
*/
async function resolveBranch(): Promise<string | null> {
// Tier 1: Local git CLI
try {
const branch = execFileSync(
"git",
["rev-parse", "--abbrev-ref", "HEAD"],
{
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
},
)
.trim()
.toLowerCase();
if (branch && branch !== "head") {
return branch;
}
// Detached HEAD — use commit short hash
const commit = execFileSync("git", ["rev-parse", "--short", "HEAD"], {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"], // Suppress stderr
stdio: ["pipe", "pipe", "pipe"],
})
.trim()
.toLowerCase();
if (!branch || branch === "head") {
// Detached HEAD state — use commit hash prefix
const commit = execFileSync("git", ["rev-parse", "--short", "HEAD"], {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"], // Suppress stderr
})
.trim()
.toLowerCase();
if (commit) {
return `commit-${commit}`;
}
// Normalize branch name: replace slashes with hyphens, remove special chars
const normalized = branch
.replace(/[^a-z0-9-]/g, "-") // Replace non-alphanumeric with hyphens
.replace(/-+/g, "-") // Collapse multiple hyphens
.replace(/^-|-$/g, ""); // Remove leading/trailing hyphens
return normalized || "v1";
} catch (error) {
// Git not available or failed
if (envVersion) {
logger.info(
{ version: envVersion },
"Git detection failed; using CACHE_MODEL_VERSION from env",
);
return envVersion;
logger.debug(
{ error: error instanceof Error ? error.message : String(error) },
"Local git CLI unavailable, trying GitHub API",
);
}
// Tier 2: GitHub API — fetch real remote info
const remoteBranch = await fetchRemoteBranchFromGitHub();
if (remoteBranch) {
return remoteBranch;
}
// Tier 3: Environment variable (explicit override)
const envVersion = process.env.CACHE_MODEL_VERSION;
if (envVersion) {
logger.info(
{ version: envVersion, source: "env" },
"Using CACHE_MODEL_VERSION from env",
);
return envVersion;
}
return null;
}
/**
* Normalize a branch name for use as a cache version key.
* Replaces non-alphanumeric characters with hyphens, collapses multiples.
*/
function normalizeBranchName(branch: string): string {
return branch
.replace(/[^a-z0-9-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
/**
* Get the current vision model version (resolved at module load time).
* Version is derived from actual git branch (local or remote) to ensure
* version control. Old cache entries with mismatched versions are ignored.
*
* Resolution order:
* 1. Local git branch name
* 2. GitHub API default branch (real fetch, no dummy)
* 3. CACHE_MODEL_VERSION env var
* 4. Error logged — falls back to "v1" with ERROR level
*/
let _resolvedVersion: string | null = null;
function getVisionModelVersion(): string {
if (_resolvedVersion) {
return _resolvedVersion;
}
// Run resolution synchronously via sync fetch for module init
// GitHub API call is sync-blocking only during startup
try {
// Try local git first (sync, already tried above)
const branch = execFileSync(
"git",
["rev-parse", "--abbrev-ref", "HEAD"],
{
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
},
)
.trim()
.toLowerCase();
if (branch && branch !== "head") {
_resolvedVersion = normalizeBranchName(branch);
return _resolvedVersion;
}
// No git AND no env var — this is a real issue in production
const errorMsg =
error instanceof Error ? error.message : String(error);
logger.error(
{ error: errorMsg },
"Git command unavailable AND CACHE_MODEL_VERSION env not set. Cache versioning disabled. Set CACHE_MODEL_VERSION env var or ensure git is installed.",
);
// Detached HEAD
const commit = execFileSync("git", ["rev-parse", "--short", "HEAD"], {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
})
.trim()
.toLowerCase();
// ONLY fall back to "v1" if we have no choice, but log it as an error
// This ensures we're not silently using a dummy value
return "v1";
if (commit) {
_resolvedVersion = `commit-${commit}`;
return _resolvedVersion;
}
} catch {
// Git not available — continue to next tier
}
// Fallback: env var (we can't do async fetch here synchronously)
const envVersion = process.env.CACHE_MODEL_VERSION;
if (envVersion) {
logger.info(
{ version: envVersion, source: "env" },
"Using CACHE_MODEL_VERSION from env",
);
_resolvedVersion = envVersion;
return _resolvedVersion;
}
// No git, no env — log ERROR, no silent dummy
logger.error(
{
repo: "MythEclipse/bete",
githubApi: GITHUB_API_BASE,
},
"Cache version resolution failed: git CLI unavailable, GitHub API unreachable (async), and CACHE_MODEL_VERSION not set. Using 'v1' as emergency fallback. Set CACHE_MODEL_VERSION in .env or ensure git is installed.",
);
_resolvedVersion = "v1";
return _resolvedVersion;
}
// Post-startup: asynchronously resolve from GitHub API and update version
// This runs in the background after module init to get the real remote branch
resolveBranch()
.then((branch) => {
if (branch) {
const normalized = normalizeBranchName(branch);
const previous = _resolvedVersion;
if (previous && previous !== normalized) {
logger.info(
{ previous, resolved: normalized, source: "github-api-async" },
"Cache version upgraded from startup fallback to GitHub API resolved branch",
);
_resolvedVersion = normalized;
}
}
})
.catch(() => {
// Silently ignore async failure — already logged in resolveBranch
});
/**
* Get the current vision model version (cached at module load time).
* Version is derived from git branch name and remains constant for this process.