Self-heal a stale cached bundle via a build version
CI/CD / frontend (push) Successful in 5m15s
CI/CD / backend (push) Successful in 5m26s
CI/CD / build (push) Successful in 39s
CI/CD / cleanup (push) Successful in 7s
CI/CD / deploy (push) Successful in 1m22s

This commit is contained in:
matitos committed 2026-09-21 00:16:49 +00:00
commit 1428220210
11 files changed
+80 -4

No files matched your search

+2
View File
@@ -133,6 +133,8 @@ jobs:
# Persistent data root: repo var wins, else derive from the org # Persistent data root: repo var wins, else derive from the org
# PERSISTENT_MOUNT (absolute HOST path), else the workspace fallback. # PERSISTENT_MOUNT (absolute HOST path), else the workspace fallback.
CRYPTO_DATA_DIR: ${{ vars.CRYPTO_DATA_DIR || (vars.PERSISTENT_MOUNT && format('{0}/crypto_trading', vars.PERSISTENT_MOUNT)) || './data' }} CRYPTO_DATA_DIR: ${{ vars.CRYPTO_DATA_DIR || (vars.PERSISTENT_MOUNT && format('{0}/crypto_trading', vars.PERSISTENT_MOUNT)) || './data' }}
# Stamped into both images so the client can detect a stale cached bundle.
CRYPTO_BUILD_VERSION: ${{ github.sha }}
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
steps: steps:
+4
View File
@@ -5,6 +5,10 @@ ENV PYTHONUNBUFFERED=1 \
WORKDIR /app WORKDIR /app
# Build stamp exposed via /api/version so the frontend can detect a stale cache.
ARG BUILD_VERSION=unknown
ENV BUILD_VERSION=${BUILD_VERSION}
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
+7
View File
@@ -63,6 +63,13 @@ def health() -> dict[str, str]:
return {"status": "ok"} return {"status": "ok"}
@router.get("/version")
def version(request: Request) -> dict[str, str]:
"""Build version of the running backend, used by the client to self-heal
from a stale cached bundle after a deploy."""
return {"version": getattr(request.app.state, "build_version", "unknown")}
@router.get("/strategies", response_model=list[StrategyOut]) @router.get("/strategies", response_model=list[StrategyOut])
def strategies() -> list[StrategyOut]: def strategies() -> list[StrategyOut]:
return [strategy_spec(cls) for cls in list_strategies()] return [strategy_spec(cls) for cls in list_strategies()]
+4
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import os
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -27,6 +28,9 @@ def create_app(
await runtime.shutdown() await runtime.shutdown()
app = FastAPI(title=settings.app_name, lifespan=lifespan) app = FastAPI(title=settings.app_name, lifespan=lifespan)
# Baked at build time (see Dockerfile); the frontend compares its own
# version against this to self-heal from a stale cache.
app.state.build_version = os.environ.get("BUILD_VERSION", "unknown")
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=settings.cors_origin_list, allow_origins=settings.cors_origin_list,
+7
View File
@@ -9,6 +9,13 @@ from app.market.fake import FakeMarketData
from app.runtime import Runtime from app.runtime import Runtime
def test_version_endpoint(runtime: Runtime) -> None:
app = create_app(runtime.settings, runtime)
with TestClient(app) as client:
assert client.get("/api/version").status_code == 200
assert "version" in client.get("/api/version").json()
def test_health_and_strategies(runtime: Runtime) -> None: def test_health_and_strategies(runtime: Runtime) -> None:
app = create_app(runtime.settings, runtime) app = create_app(runtime.settings, runtime)
with TestClient(app) as client: with TestClient(app) as client:
+8 -2
View File
@@ -5,7 +5,10 @@ name: crypto_trading_local
services: services:
backend: backend:
build: ./backend build:
context: ./backend
args:
BUILD_VERSION: ${BUILD_VERSION:-unknown}
container_name: crypto-trading-backend-local container_name: crypto-trading-backend-local
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -37,7 +40,10 @@ services:
- "8000:8000" - "8000:8000"
frontend: frontend:
build: ./frontend build:
context: ./frontend
args:
BUILD_VERSION: ${BUILD_VERSION:-unknown}
container_name: crypto-trader-frontend-local container_name: crypto-trader-frontend-local
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
+8 -2
View File
@@ -2,7 +2,10 @@ name: crypto_trading
services: services:
backend: backend:
build: ./backend build:
context: ./backend
args:
BUILD_VERSION: ${CRYPTO_BUILD_VERSION:-unknown}
image: crypto_trading-backend image: crypto_trading-backend
container_name: crypto-trading-backend container_name: crypto-trading-backend
restart: unless-stopped restart: unless-stopped
@@ -56,7 +59,10 @@ services:
- internal - internal
frontend: frontend:
build: ./frontend build:
context: ./frontend
args:
BUILD_VERSION: ${CRYPTO_BUILD_VERSION:-unknown}
image: crypto_trading-frontend image: crypto_trading-frontend
container_name: crypto-trading-frontend container_name: crypto-trading-frontend
restart: unless-stopped restart: unless-stopped
+4
View File
@@ -1,5 +1,9 @@
FROM node:22-alpine AS build FROM node:22-alpine AS build
# Same value as the backend's BUILD_VERSION so the client can compare them.
ARG BUILD_VERSION=unknown
ENV VITE_BUILD_VERSION=${BUILD_VERSION}
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund RUN npm ci --no-audit --no-fund
+20
View File
@@ -133,7 +133,27 @@ function handleMessage(message) {
} }
} }
// If the server is running a newer build than this (possibly cached) bundle,
// reload once with a cache-busting query so phones pick up deploys without a
// manual hard-refresh. Guarded so it can never loop.
async function ensureFreshBuild() {
try {
const { version } = await api.getVersion()
if (!version || version === 'unknown') return
if (version === __BUILD_VERSION__) return
const key = `reloaded:${version}`
if (sessionStorage.getItem(key)) return
sessionStorage.setItem(key, '1')
const url = new URL(window.location.href)
url.searchParams.set('v', version)
window.location.replace(url.toString())
} catch {
// ignore; not worth blocking the app
}
}
onMounted(async () => { onMounted(async () => {
ensureFreshBuild()
await Promise.all([ await Promise.all([
loadStrategies(), loadStrategies(),
loadPresets(), loadPresets(),
+1
View File
@@ -2,6 +2,7 @@ import axios from 'axios'
const http = axios.create({ baseURL: '/api', timeout: 10000 }) const http = axios.create({ baseURL: '/api', timeout: 10000 })
export const getVersion = () => http.get('/version').then((r) => r.data)
export const getStatus = () => http.get('/status').then((r) => r.data) export const getStatus = () => http.get('/status').then((r) => r.data)
export const getStrategies = () => http.get('/strategies').then((r) => r.data) export const getStrategies = () => http.get('/strategies').then((r) => r.data)
export const getPositions = () => http.get('/positions').then((r) => r.data) export const getPositions = () => http.get('/positions').then((r) => r.data)
+15
View File
@@ -1,7 +1,22 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import { execSync } from 'node:child_process'
// Short git SHA (or a timestamp fallback) baked into the bundle so the client
// can detect when the server is running a newer build and reload itself.
let buildVersion = process.env.VITE_BUILD_VERSION || String(Date.now())
if (!process.env.VITE_BUILD_VERSION) {
try {
buildVersion = execSync('git rev-parse --short HEAD').toString().trim()
} catch {
// not a git checkout; keep the timestamp
}
}
export default defineConfig({ export default defineConfig({
define: {
__BUILD_VERSION__: JSON.stringify(buildVersion),
},
plugins: [vue()], plugins: [vue()],
server: { server: {
port: 5173, port: 5173,