refactor: remove lite variant, fix release workflow

- Remove all lite/full variant logic from frontend, API, shared constants,
  docs, and tests (single unified Docker image only)
- Replace single QEMU multi-arch Docker build with per-architecture native
  builds (amd64 + arm64) and manifest merge to fix disk space exhaustion
- Add disk cleanup step and per-platform build cache scopes
- Switch release trigger from push to workflow_dispatch
- Add GitHub issue templates and PR template
This commit is contained in:
Siddharth Kumar Sah
2026-04-10 17:38:54 +08:00
parent b0083e2b08
commit 958b10cb45
32 changed files with 311 additions and 424 deletions
-8
View File
@@ -23,14 +23,6 @@ import { teamsRoutes } from "./routes/teams.js";
import { registerToolRoutes } from "./routes/tools/index.js";
import { userFileRoutes } from "./routes/user-files.js";
// Warn about deprecated STIRLING_VARIANT env var
if (process.env.STIRLING_VARIANT) {
console.warn(
`WARNING: STIRLING_VARIANT="${process.env.STIRLING_VARIANT}" is set but ignored. ` +
"There is now a single unified image with all features. Remove STIRLING_VARIANT from your environment.",
);
}
// Run before anything else
runMigrations();
console.log("Database initialized");
+1 -5
View File
@@ -6,7 +6,6 @@
* GET /api/v1/settings/:key — Get a specific setting
*/
import { PYTHON_SIDECAR_TOOLS } from "@stirling-image/shared";
import { eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { db, schema } from "../db/index.js";
@@ -27,10 +26,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
settings[row.key] = row.value;
}
const variant = process.env.STIRLING_VARIANT === "lite" ? "lite" : "full";
const variantUnavailableTools = variant === "lite" ? [...PYTHON_SIDECAR_TOOLS] : [];
return reply.send({ settings, variant, variantUnavailableTools });
return reply.send({ settings });
});
// PUT /api/v1/settings — Save settings (admin only)
+3 -21
View File
@@ -1,4 +1,4 @@
import { PYTHON_SIDECAR_TOOLS, TOOLS } from "@stirling-image/shared";
import { TOOLS } from "@stirling-image/shared";
import { eq } from "drizzle-orm";
import type { FastifyInstance } from "fastify";
import { db, schema } from "../../db/index.js";
@@ -69,10 +69,6 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
// Build skip set
const skipTools = new Set([...disabledTools, ...(enableExperimental ? [] : experimentalToolIds)]);
// In lite mode, register 501 stubs for AI tools instead of real handlers
const isLite = process.env.STIRLING_VARIANT === "lite";
const liteStubTools = new Set<string>(PYTHON_SIDECAR_TOOLS);
const toolRegistrations: Array<{
id: string;
register: (app: FastifyInstance) => void;
@@ -131,7 +127,6 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
];
let skipped = 0;
let stubbed = 0;
for (const { id, register } of toolRegistrations) {
if (skipTools.has(id)) {
app.log.info(`Skipping disabled/experimental tool: ${id}`);
@@ -139,24 +134,11 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
continue;
}
if (isLite && liteStubTools.has(id)) {
// Register a 501 stub instead of the real handler
app.post(`/api/v1/tools/${id}`, async (_request, reply) => {
return reply.status(501).send({
statusCode: 501,
error: "Not Available",
message: `The "${id}" tool requires the full image. Pull stirlingimage/stirling-image:latest for all features.`,
});
});
stubbed++;
continue;
}
register(app);
}
const registered = toolRegistrations.length - skipped - stubbed;
const registered = toolRegistrations.length - skipped;
app.log.info(
`Tool routes: ${registered} active, ${stubbed} lite-stubbed, ${skipped} skipped (${toolRegistrations.length} total)`,
`Tool routes: ${registered} active, ${skipped} skipped (${toolRegistrations.length} total)`,
);
}
+1 -1
View File
@@ -46,7 +46,7 @@ export default defineConfig({
`,
customTemplateVariables: {
description:
"Self-hosted, open-source image processing platform with 30+ tools. Runs in a single Docker container. Available as :latest (full, ~11 GB with AI/ML) or :lite (~1.5 GB, image processing only).",
"Self-hosted, open-source image processing platform with 30+ tools including AI/ML. Runs in a single Docker container with GPU auto-detection.",
details:
"Resize, compress, convert, remove backgrounds, upscale, run OCR, and more - without sending images to external services.",
},
+11 -4
View File
@@ -110,9 +110,16 @@ Set `client_max_body_size` to match your `MAX_UPLOAD_SIZE_MB` value.
## CI/CD
The GitHub repository has two workflows:
The GitHub repository has three workflows:
- **release.yml** -- On release, builds a multi-arch Docker image (amd64 + arm64), and pushes to Docker Hub (`stirlingimage/stirling-image`) and GitHub Container Registry (`ghcr.io/stirling-image/stirling-image`).
- **deploy-docs.yml** -- Builds this documentation site and deploys it to GitHub Pages.
- **ci.yml** -- Runs automatically on every push and PR. Lints, typechecks, tests, builds, and validates the Docker image (without pushing).
- **release.yml** -- Triggered manually via `workflow_dispatch`. Runs semantic-release to create a version tag and GitHub release, then builds a multi-arch Docker image (amd64 + arm64) and pushes to Docker Hub (`stirlingimage/stirling-image`) and GitHub Container Registry (`ghcr.io/stirling-image/stirling-image`).
- **deploy-docs.yml** -- Builds this documentation site and deploys it to GitHub Pages on push to `main`.
Both run automatically. No manual steps needed after merging to `main`.
To create a release, go to **Actions > Release > Run workflow** in the GitHub UI, or run:
```bash
gh workflow run release.yml
```
Semantic-release determines the version from commit history. The `latest` Docker tag always points to the most recent release.
-6
View File
@@ -198,12 +198,6 @@ Build the full production image locally:
docker build -f docker/Dockerfile -t stirling-image:latest .
```
Build the lite image (no Python/AI, ~1.5 GB):
```bash
docker build --build-arg VARIANT=lite -f docker/Dockerfile -t stirling-image:lite .
```
Use BuildKit cache mounts for faster rebuilds:
```bash
+1 -4
View File
@@ -118,9 +118,6 @@ volumes:
## Migration from previous tags
If you were using `:lite` or `:cuda` tags, switch to `:latest`:
- **From `:lite`**: Pull `:latest`. You now have all AI tools included.
- **From `:cuda`**: Pull `:latest` and keep `--gpus all`. Same GPU support, unified image.
If you were using the `:cuda` tag, switch to `:latest` and keep `--gpus all`. Same GPU support, unified image.
Your data and settings are preserved in the volumes.
+2 -12
View File
@@ -14,21 +14,11 @@ docker run -d \
Open `http://localhost:1349` in your browser. Log in with `admin` / `admin`.
::: tip Lite image
Don't need AI tools (background removal, upscaling, OCR, face blur, object eraser)? Use the lite image instead - 1.5 GB vs 11 GB:
```bash
stirlingimage/stirling-image:lite
```
All 27+ image processing tools work the same. See [Docker Tags](./docker-tags) for the full comparison.
:::
::: tip GPU acceleration
Have an NVIDIA GPU? The CUDA image auto-detects your GPU and accelerates background removal (2.7x), upscaling (3x), and OCR (1.5x):
Have an NVIDIA GPU? Add `--gpus all` to accelerate background removal (2.7x), upscaling (3x), and OCR (1.5x):
```bash
docker run -d --gpus all -p 1349:1349 -v stirling-data:/data stirlingimage/stirling-image:cuda
docker run -d --gpus all -p 1349:1349 -v stirling-data:/data stirlingimage/stirling-image:latest
```
Requires [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html). Falls back to CPU if no GPU is found. See [Docker Tags](./docker-tags) for details and benchmarks.
+2 -43
View File
@@ -1,58 +1,17 @@
import type { Tool } from "@stirling-image/shared";
import * as icons from "lucide-react";
import { FileImage, Sparkles, Star } from "lucide-react";
import { FileImage, Star } from "lucide-react";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
interface ToolCardProps {
tool: Tool;
variantUnavailable?: boolean;
}
export function ToolCard({ tool, variantUnavailable }: ToolCardProps) {
export function ToolCard({ tool }: ToolCardProps) {
const iconsMap = icons as unknown as Record<string, React.ComponentType<{ className?: string }>>;
const IconComponent = iconsMap[tool.icon] || FileImage;
if (variantUnavailable) {
return (
<div className="group flex items-center gap-3 relative">
<button
type="button"
className="opacity-0 group-hover:opacity-100 transition-opacity absolute -left-5"
title="Add to favourites"
>
<Star className="h-3 w-3 text-muted-foreground hover:text-yellow-500" />
</button>
<button
type="button"
onClick={() =>
toast("This tool requires the full image.", {
description:
"Pull stirlingimage/stirling-image:latest for all features including AI tools.",
action: {
label: "Learn more",
onClick: () =>
window.open(
"https://stirling-image.github.io/stirling-image/guide/docker-tags",
"_blank",
),
},
})
}
className="flex items-center gap-3 py-2 px-3 rounded-lg w-full transition-colors hover:bg-muted/50 opacity-50 cursor-pointer"
>
<IconComponent className="h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium text-foreground">{tool.name}</span>
<span className="flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-medium">
<Sparkles className="h-2.5 w-2.5" />
AI
</span>
</button>
</div>
);
}
return (
<div className="group flex items-center gap-3 relative">
<button
+2 -31
View File
@@ -1,5 +1,4 @@
import { CATEGORIES, TOOLS } from "@stirling-image/shared";
import { Info } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useSettingsStore } from "@/stores/settings-store";
import { SearchBar } from "../common/search-bar";
@@ -7,15 +6,12 @@ import { ToolCard } from "../common/tool-card";
export function ToolPanel() {
const [search, setSearch] = useState("");
const { variant, disabledTools, experimentalEnabled, variantUnavailableTools, loaded, fetch } =
useSettingsStore();
const { disabledTools, experimentalEnabled, loaded, fetch } = useSettingsStore();
useEffect(() => {
fetch();
}, [fetch]);
const unavailableSet = useMemo(() => new Set(variantUnavailableTools), [variantUnavailableTools]);
const visibleTools = useMemo(() => {
if (!loaded) return [];
return TOOLS.filter((t) => {
@@ -49,27 +45,6 @@ export function ToolPanel() {
<SearchBar value={search} onChange={setSearch} />
</div>
<div className="px-3 pb-4 flex-1">
{variant === "lite" && (
<div className="mb-3 p-2.5 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 text-amber-800 dark:text-amber-300">
<div className="flex items-start gap-2">
<Info className="h-4 w-4 mt-0.5 shrink-0" />
<div className="text-xs leading-relaxed">
<p className="font-medium">Lite mode</p>
<p className="mt-0.5 text-amber-700 dark:text-amber-400">
AI tools are unavailable. Use the{" "}
<code className="font-mono text-[10px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">
latest
</code>{" "}
or{" "}
<code className="font-mono text-[10px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">
full
</code>{" "}
tag for all features.
</p>
</div>
</div>
</div>
)}
{CATEGORIES.filter((cat) => groupedTools.has(cat.id)).map((category) => (
<div key={category.id} className="mb-4">
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-2">
@@ -77,11 +52,7 @@ export function ToolPanel() {
</h3>
<div className="space-y-0.5">
{groupedTools.get(category.id)?.map((tool) => (
<ToolCard
key={tool.id}
tool={tool}
variantUnavailable={unavailableSet.has(tool.id)}
/>
<ToolCard key={tool.id} tool={tool} />
))}
</div>
</div>
+6 -37
View File
@@ -1,12 +1,10 @@
import { CATEGORIES, TOOLS } from "@stirling-image/shared";
import * as icons from "lucide-react";
import { useCallback, useEffect, useMemo } from "react";
import { useCallback, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { ImageViewer } from "@/components/common/image-viewer";
import { MultiImageViewer } from "@/components/common/multi-image-viewer";
import { AppLayout } from "@/components/layout/app-layout";
import { cn } from "@/lib/utils";
import { useFileStore } from "@/stores/file-store";
import { useSettingsStore } from "@/stores/settings-store";
@@ -17,14 +15,12 @@ export function HomePage() {
const { setFiles, files, reset, originalBlobUrl, selectedFileName, selectedFileSize } =
useFileStore();
const navigate = useNavigate();
const { variantUnavailableTools, fetch: fetchSettings } = useSettingsStore();
const { fetch: fetchSettings } = useSettingsStore();
useEffect(() => {
fetchSettings();
}, [fetchSettings]);
const unavailableSet = useMemo(() => new Set(variantUnavailableTools), [variantUnavailableTools]);
const handleFiles = useCallback(
(newFiles: File[]) => {
reset();
@@ -33,25 +29,6 @@ export function HomePage() {
[setFiles, reset],
);
const handleToolClick = (route: string, toolId: string) => {
if (unavailableSet.has(toolId)) {
toast("This tool requires the full image.", {
description:
"Pull stirlingimage/stirling-image:latest for all features including AI tools.",
action: {
label: "Learn more",
onClick: () =>
window.open(
"https://stirling-image.github.io/stirling-image/guide/docker-tags",
"_blank",
),
},
});
return;
}
navigate(route);
};
const hasFile = files.length > 0;
// If no file uploaded, show default layout (tool panel + dropzone)
@@ -103,11 +80,8 @@ export function HomePage() {
<button
key={id}
type="button"
onClick={() => handleToolClick(tool.route, tool.id)}
className={cn(
"flex items-center gap-2 p-3 rounded-xl border border-border hover:border-primary hover:bg-primary/5 transition-colors text-left",
unavailableSet.has(id) && "opacity-50",
)}
onClick={() => navigate(tool.route)}
className="flex items-center gap-2 p-3 rounded-xl border border-border hover:border-primary hover:bg-primary/5 transition-colors text-left"
>
<div className="p-1.5 rounded-lg bg-primary/10 text-primary">
<Icon className="h-4 w-4" />
@@ -148,13 +122,8 @@ export function HomePage() {
<button
key={tool.id}
type="button"
onClick={() => handleToolClick(tool.route, tool.id)}
className={cn(
"flex items-center gap-2.5 w-full py-1.5 px-2 rounded-lg text-left transition-colors",
unavailableSet.has(tool.id)
? "opacity-50 hover:bg-muted/50"
: "hover:bg-muted text-foreground",
)}
onClick={() => navigate(tool.route)}
className="flex items-center gap-2.5 w-full py-1.5 px-2 rounded-lg text-left transition-colors hover:bg-muted text-foreground"
>
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm">{tool.name}</span>
+1 -9
View File
@@ -2,8 +2,6 @@ import { create } from "zustand";
import { apiGet } from "@/lib/api";
interface SettingsState {
variant: "full" | "lite";
variantUnavailableTools: string[];
disabledTools: string[];
experimentalEnabled: boolean;
loaded: boolean;
@@ -11,8 +9,6 @@ interface SettingsState {
}
export const useSettingsStore = create<SettingsState>((set, get) => ({
variant: "full",
variantUnavailableTools: [],
disabledTools: [],
experimentalEnabled: false,
loaded: false,
@@ -22,19 +18,15 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
try {
const data = await apiGet<{
settings: Record<string, string>;
variant: "full" | "lite";
variantUnavailableTools: string[];
}>("/v1/settings");
set({
variant: data.variant ?? "full",
variantUnavailableTools: data.variantUnavailableTools ?? [],
disabledTools: data.settings.disabledTools ? JSON.parse(data.settings.disabledTools) : [],
experimentalEnabled: data.settings.enableExperimentalTools === "true",
loaded: true,
});
} catch {
// Settings fetch failed - default to full with no disabled tools
// Settings fetch failed - default to no disabled tools
set({ loaded: true });
}
},