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"); const logger = createChildLogger("text-cache-store");
/** GitHub API base URL for MythEclipse/bete repo */
const GITHUB_API_BASE = "https://api.github.com/repos/MythEclipse/bete";
/** /**
* Model version for vision/LLM cache entries. * Fetch the current branch name from GitHub API as a real fallback
* Dynamically derived from git branch name to ensure version control. * when local git command is unavailable. This queries the actual remote.
*
* 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 { async function fetchRemoteBranchFromGitHub(): Promise<string | null> {
// 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;
try { try {
// Get current git branch name using execFileSync (safe, no shell injection) const controller = new AbortController();
const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { const timeout = setTimeout(() => controller.abort(), 5000);
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"], // Suppress stderr
})
.trim()
.toLowerCase();
if (!branch || branch === "head") { const response = await fetch(`${GITHUB_API_BASE}`, {
// Detached HEAD state — use commit hash prefix signal: controller.signal,
const commit = execFileSync("git", ["rev-parse", "--short", "HEAD"], { headers: {
encoding: "utf-8", Accept: "application/vnd.github+json",
stdio: ["pipe", "pipe", "pipe"], // Suppress stderr "X-GitHub-Api-Version": "2022-11-28",
}) },
.trim() });
.toLowerCase(); clearTimeout(timeout);
return `commit-${commit}`;
if (!response.ok) {
logger.debug(
{ status: response.status },
"GitHub API repo fetch failed",
);
return null;
} }
// Normalize branch name: replace slashes with hyphens, remove special chars const data = (await response.json()) as {
const normalized = branch default_branch?: string;
.replace(/[^a-z0-9-]/g, "-") // Replace non-alphanumeric with hyphens owner?: { login?: string };
.replace(/-+/g, "-") // Collapse multiple hyphens name?: string;
.replace(/^-|-$/g, ""); // Remove leading/trailing hyphens };
return normalized || "v1"; 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) { } catch (error) {
// Git not available or failed 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"],
})
.trim()
.toLowerCase();
if (commit) {
return `commit-${commit}`;
}
} catch (error) {
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) { if (envVersion) {
logger.info( logger.info(
{ version: envVersion }, { version: envVersion, source: "env" },
"Git detection failed; using CACHE_MODEL_VERSION from env", "Using CACHE_MODEL_VERSION from env",
); );
return envVersion; return envVersion;
} }
// No git AND no env var — this is a real issue in production return null;
const errorMsg = }
error instanceof Error ? error.message : String(error);
/**
* 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;
}
// Detached HEAD
const commit = execFileSync("git", ["rev-parse", "--short", "HEAD"], {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
})
.trim()
.toLowerCase();
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( 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.", 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.",
); );
// ONLY fall back to "v1" if we have no choice, but log it as an error _resolvedVersion = "v1";
// This ensures we're not silently using a dummy value return _resolvedVersion;
return "v1";
}
} }
// 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). * Get the current vision model version (cached at module load time).
* Version is derived from git branch name and remains constant for this process. * Version is derived from git branch name and remains constant for this process.