From 62453ebefaee5e7edde5c8771396fe96febb68a2 Mon Sep 17 00:00:00 2001 From: jubnl Date: Sun, 12 Apr 2026 16:50:20 +0200 Subject: [PATCH] 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 --- .github/workflows/docker-dev.yml | 45 +++++++++++------ .github/workflows/docker.yml | 25 ++++++--- client/src/components/Admin/GitHubPanel.tsx | 10 +++- server/src/services/adminService.ts | 56 ++++++++++++++------- 4 files changed, 93 insertions(+), 43 deletions(-) diff --git a/.github/workflows/docker-dev.yml b/.github/workflows/docker-dev.yml index 88a2de16..50970cab 100644 --- a/.github/workflows/docker-dev.yml +++ b/.github/workflows/docker-dev.yml @@ -17,26 +17,34 @@ on: permissions: contents: write +concurrency: + group: prerelease-build + cancel-in-progress: false + jobs: version-bump: runs-on: ubuntu-latest outputs: version: ${{ steps.bump.outputs.VERSION }} + sha: ${{ steps.bump.outputs.SHA }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - fetch-tags: true token: ${{ secrets.GITHUB_TOKEN }} - - name: Determine prerelease version and tag + - name: Determine prerelease version id: bump run: | git fetch --tags + # Capture the exact commit we're building so build/merge jobs are pinned to it + echo "SHA=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT + # Get latest stable tag (exclude prerelease tags) STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1) STABLE="${STABLE_TAG#v}" + STABLE="${STABLE:-0.0.0}" echo "Latest stable: $STABLE" IFS='.' read -r MAJOR MINOR PATCH <<< "$STABLE" @@ -62,12 +70,6 @@ jobs: echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT echo "$STABLE → $NEW_VERSION" - # Tag only — no file changes, no commit - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git tag "v$NEW_VERSION" - git push origin "v$NEW_VERSION" - build: runs-on: ${{ matrix.runner }} needs: version-bump @@ -85,7 +87,7 @@ jobs: - uses: actions/checkout@v4 with: - ref: dev + ref: ${{ needs.version-bump.outputs.sha }} - uses: docker/setup-buildx-action@v3 @@ -125,7 +127,9 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: dev + ref: ${{ needs.version-bump.outputs.sha }} + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} - name: Download build digests uses: actions/download-artifact@v4 @@ -144,27 +148,36 @@ jobs: - name: Create and push multi-arch manifest working-directory: /tmp/digests run: | - VERSION=${{ needs.version-bump.outputs.version }} + VERSION="${{ needs.version-bump.outputs.version }}" mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *) MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)-pre" docker buildx imagetools create \ - -t mauriceboe/trek:latest-pre \ - -t mauriceboe/trek:$MAJOR_TAG \ - -t mauriceboe/trek:$VERSION \ + -t "mauriceboe/trek:latest-pre" \ + -t "mauriceboe/trek:$MAJOR_TAG" \ + -t "mauriceboe/trek:$VERSION" \ "${digests[@]}" - name: Inspect manifest run: docker buildx imagetools inspect mauriceboe/trek:latest-pre + - name: Push git tag + run: | + VERSION="${{ needs.version-bump.outputs.version }}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag "v$VERSION" + git push origin "v$VERSION" + - name: Clean up old prerelease tags env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | KEEP=5 - VERSION=${{ needs.version-bump.outputs.version }} + VERSION="${{ needs.version-bump.outputs.version }}" BASE_VERSION="$(echo "$VERSION" | sed 's/-pre\..*//')" git fetch --tags - mapfile -t ALL_TAGS < <(git tag -l "v${BASE_VERSION}-pre.*" | sort -t. -k4 -n) + # Sort by numeric prerelease N (field after -pre.) to get correct ascending order + mapfile -t ALL_TAGS < <(git tag -l "v${BASE_VERSION}-pre.*" | awk -F'-pre\\.' '{print $2" "$0}' | sort -n | awk '{print $2}') TOTAL=${#ALL_TAGS[@]} DELETE_COUNT=$((TOTAL - KEEP)) if [ "$DELETE_COUNT" -gt 0 ]; then diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index a4b5ea31..e1880116 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -21,6 +21,10 @@ on: permissions: contents: write +concurrency: + group: stable-build + cancel-in-progress: false + jobs: version-bump: runs-on: ubuntu-latest @@ -41,6 +45,7 @@ jobs: # Derive version from git tags — no package.json dependency STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1) STABLE="${STABLE_TAG#v}" + STABLE="${STABLE:-0.0.0}" PRE_TAG=$(git tag -l 'v*-pre.*' | sort -V | tail -1) @@ -65,6 +70,12 @@ jobs: if [ -n "$PRE_TAG" ]; then PRE_BASE="${PRE_TAG#v}" PRE_BASE="${PRE_BASE%-pre.*}" + PRE_MAJOR="$(echo "$PRE_BASE" | cut -d. -f1)" + # Refuse to auto-finalize a major bump — it bypasses confirm_major + if [ "$PRE_MAJOR" -gt "$MAJOR" ]; then + echo "::error::In-flight prerelease $PRE_TAG is a major bump ($STABLE → $PRE_BASE). Use bump=major with confirm_major=MAJOR to finalize." + exit 1 + fi # If prerelease base is strictly greater than stable, finalize it HIGHEST=$(printf '%s\n' "$PRE_BASE" "$STABLE" | sort -V | tail -1) if [ "$HIGHEST" = "$PRE_BASE" ] && [ "$PRE_BASE" != "$STABLE" ]; then @@ -176,16 +187,16 @@ jobs: - name: Create and push multi-arch manifest working-directory: /tmp/digests run: | - VERSION=${{ needs.version-bump.outputs.version }} + VERSION="${{ needs.version-bump.outputs.version }}" mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *) MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)" docker buildx imagetools create \ - -t mauriceboe/trek:latest \ - -t mauriceboe/trek:$MAJOR_TAG \ - -t mauriceboe/trek:$VERSION \ - -t mauriceboe/nomad:latest \ - -t mauriceboe/nomad:$MAJOR_TAG \ - -t mauriceboe/nomad:$VERSION \ + -t "mauriceboe/trek:latest" \ + -t "mauriceboe/trek:$MAJOR_TAG" \ + -t "mauriceboe/trek:$VERSION" \ + -t "mauriceboe/nomad:latest" \ + -t "mauriceboe/nomad:$MAJOR_TAG" \ + -t "mauriceboe/nomad:$VERSION" \ "${digests[@]}" - name: Inspect manifest diff --git a/client/src/components/Admin/GitHubPanel.tsx b/client/src/components/Admin/GitHubPanel.tsx index 2c8d5d07..e4b9a578 100644 --- a/client/src/components/Admin/GitHubPanel.tsx +++ b/client/src/components/Admin/GitHubPanel.tsx @@ -6,9 +6,15 @@ import apiClient from '../../api/client' const REPO = 'mauriceboe/TREK' const PER_PAGE = 10 +interface GithubRelease { + id: number + prerelease: boolean + [key: string]: unknown +} + export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: boolean }) { const { t, language } = useTranslation() - const [releases, setReleases] = useState([]) + const [releases, setReleases] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [expanded, setExpanded] = useState({}) @@ -273,7 +279,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
- {(isPrerelease ? releases : releases.filter((r: any) => !r.prerelease)).map((release, idx) => { + {(isPrerelease ? releases : releases.filter(r => !r.prerelease)).map((release, idx) => { const isLatest = idx === 0 const isExpanded = expanded[release.id] diff --git a/server/src/services/adminService.ts b/server/src/services/adminService.ts index d96289f8..e31ab346 100644 --- a/server/src/services/adminService.ts +++ b/server/src/services/adminService.ts @@ -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 {