mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
@@ -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,32 +334,44 @@ 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) {
|
||||||
const data = await resp.json() as Array<{ tag_name?: string; html_url?: string; prerelease?: boolean }>;
|
result = fallback;
|
||||||
const prereleases = Array.isArray(data) ? data.filter(r => r.prerelease) : [];
|
} else {
|
||||||
if (!prereleases.length) return fallback;
|
const data = await resp.json() as Array<{ tag_name?: string; html_url?: string; prerelease?: boolean }>;
|
||||||
// Sort by version descending and pick highest
|
const prereleases = Array.isArray(data) ? data.filter(r => r.prerelease) : [];
|
||||||
const sorted = prereleases.sort((a, b) => compareVersions(
|
if (!prereleases.length) {
|
||||||
(b.tag_name || '').replace(/^v/, ''),
|
result = fallback;
|
||||||
(a.tag_name || '').replace(/^v/, '')
|
} else {
|
||||||
));
|
// Sort by version descending and pick highest
|
||||||
const latest = (sorted[0].tag_name || '').replace(/^v/, '');
|
const sorted = prereleases.sort((a, b) => compareVersions(
|
||||||
const update_available = !!latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0;
|
(b.tag_name || '').replace(/^v/, ''),
|
||||||
return { current: currentVersion, latest, update_available, release_url: sorted[0].html_url || '', is_docker: isDocker, is_prerelease: true };
|
(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 {
|
} 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) {
|
||||||
const data = await resp.json() as { tag_name?: string; html_url?: string };
|
result = fallback;
|
||||||
const latest = (data.tag_name || '').replace(/^v/, '');
|
} else {
|
||||||
const update_available = !!latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0;
|
const data = await resp.json() as { tag_name?: string; html_url?: string };
|
||||||
return { current: currentVersion, latest, update_available, release_url: data.html_url || '', is_docker: isDocker, is_prerelease: false };
|
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 {
|
} 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> {
|
||||||
|
|||||||
Reference in New Issue
Block a user