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
+29 -16
View File
@@ -17,26 +17,34 @@ on:
permissions: permissions:
contents: write contents: write
concurrency:
group: prerelease-build
cancel-in-progress: false
jobs: jobs:
version-bump: version-bump:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
version: ${{ steps.bump.outputs.VERSION }} version: ${{ steps.bump.outputs.VERSION }}
sha: ${{ steps.bump.outputs.SHA }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
fetch-tags: true
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: Determine prerelease version and tag - name: Determine prerelease version
id: bump id: bump
run: | run: |
git fetch --tags 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) # 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_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
STABLE="${STABLE_TAG#v}" STABLE="${STABLE_TAG#v}"
STABLE="${STABLE:-0.0.0}"
echo "Latest stable: $STABLE" echo "Latest stable: $STABLE"
IFS='.' read -r MAJOR MINOR PATCH <<< "$STABLE" IFS='.' read -r MAJOR MINOR PATCH <<< "$STABLE"
@@ -62,12 +70,6 @@ jobs:
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "$STABLE → $NEW_VERSION" 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: build:
runs-on: ${{ matrix.runner }} runs-on: ${{ matrix.runner }}
needs: version-bump needs: version-bump
@@ -85,7 +87,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
ref: dev ref: ${{ needs.version-bump.outputs.sha }}
- uses: docker/setup-buildx-action@v3 - uses: docker/setup-buildx-action@v3
@@ -125,7 +127,9 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
ref: dev ref: ${{ needs.version-bump.outputs.sha }}
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Download build digests - name: Download build digests
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
@@ -144,27 +148,36 @@ jobs:
- name: Create and push multi-arch manifest - name: Create and push multi-arch manifest
working-directory: /tmp/digests working-directory: /tmp/digests
run: | run: |
VERSION=${{ needs.version-bump.outputs.version }} VERSION="${{ needs.version-bump.outputs.version }}"
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *) mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)-pre" MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)-pre"
docker buildx imagetools create \ docker buildx imagetools create \
-t mauriceboe/trek:latest-pre \ -t "mauriceboe/trek:latest-pre" \
-t mauriceboe/trek:$MAJOR_TAG \ -t "mauriceboe/trek:$MAJOR_TAG" \
-t mauriceboe/trek:$VERSION \ -t "mauriceboe/trek:$VERSION" \
"${digests[@]}" "${digests[@]}"
- name: Inspect manifest - name: Inspect manifest
run: docker buildx imagetools inspect mauriceboe/trek:latest-pre 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 - name: Clean up old prerelease tags
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
KEEP=5 KEEP=5
VERSION=${{ needs.version-bump.outputs.version }} VERSION="${{ needs.version-bump.outputs.version }}"
BASE_VERSION="$(echo "$VERSION" | sed 's/-pre\..*//')" BASE_VERSION="$(echo "$VERSION" | sed 's/-pre\..*//')"
git fetch --tags 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[@]} TOTAL=${#ALL_TAGS[@]}
DELETE_COUNT=$((TOTAL - KEEP)) DELETE_COUNT=$((TOTAL - KEEP))
if [ "$DELETE_COUNT" -gt 0 ]; then if [ "$DELETE_COUNT" -gt 0 ]; then
+18 -7
View File
@@ -21,6 +21,10 @@ on:
permissions: permissions:
contents: write contents: write
concurrency:
group: stable-build
cancel-in-progress: false
jobs: jobs:
version-bump: version-bump:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -41,6 +45,7 @@ jobs:
# Derive version from git tags — no package.json dependency # 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_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
STABLE="${STABLE_TAG#v}" STABLE="${STABLE_TAG#v}"
STABLE="${STABLE:-0.0.0}"
PRE_TAG=$(git tag -l 'v*-pre.*' | sort -V | tail -1) PRE_TAG=$(git tag -l 'v*-pre.*' | sort -V | tail -1)
@@ -65,6 +70,12 @@ jobs:
if [ -n "$PRE_TAG" ]; then if [ -n "$PRE_TAG" ]; then
PRE_BASE="${PRE_TAG#v}" PRE_BASE="${PRE_TAG#v}"
PRE_BASE="${PRE_BASE%-pre.*}" 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 # If prerelease base is strictly greater than stable, finalize it
HIGHEST=$(printf '%s\n' "$PRE_BASE" "$STABLE" | sort -V | tail -1) HIGHEST=$(printf '%s\n' "$PRE_BASE" "$STABLE" | sort -V | tail -1)
if [ "$HIGHEST" = "$PRE_BASE" ] && [ "$PRE_BASE" != "$STABLE" ]; then if [ "$HIGHEST" = "$PRE_BASE" ] && [ "$PRE_BASE" != "$STABLE" ]; then
@@ -176,16 +187,16 @@ jobs:
- name: Create and push multi-arch manifest - name: Create and push multi-arch manifest
working-directory: /tmp/digests working-directory: /tmp/digests
run: | run: |
VERSION=${{ needs.version-bump.outputs.version }} VERSION="${{ needs.version-bump.outputs.version }}"
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *) mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)" MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)"
docker buildx imagetools create \ docker buildx imagetools create \
-t mauriceboe/trek:latest \ -t "mauriceboe/trek:latest" \
-t mauriceboe/trek:$MAJOR_TAG \ -t "mauriceboe/trek:$MAJOR_TAG" \
-t mauriceboe/trek:$VERSION \ -t "mauriceboe/trek:$VERSION" \
-t mauriceboe/nomad:latest \ -t "mauriceboe/nomad:latest" \
-t mauriceboe/nomad:$MAJOR_TAG \ -t "mauriceboe/nomad:$MAJOR_TAG" \
-t mauriceboe/nomad:$VERSION \ -t "mauriceboe/nomad:$VERSION" \
"${digests[@]}" "${digests[@]}"
- name: Inspect manifest - name: Inspect manifest
+8 -2
View File
@@ -6,9 +6,15 @@ import apiClient from '../../api/client'
const REPO = 'mauriceboe/TREK' const REPO = 'mauriceboe/TREK'
const PER_PAGE = 10 const PER_PAGE = 10
interface GithubRelease {
id: number
prerelease: boolean
[key: string]: unknown
}
export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: boolean }) { export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: boolean }) {
const { t, language } = useTranslation() const { t, language } = useTranslation()
const [releases, setReleases] = useState([]) const [releases, setReleases] = useState<GithubRelease[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [expanded, setExpanded] = useState({}) const [expanded, setExpanded] = useState({})
@@ -273,7 +279,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
<div className="absolute left-[11px] top-3 bottom-3 w-px" style={{ background: 'var(--border-primary)' }} /> <div className="absolute left-[11px] top-3 bottom-3 w-px" style={{ background: 'var(--border-primary)' }} />
<div className="space-y-0"> <div className="space-y-0">
{(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 isLatest = idx === 0
const isExpanded = expanded[release.id] const isExpanded = expanded[release.id]
+26 -6
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() { 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 currentVersion: string = process.env.APP_VERSION || require('../../package.json').version;
const isPrerelease = currentVersion.includes('-pre.'); const isPrerelease = currentVersion.includes('-pre.');
const fallback = { current: currentVersion, latest: currentVersion, update_available: false, is_docker: isDocker, is_prerelease: isPrerelease }; const fallback = { current: currentVersion, latest: currentVersion, update_available: false, is_docker: isDocker, is_prerelease: isPrerelease };
let result: object = fallback;
try { try {
if (isPrerelease) { if (isPrerelease) {
// Fetch release list and find the newest prerelease // Fetch release list and find the newest prerelease
@@ -326,10 +334,14 @@ export async function checkVersion() {
'https://api.github.com/repos/mauriceboe/TREK/releases?per_page=20', 'https://api.github.com/repos/mauriceboe/TREK/releases?per_page=20',
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } } { headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
); );
if (!resp.ok) return fallback; if (!resp.ok) {
result = fallback;
} else {
const data = await resp.json() as Array<{ tag_name?: string; html_url?: string; prerelease?: boolean }>; 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) : []; const prereleases = Array.isArray(data) ? data.filter(r => r.prerelease) : [];
if (!prereleases.length) return fallback; if (!prereleases.length) {
result = fallback;
} else {
// Sort by version descending and pick highest // Sort by version descending and pick highest
const sorted = prereleases.sort((a, b) => compareVersions( const sorted = prereleases.sort((a, b) => compareVersions(
(b.tag_name || '').replace(/^v/, ''), (b.tag_name || '').replace(/^v/, ''),
@@ -337,21 +349,29 @@ export async function checkVersion() {
)); ));
const latest = (sorted[0].tag_name || '').replace(/^v/, ''); const latest = (sorted[0].tag_name || '').replace(/^v/, '');
const update_available = !!latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0; 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 }; result = { current: currentVersion, latest, update_available, release_url: sorted[0].html_url || '', is_docker: isDocker, is_prerelease: true };
}
}
} else { } else {
const resp = await fetch( const resp = await fetch(
'https://api.github.com/repos/mauriceboe/TREK/releases/latest', 'https://api.github.com/repos/mauriceboe/TREK/releases/latest',
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } } { headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
); );
if (!resp.ok) return fallback; if (!resp.ok) {
result = fallback;
} else {
const data = await resp.json() as { tag_name?: string; html_url?: string }; const data = await resp.json() as { tag_name?: string; html_url?: string };
const latest = (data.tag_name || '').replace(/^v/, ''); const latest = (data.tag_name || '').replace(/^v/, '');
const update_available = !!latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0; 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 }; result = { current: currentVersion, latest, update_available, release_url: data.html_url || '', is_docker: isDocker, is_prerelease: false };
}
} }
} catch { } catch {
return fallback; result = fallback;
} }
_versionCache = { data: result, expiresAt: Date.now() + VERSION_CACHE_TTL };
return result;
} }
export async function checkAndNotifyVersion(): Promise<void> { export async function checkAndNotifyVersion(): Promise<void> {