fix: harden prerelease workflow against races, orphan tags, and edge cases

- Add concurrency groups to both workflows to prevent parallel version-bump races
- Defer git tag push to merge job so orphan tags can't exist without a live image
- Pin build/merge jobs to the SHA captured in version-bump to prevent TOCTOU
- Guard auto-finalize in docker.yml against cross-major prereleases (requires bump=major + confirm_major=MAJOR)
- Add STABLE fallback to 0.0.0 for fresh repos with no stable tag
- Fix cleanup sort to extract numeric N via awk instead of fragile sort -t. -k4 -n
- Add 5-minute in-memory cache to checkVersion to avoid GitHub API rate limits
- Type GitHubPanel releases state; remove any cast on filter
- Quote all $VERSION/$MAJOR_TAG vars in imagetools create calls
This commit is contained in:
jubnl
2026-04-12 16:50:20 +02:00
parent e198791139
commit 62453ebefa
4 changed files with 93 additions and 43 deletions
+38 -18
View File
@@ -315,10 +315,18 @@ export async function getGithubReleases(perPage: string = '10', page: string = '
}
}
const VERSION_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
let _versionCache: { data: object; expiresAt: number } | null = null;
export async function checkVersion() {
if (_versionCache && Date.now() < _versionCache.expiresAt) {
return _versionCache.data;
}
const currentVersion: string = process.env.APP_VERSION || require('../../package.json').version;
const isPrerelease = currentVersion.includes('-pre.');
const fallback = { current: currentVersion, latest: currentVersion, update_available: false, is_docker: isDocker, is_prerelease: isPrerelease };
let result: object = fallback;
try {
if (isPrerelease) {
// Fetch release list and find the newest prerelease
@@ -326,32 +334,44 @@ export async function checkVersion() {
'https://api.github.com/repos/mauriceboe/TREK/releases?per_page=20',
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
);
if (!resp.ok) return fallback;
const data = await resp.json() as Array<{ tag_name?: string; html_url?: string; prerelease?: boolean }>;
const prereleases = Array.isArray(data) ? data.filter(r => r.prerelease) : [];
if (!prereleases.length) return fallback;
// Sort by version descending and pick highest
const sorted = prereleases.sort((a, b) => compareVersions(
(b.tag_name || '').replace(/^v/, ''),
(a.tag_name || '').replace(/^v/, '')
));
const latest = (sorted[0].tag_name || '').replace(/^v/, '');
const update_available = !!latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0;
return { current: currentVersion, latest, update_available, release_url: sorted[0].html_url || '', is_docker: isDocker, is_prerelease: true };
if (!resp.ok) {
result = fallback;
} else {
const data = await resp.json() as Array<{ tag_name?: string; html_url?: string; prerelease?: boolean }>;
const prereleases = Array.isArray(data) ? data.filter(r => r.prerelease) : [];
if (!prereleases.length) {
result = fallback;
} else {
// Sort by version descending and pick highest
const sorted = prereleases.sort((a, b) => compareVersions(
(b.tag_name || '').replace(/^v/, ''),
(a.tag_name || '').replace(/^v/, '')
));
const latest = (sorted[0].tag_name || '').replace(/^v/, '');
const update_available = !!latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0;
result = { current: currentVersion, latest, update_available, release_url: sorted[0].html_url || '', is_docker: isDocker, is_prerelease: true };
}
}
} else {
const resp = await fetch(
'https://api.github.com/repos/mauriceboe/TREK/releases/latest',
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
);
if (!resp.ok) return fallback;
const data = await resp.json() as { tag_name?: string; html_url?: string };
const latest = (data.tag_name || '').replace(/^v/, '');
const update_available = !!latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0;
return { current: currentVersion, latest, update_available, release_url: data.html_url || '', is_docker: isDocker, is_prerelease: false };
if (!resp.ok) {
result = fallback;
} else {
const data = await resp.json() as { tag_name?: string; html_url?: string };
const latest = (data.tag_name || '').replace(/^v/, '');
const update_available = !!latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0;
result = { current: currentVersion, latest, update_available, release_url: data.html_url || '', is_docker: isDocker, is_prerelease: false };
}
}
} catch {
return fallback;
result = fallback;
}
_versionCache = { data: result, expiresAt: Date.now() + VERSION_CACHE_TTL };
return result;
}
export async function checkAndNotifyVersion(): Promise<void> {