Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -248,10 +248,36 @@ jobs:
${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:${{ github.sha }}
${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:latest

# ─── Cache Busting ──────────────────────────────────────────────────────────
cache-bust:
name: Cache Bust (SW stamp + CDN purge)
needs: [build-api]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- run: npm ci
- name: Build frontend
run: npm run build
- name: Run cache busting
run: ./ops/deploy/cache-bust.sh --purge-cdn
env:
CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFRONT_DISTRIBUTION_ID: ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }}
- name: Upload cache-busted dist
uses: actions/upload-artifact@v4
with:
name: dist-cache-busted
path: dist/public/
retention-days: 7

# ─── Deploy Staging ─────────────────────────────────────────────────────────
deploy-staging:
name: Deploy to Staging
needs: [build-api, build-go-services, build-rust-services, build-python-services, build-infra-services]
needs: [build-api, build-go-services, build-rust-services, build-python-services, build-infra-services, cache-bust]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' || github.event.inputs.environment == 'staging'
environment: staging
Expand Down
35 changes: 32 additions & 3 deletions client/public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,25 @@
// v204 additions: Form M history, HNW banking, CBN compliance, BDC portal, SME trade routes cached
// v23 additions: Tier 1 (Expense, Contractor, KYB, Payroll Tax), Tier 2 (Savings, Bonds, LC, Invoice Financing, Payroll Run), Tier 3 (Embedded Payroll, Mortgage, Credit Scoring, ESG)

// CACHE_VERSION is auto-set by the deploy pipeline (ops/deploy/cache-bust.sh).
// During development it falls back to 'v24'. On every production deploy the
// script rewrites this line with a content-derived hash so the SW byte-changes,
// which triggers the browser's update-check and forces re-installation.
const CACHE_VERSION = 'v24';
const STATIC_CACHE = `remitflow-static-${CACHE_VERSION}`;
const API_CACHE = `remitflow-api-${CACHE_VERSION}`;
const FX_CACHE = `remitflow-fx-${CACHE_VERSION}`;
const COMMUNITY_CACHE = `remitflow-community-${CACHE_VERSION}`;
const REVENUE_SHARE_CACHE = `remitflow-revenue-share-${CACHE_VERSION}`;

const KNOWN_CACHE_PREFIXES = [
'remitflow-static-',
'remitflow-api-',
'remitflow-fx-',
'remitflow-community-',
'remitflow-revenue-share-',
];

// Cache size limits per cache bucket — prevents unbounded storage growth
const CACHE_SIZE_LIMITS = {
[STATIC_CACHE]: 100, // 100 items max
Expand Down Expand Up @@ -125,13 +137,30 @@ self.addEventListener('install', (event) => {
});

self.addEventListener('activate', (event) => {
const currentCaches = new Set([STATIC_CACHE, API_CACHE, FX_CACHE, COMMUNITY_CACHE, REVENUE_SHARE_CACHE]);
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.filter((k) => k.startsWith('remitflow-') && ![STATIC_CACHE, API_CACHE, FX_CACHE, REVENUE_SHARE_CACHE].includes(k))
.map((k) => caches.delete(k))
keys
.filter((k) => {
if (currentCaches.has(k)) return false;
return KNOWN_CACHE_PREFIXES.some((prefix) => k.startsWith(prefix));
})
.map((k) => {
console.log('[SW] Deleting stale cache:', k);
return caches.delete(k);
})
)
).then(() => self.clients.claim())
)
.then(() => self.clients.claim())
.then(() => {
// Notify all clients that a new version is active
self.clients.matchAll({ type: 'window' }).then((clients) => {
clients.forEach((client) => {
client.postMessage({ type: 'SW_UPDATED', version: CACHE_VERSION });
});
});
})
);
});

Expand Down
7 changes: 7 additions & 0 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { lazy, Suspense } from "react";
import { PWAInstallPrompt, PWAOfflineBanner, PWAUpdateBanner } from "./components/PWAInstallPrompt";
import { MobileBottomNav } from "./components/MobileBottomNav";
import { ConnectionQualityIndicator } from "./components/ConnectionQualityIndicator";
import { useVersionCheck } from "./hooks/useVersionCheck";
import { Loader2 } from "lucide-react";
const AMLBatchEnginePage = lazy(() => import("@/pages/AMLBatchEnginePage"));
const PBACPolicies = lazy(() => import("@/pages/PBACPolicies"));
Expand Down Expand Up @@ -747,11 +748,17 @@ function Router() {
);
}

function VersionChecker() {
useVersionCheck();
return null;
}

function App() {
return (
<ErrorBoundary>
<ThemeProvider defaultTheme="light" switchable={true}>
<TooltipProvider>
<VersionChecker />
<PWAOfflineBanner />
<PWAUpdateBanner />
<Toaster richColors position="top-right" />
Expand Down
2 changes: 2 additions & 0 deletions client/src/build-meta.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
declare const __BUILD_HASH__: string;
declare const __BUILD_TIMESTAMP__: string;
69 changes: 69 additions & 0 deletions client/src/hooks/useVersionCheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { useEffect, useRef, useCallback } from "react";

const VERSION_CHECK_INTERVAL = 5 * 60 * 1000; // 5 minutes
const currentBuildHash = typeof __BUILD_HASH__ !== "undefined" ? __BUILD_HASH__ : "dev";

interface VersionInfo {
hash: string;
timestamp: string;
version: string;
}

export function useVersionCheck() {
const lastKnownHash = useRef(currentBuildHash);

const checkVersion = useCallback(async () => {
if (currentBuildHash === "dev") return;

try {
const res = await fetch("/api/version", { cache: "no-store" });
if (!res.ok) return;

const info: VersionInfo = await res.json();
if (info.hash !== "dev" && info.hash !== lastKnownHash.current) {
lastKnownHash.current = info.hash;

if ("serviceWorker" in navigator && navigator.serviceWorker.controller) {
const reg = await navigator.serviceWorker.getRegistration();
if (reg) {
reg.update();
}
}
}
} catch {
// Network error — skip this check
}
}, []);

useEffect(() => {
// Check on mount (page load / navigation)
checkVersion();

// Periodic polling
const interval = setInterval(checkVersion, VERSION_CHECK_INTERVAL);

// Check when tab becomes visible (user returns from another tab)
const onVisibilityChange = () => {
if (document.visibilityState === "visible") {
checkVersion();
}
};
document.addEventListener("visibilitychange", onVisibilityChange);

// Listen for SW update messages
const onSWMessage = (event: MessageEvent) => {
if (event.data?.type === "SW_UPDATED") {
window.location.reload();
}
};
navigator.serviceWorker?.addEventListener("message", onSWMessage);

return () => {
clearInterval(interval);
document.removeEventListener("visibilitychange", onVisibilityChange);
navigator.serviceWorker?.removeEventListener("message", onSWMessage);
};
}, [checkVersion]);

return { buildHash: currentBuildHash };
}
147 changes: 147 additions & 0 deletions ops/deploy/cache-bust.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# RemitFlow — Deployment Cache Busting
# ─────────────────────────────────────────────────────────────────────────────
# Run this AFTER `npm run build` and BEFORE deploying to production.
# It performs three cache-busting steps:
# 1. Stamps the service worker with the build hash (forces SW update)
# 2. Generates a build-manifest.json (used by /api/version endpoint)
# 3. Optionally purges CDN caches (Cloudflare, CloudFront, Fastly)
#
# Usage:
# ./ops/deploy/cache-bust.sh # stamp only
# ./ops/deploy/cache-bust.sh --purge-cdn # stamp + CDN purge
# ./ops/deploy/cache-bust.sh --purge-cdn --dry-run # preview without executing
# ─────────────────────────────────────────────────────────────────────────────
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
DIST_DIR="${REPO_ROOT}/dist/public"
SW_PATH="${DIST_DIR}/sw.js"
MANIFEST_PATH="${DIST_DIR}/build-manifest.json"

PURGE_CDN=false
DRY_RUN=false

for arg in "$@"; do
case "$arg" in
--purge-cdn) PURGE_CDN=true ;;
--dry-run) DRY_RUN=true ;;
esac
done

# ─── Step 0: Validate build output exists ─────────────────────────────────────

if [ ! -d "$DIST_DIR" ]; then
echo "ERROR: Build output not found at $DIST_DIR"
echo " Run 'npm run build' first."
exit 1
fi

# ─── Step 1: Compute build hash from dist contents ───────────────────────────

echo "→ Computing build hash..."
BUILD_HASH=$(find "$DIST_DIR" -type f \( -name '*.js' -o -name '*.css' -o -name '*.html' \) \
-exec sha256sum {} + | sort | sha256sum | cut -c1-12)
BUILD_TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
BUILD_VERSION="v-${BUILD_HASH}"

echo " Hash: ${BUILD_HASH}"
echo " Timestamp: ${BUILD_TIMESTAMP}"
echo " Version: ${BUILD_VERSION}"

# ─── Step 2: Stamp the service worker ─────────────────────────────────────────

if [ -f "$SW_PATH" ]; then
echo "→ Stamping service worker with build hash..."
if $DRY_RUN; then
echo " [DRY RUN] Would replace CACHE_VERSION in $SW_PATH"
else
# Replace the CACHE_VERSION line with the new build hash
sed -i "s/const CACHE_VERSION = '[^']*';/const CACHE_VERSION = '${BUILD_VERSION}';/" "$SW_PATH"
echo " ✓ SW CACHE_VERSION set to '${BUILD_VERSION}'"
fi
else
echo " ⚠ sw.js not found in dist — skipping SW stamp"
fi

# ─── Step 3: Write build manifest ─────────────────────────────────────────────

echo "→ Writing build manifest..."
if $DRY_RUN; then
echo " [DRY RUN] Would write: { hash: ${BUILD_HASH}, timestamp: ${BUILD_TIMESTAMP} }"
else
cat > "$MANIFEST_PATH" <<EOF
{
"hash": "${BUILD_HASH}",
"timestamp": "${BUILD_TIMESTAMP}",
"version": "${BUILD_VERSION}"
}
EOF
echo " ✓ Manifest written to ${MANIFEST_PATH}"
fi

# ─── Step 4: CDN Purge (optional) ─────────────────────────────────────────────

if $PURGE_CDN; then
echo "→ Purging CDN caches..."

# Cloudflare
if [ -n "${CLOUDFLARE_ZONE_ID:-}" ] && [ -n "${CLOUDFLARE_API_TOKEN:-}" ]; then
echo " → Cloudflare zone ${CLOUDFLARE_ZONE_ID}..."
if $DRY_RUN; then
echo " [DRY RUN] Would purge all files"
else
curl -s -X POST "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
-H "Content-Type: application/json" \
--data '{"purge_everything":true}' | jq -r '.success // "failed"'
echo " ✓ Cloudflare cache purged"
fi
else
echo " ⚠ Cloudflare: CLOUDFLARE_ZONE_ID or CLOUDFLARE_API_TOKEN not set — skipping"
fi

# AWS CloudFront
if [ -n "${CLOUDFRONT_DISTRIBUTION_ID:-}" ]; then
echo " → CloudFront distribution ${CLOUDFRONT_DISTRIBUTION_ID}..."
if $DRY_RUN; then
echo " [DRY RUN] Would create invalidation for /*"
else
aws cloudfront create-invalidation \
--distribution-id "${CLOUDFRONT_DISTRIBUTION_ID}" \
--paths "/*" \
--query 'Invalidation.Id' --output text 2>/dev/null || echo " ⚠ CloudFront invalidation failed (aws CLI required)"
echo " ✓ CloudFront invalidation created"
fi
else
echo " ⚠ CloudFront: CLOUDFRONT_DISTRIBUTION_ID not set — skipping"
fi

# Fastly
if [ -n "${FASTLY_SERVICE_ID:-}" ] && [ -n "${FASTLY_API_KEY:-}" ]; then
echo " → Fastly service ${FASTLY_SERVICE_ID}..."
if $DRY_RUN; then
echo " [DRY RUN] Would purge all"
else
curl -s -X POST "https://api.fastly.com/service/${FASTLY_SERVICE_ID}/purge_all" \
-H "Fastly-Key: ${FASTLY_API_KEY}" | jq -r '.status // "failed"'
echo " ✓ Fastly cache purged"
fi
else
echo " ⚠ Fastly: FASTLY_SERVICE_ID or FASTLY_API_KEY not set — skipping"
fi
fi

# ─── Summary ──────────────────────────────────────────────────────────────────

echo ""
echo "═══════════════════════════════════════════════════════"
echo " Cache busting complete"
echo " Build: ${BUILD_VERSION}"
echo " Hash: ${BUILD_HASH}"
echo " Time: ${BUILD_TIMESTAMP}"
echo " CDN: $(if $PURGE_CDN; then echo 'purged'; else echo 'skipped (use --purge-cdn)'; fi)"
echo " Mode: $(if $DRY_RUN; then echo 'DRY RUN'; else echo 'applied'; fi)"
echo "═══════════════════════════════════════════════════════"
Loading