feat: add semantic-release for automated versioning and help dialog

- Set up semantic-release with zero-touch CI pipeline on push to main
- Add version sync script to keep all package.json files and APP_VERSION
  constant in sync automatically
- Consolidate Docker publishing into single tag-triggered workflow that
  pushes to both Docker Hub and ghcr.io with semver tags
- Add help dialog with keyboard shortcuts, getting started guide, and
  resource links
- Sync all versions to 0.2.1 to match Docker Hub latest
This commit is contained in:
Siddharth Kumar Sah
2026-03-22 21:25:14 +08:00
parent fb1d366d5f
commit 4807bd2726
22 changed files with 2359 additions and 82 deletions
+1 -41
View File
@@ -45,7 +45,7 @@ jobs:
- run: pnpm build - run: pnpm build
docker: docker:
name: Docker Build name: Docker Build Test
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build needs: build
steps: steps:
@@ -61,43 +61,3 @@ jobs:
tags: stirling-image:ci tags: stirling-image:ci
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
docker-publish:
name: Publish Docker Image
runs-on: ubuntu-latest
needs: docker
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+18 -5
View File
@@ -2,7 +2,6 @@ name: Build and Push Docker Image
on: on:
push: push:
branches: [main]
tags: ["v*"] tags: ["v*"]
workflow_dispatch: workflow_dispatch:
@@ -30,22 +29,36 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Extract metadata - name: Extract metadata
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: ${{ env.IMAGE_NAME }} images: |
${{ env.IMAGE_NAME }}
ghcr.io/${{ github.repository }}
tags: | tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=sha,prefix= type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest
- name: Build and push - name: Build and push
uses: docker/build-push-action@v5 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: docker/Dockerfile file: docker/Dockerfile
push: true push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha cache-from: type=gha
+40
View File
@@ -0,0 +1,40 @@
name: Release
on:
push:
branches: [main]
permissions:
contents: write
issues: write
pull-requests: write
jobs:
release:
name: Semantic Release
runs-on: ubuntu-latest
# Skip release commits to avoid infinite loop
if: "!contains(github.event.head_commit.message, '[skip ci]')"
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npx semantic-release
+44
View File
@@ -0,0 +1,44 @@
{
"branches": ["main"],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
[
"@semantic-release/changelog",
{
"changelogFile": "CHANGELOG.md"
}
],
[
"@semantic-release/npm",
{
"npmPublish": false
}
],
[
"@semantic-release/exec",
{
"prepareCmd": "./scripts/sync-version.sh ${nextRelease.version}"
}
],
[
"@semantic-release/git",
{
"assets": [
"package.json",
"pnpm-lock.yaml",
"CHANGELOG.md",
"apps/web/package.json",
"apps/api/package.json",
"apps/docs/package.json",
"packages/shared/package.json",
"packages/shared/src/constants.ts",
"packages/image-engine/package.json",
"packages/ai/package.json"
],
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
}
],
"@semantic-release/github"
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@stirling-image/api", "name": "@stirling-image/api",
"version": "0.0.1", "version": "0.2.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+16 -2
View File
@@ -401,8 +401,22 @@ export async function authMiddleware(app: FastifyInstance): Promise<void> {
app.addHook( app.addHook(
"preHandler", "preHandler",
async (request: FastifyRequest, reply: FastifyReply) => { async (request: FastifyRequest, reply: FastifyReply) => {
// Skip if auth is disabled // When auth is disabled, attach the first admin user so requireAuth/requireAdmin pass
if (!env.AUTH_ENABLED) return; if (!env.AUTH_ENABLED) {
const adminUser = db
.select()
.from(schema.users)
.where(eq(schema.users.role, "admin"))
.get();
if (adminUser) {
(request as FastifyRequest & { user?: AuthUser }).user = {
id: adminUser.id,
username: adminUser.username,
role: "admin",
};
}
return;
}
const isPublic = isPublicRoute(request.url); const isPublic = isPublicRoute(request.url);
@@ -55,7 +55,7 @@ export function registerRemoveBackground(app: FastifyInstance) {
const resultBuffer = await removeBackground( const resultBuffer = await removeBackground(
fileBuffer, fileBuffer,
join(workspacePath, "output"), join(workspacePath, "output"),
{ model: settings.model }, { model: settings.model, backgroundColor: settings.backgroundColor },
); );
// Save output // Save output
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@stirling-image/docs", "name": "@stirling-image/docs",
"version": "0.1.0", "version": "0.2.1",
"private": true, "private": true,
"scripts": { "scripts": {
"docs:dev": "vitepress dev .", "docs:dev": "vitepress dev .",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@stirling-image/web", "name": "@stirling-image/web",
"version": "0.0.1", "version": "0.2.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+13 -1
View File
@@ -13,6 +13,7 @@ import { ToolPanel } from "./tool-panel";
import { Footer } from "./footer"; import { Footer } from "./footer";
import { Dropzone } from "../common/dropzone"; import { Dropzone } from "../common/dropzone";
import { SettingsDialog } from "../settings/settings-dialog"; import { SettingsDialog } from "../settings/settings-dialog";
import { HelpDialog } from "../help/help-dialog";
import { useMobile } from "@/hooks/use-mobile"; import { useMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -24,6 +25,7 @@ interface AppLayoutProps {
export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayoutProps) { export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayoutProps) {
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const isMobile = useMobile(); const isMobile = useMobile();
@@ -31,7 +33,7 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
<div className="flex h-screen bg-background text-foreground overflow-hidden"> <div className="flex h-screen bg-background text-foreground overflow-hidden">
{/* Desktop sidebar */} {/* Desktop sidebar */}
{!isMobile && ( {!isMobile && (
<Sidebar onSettingsClick={() => setSettingsOpen(true)} /> <Sidebar onSettingsClick={() => setSettingsOpen(true)} onHelpClick={() => setHelpOpen(true)} />
)} )}
{/* Mobile sidebar overlay */} {/* Mobile sidebar overlay */}
@@ -59,6 +61,10 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
setMobileSidebarOpen(false); setMobileSidebarOpen(false);
setSettingsOpen(true); setSettingsOpen(true);
}} }}
onHelpClick={() => {
setMobileSidebarOpen(false);
setHelpOpen(true);
}}
expanded expanded
/> />
</div> </div>
@@ -122,6 +128,12 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
open={settingsOpen} open={settingsOpen}
onClose={() => setSettingsOpen(false)} onClose={() => setSettingsOpen(false)}
/> />
{/* Help dialog */}
<HelpDialog
open={helpOpen}
onClose={() => setHelpOpen(false)}
/>
</div> </div>
); );
} }
+10 -2
View File
@@ -25,17 +25,18 @@ const topItems: SidebarItem[] = [
]; ];
const bottomItems: SidebarItem[] = [ const bottomItems: SidebarItem[] = [
{ icon: HelpCircle, label: "Help", href: "/help" }, { icon: HelpCircle, label: "Help" },
{ icon: Settings, label: "Settings" }, { icon: Settings, label: "Settings" },
]; ];
interface SidebarProps { interface SidebarProps {
onSettingsClick: () => void; onSettingsClick: () => void;
onHelpClick: () => void;
/** When true, renders in expanded mode (for mobile overlay). */ /** When true, renders in expanded mode (for mobile overlay). */
expanded?: boolean; expanded?: boolean;
} }
export function Sidebar({ onSettingsClick, expanded = false }: SidebarProps) { export function Sidebar({ onSettingsClick, onHelpClick, expanded = false }: SidebarProps) {
const location = useLocation(); const location = useLocation();
const renderItem = (item: SidebarItem, isActive: boolean) => { const renderItem = (item: SidebarItem, isActive: boolean) => {
@@ -72,6 +73,13 @@ export function Sidebar({ onSettingsClick, expanded = false }: SidebarProps) {
</button> </button>
); );
} }
if (item.label === "Help") {
return (
<button key={item.label} onClick={onHelpClick} className="w-full">
{content}
</button>
);
}
return ( return (
<Link key={item.label} to={item.href || "/"}> <Link key={item.label} to={item.href || "/"}>
{content} {content}
+14 -1
View File
@@ -1,18 +1,31 @@
{ {
"name": "stirling-image", "name": "stirling-image",
"version": "0.2.1",
"private": true, "private": true,
"packageManager": "pnpm@9.15.4", "packageManager": "pnpm@9.15.4",
"scripts": { "scripts": {
"dev": "turbo dev", "dev": "turbo dev",
"docs:dev": "pnpm --filter @stirling-image/docs docs:dev",
"build": "turbo build", "build": "turbo build",
"lint": "turbo lint", "lint": "turbo lint",
"clean": "turbo clean", "clean": "turbo clean",
"typecheck": "turbo typecheck", "typecheck": "turbo typecheck",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui" "test:e2e:ui": "playwright test --ui",
"version:sync": "./scripts/sync-version.sh",
"release": "semantic-release",
"release:dry": "semantic-release --dry-run"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.58.2", "@playwright/test": "^1.58.2",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/commit-analyzer": "^13.0.1",
"@semantic-release/exec": "^7.1.0",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^12.0.6",
"@semantic-release/npm": "^13.1.5",
"@semantic-release/release-notes-generator": "^14.1.0",
"semantic-release": "^25.0.3",
"turbo": "^2.4.0", "turbo": "^2.4.0",
"typescript": "^5.7.0" "typescript": "^5.7.0"
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@stirling-image/ai", "name": "@stirling-image/ai",
"version": "0.0.1", "version": "0.2.1",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./src/index.ts", "main": "./src/index.ts",
-2
View File
@@ -68,10 +68,8 @@ def main():
} }
) )
) )
sys.exit(1)
except Exception as e: except Exception as e:
print(json.dumps({"success": False, "error": str(e)})) print(json.dumps({"success": False, "error": str(e)}))
sys.exit(1)
if __name__ == "__main__": if __name__ == "__main__":
+25 -15
View File
@@ -1,6 +1,8 @@
import { runPythonScript } from "./bridge.js"; import { runPythonScript } from "./bridge.js";
import { writeFile, readFile } from "node:fs/promises"; import { writeFile, readFile, unlink } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
export interface RemoveBackgroundOptions { export interface RemoveBackgroundOptions {
model?: string; model?: string;
@@ -12,22 +14,30 @@ export async function removeBackground(
outputDir: string, outputDir: string,
options: RemoveBackgroundOptions = {}, options: RemoveBackgroundOptions = {},
): Promise<Buffer> { ): Promise<Buffer> {
const inputPath = join(outputDir, "input_bg.png"); const id = randomUUID();
const outputPath = join(outputDir, "output_bg.png"); const inputPath = join(tmpdir(), `rembg_in_${id}.png`);
const outputPath = join(outputDir, `rembg_out_${id}.png`);
await writeFile(inputPath, inputBuffer); await writeFile(inputPath, inputBuffer);
// BiRefNet models need longer timeout (up to 10 min for first load) try {
const timeout = options.model?.startsWith("birefnet") ? 600000 : 300000; // BiRefNet models need longer timeout (up to 10 min for first load)
const { stdout } = await runPythonScript("remove_bg.py", [ const timeout = options.model?.startsWith("birefnet") ? 600000 : 300000;
inputPath, const { stdout } = await runPythonScript("remove_bg.py", [
outputPath, inputPath,
JSON.stringify(options), outputPath,
], timeout); JSON.stringify(options),
], timeout);
const result = JSON.parse(stdout); const result = JSON.parse(stdout);
if (!result.success) { if (!result.success) {
throw new Error(result.error || "Background removal failed"); throw new Error(result.error || "Background removal failed");
}
const outputBuffer = await readFile(outputPath);
return outputBuffer;
} finally {
// Clean up temp files
await unlink(inputPath).catch(() => {});
await unlink(outputPath).catch(() => {});
} }
return readFile(outputPath);
} }
+2 -2
View File
@@ -25,8 +25,8 @@ function extractPythonError(error: unknown): string {
stdout?: string; stdout?: string;
message?: string; message?: string;
}; };
// Try to parse JSON error from stderr or stdout // Try stdout first (Python scripts write JSON errors there), then stderr
for (const output of [execError.stderr, execError.stdout]) { for (const output of [execError.stdout, execError.stderr]) {
if (output) { if (output) {
try { try {
const parsed = JSON.parse(output.trim()); const parsed = JSON.parse(output.trim());
-2
View File
@@ -1,5 +1,3 @@
export const AI_VERSION = "0.0.1";
export { runPythonScript } from "./bridge.js"; export { runPythonScript } from "./bridge.js";
export { removeBackground } from "./background-removal.js"; export { removeBackground } from "./background-removal.js";
export type { RemoveBackgroundOptions } from "./background-removal.js"; export type { RemoveBackgroundOptions } from "./background-removal.js";
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@stirling-image/image-engine", "name": "@stirling-image/image-engine",
"version": "0.0.1", "version": "0.2.1",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./src/index.ts", "main": "./src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@stirling-image/shared", "name": "@stirling-image/shared",
"version": "0.0.1", "version": "0.2.1",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./src/index.ts", "main": "./src/index.ts",
+1 -1
View File
@@ -100,4 +100,4 @@ export const SUPPORTED_OUTPUT_FORMATS = [
export const DEFAULT_OUTPUT_FORMAT = "jpg" as const; export const DEFAULT_OUTPUT_FORMAT = "jpg" as const;
export const APP_VERSION = "0.1.0"; export const APP_VERSION = "0.2.1";
+2120
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Syncs the version from semantic-release to all workspace package.json files
# and the APP_VERSION constant in shared/constants.ts.
#
# Usage: ./scripts/sync-version.sh <version>
# Example: ./scripts/sync-version.sh 1.2.3
set -euo pipefail
VERSION="${1:?Usage: sync-version.sh <version>}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# All workspace package.json files to sync
PACKAGES=(
"apps/web/package.json"
"apps/api/package.json"
"apps/docs/package.json"
"packages/shared/package.json"
"packages/image-engine/package.json"
"packages/ai/package.json"
)
for pkg in "${PACKAGES[@]}"; do
FILE="$ROOT/$pkg"
if [ -f "$FILE" ]; then
# Use node to update JSON cleanly (preserves formatting better than sed)
node -e "
const fs = require('fs');
const path = '$FILE';
const raw = fs.readFileSync(path, 'utf8');
const json = JSON.parse(raw);
json.version = '$VERSION';
fs.writeFileSync(path, JSON.stringify(json, null, 2) + '\n');
"
echo " Updated $pkg -> $VERSION"
fi
done
# Update APP_VERSION in shared constants
CONSTANTS="$ROOT/packages/shared/src/constants.ts"
if [ -f "$CONSTANTS" ]; then
sed -i.bak "s/export const APP_VERSION = \".*\"/export const APP_VERSION = \"$VERSION\"/" "$CONSTANTS"
rm -f "$CONSTANTS.bak"
echo " Updated APP_VERSION -> $VERSION"
fi
echo "All versions synced to $VERSION"