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

View file

@ -45,7 +45,7 @@ jobs:
- run: pnpm build
docker:
name: Docker Build
name: Docker Build Test
runs-on: ubuntu-latest
needs: build
steps:
@ -61,43 +61,3 @@ jobs:
tags: stirling-image:ci
cache-from: type=gha
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

View file

@ -2,7 +2,6 @@ name: Build and Push Docker Image
on:
push:
branches: [main]
tags: ["v*"]
workflow_dispatch:
@ -30,22 +29,36 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
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
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
images: |
${{ env.IMAGE_NAME }}
ghcr.io/${{ github.repository }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=sha,prefix=
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest
- name: Build and push
uses: docker/build-push-action@v5
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

40
.github/workflows/release.yml vendored Normal file
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
.releaserc.json Normal file
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"
]
}

View file

@ -1,6 +1,6 @@
{
"name": "@stirling-image/api",
"version": "0.0.1",
"version": "0.2.1",
"private": true,
"type": "module",
"scripts": {

View file

@ -401,8 +401,22 @@ export async function authMiddleware(app: FastifyInstance): Promise<void> {
app.addHook(
"preHandler",
async (request: FastifyRequest, reply: FastifyReply) => {
// Skip if auth is disabled
if (!env.AUTH_ENABLED) return;
// When auth is disabled, attach the first admin user so requireAuth/requireAdmin pass
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);

View file

@ -55,7 +55,7 @@ export function registerRemoveBackground(app: FastifyInstance) {
const resultBuffer = await removeBackground(
fileBuffer,
join(workspacePath, "output"),
{ model: settings.model },
{ model: settings.model, backgroundColor: settings.backgroundColor },
);
// Save output

View file

@ -1,6 +1,6 @@
{
"name": "@stirling-image/docs",
"version": "0.1.0",
"version": "0.2.1",
"private": true,
"scripts": {
"docs:dev": "vitepress dev .",
@ -11,4 +11,4 @@
"tsx": "^4.19.0",
"vitepress": "^1.1.4"
}
}
}

View file

@ -1,6 +1,6 @@
{
"name": "@stirling-image/web",
"version": "0.0.1",
"version": "0.2.1",
"private": true,
"type": "module",
"scripts": {

View file

@ -13,6 +13,7 @@ import { ToolPanel } from "./tool-panel";
import { Footer } from "./footer";
import { Dropzone } from "../common/dropzone";
import { SettingsDialog } from "../settings/settings-dialog";
import { HelpDialog } from "../help/help-dialog";
import { useMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
@ -24,6 +25,7 @@ interface AppLayoutProps {
export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayoutProps) {
const [settingsOpen, setSettingsOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
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">
{/* Desktop sidebar */}
{!isMobile && (
<Sidebar onSettingsClick={() => setSettingsOpen(true)} />
<Sidebar onSettingsClick={() => setSettingsOpen(true)} onHelpClick={() => setHelpOpen(true)} />
)}
{/* Mobile sidebar overlay */}
@ -59,6 +61,10 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
setMobileSidebarOpen(false);
setSettingsOpen(true);
}}
onHelpClick={() => {
setMobileSidebarOpen(false);
setHelpOpen(true);
}}
expanded
/>
</div>
@ -122,6 +128,12 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
/>
{/* Help dialog */}
<HelpDialog
open={helpOpen}
onClose={() => setHelpOpen(false)}
/>
</div>
);
}

View file

@ -25,17 +25,18 @@ const topItems: SidebarItem[] = [
];
const bottomItems: SidebarItem[] = [
{ icon: HelpCircle, label: "Help", href: "/help" },
{ icon: HelpCircle, label: "Help" },
{ icon: Settings, label: "Settings" },
];
interface SidebarProps {
onSettingsClick: () => void;
onHelpClick: () => void;
/** When true, renders in expanded mode (for mobile overlay). */
expanded?: boolean;
}
export function Sidebar({ onSettingsClick, expanded = false }: SidebarProps) {
export function Sidebar({ onSettingsClick, onHelpClick, expanded = false }: SidebarProps) {
const location = useLocation();
const renderItem = (item: SidebarItem, isActive: boolean) => {
@ -72,6 +73,13 @@ export function Sidebar({ onSettingsClick, expanded = false }: SidebarProps) {
</button>
);
}
if (item.label === "Help") {
return (
<button key={item.label} onClick={onHelpClick} className="w-full">
{content}
</button>
);
}
return (
<Link key={item.label} to={item.href || "/"}>
{content}

View file

@ -1,18 +1,31 @@
{
"name": "stirling-image",
"version": "0.2.1",
"private": true,
"packageManager": "pnpm@9.15.4",
"scripts": {
"dev": "turbo dev",
"docs:dev": "pnpm --filter @stirling-image/docs docs:dev",
"build": "turbo build",
"lint": "turbo lint",
"clean": "turbo clean",
"typecheck": "turbo typecheck",
"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": {
"@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",
"typescript": "^5.7.0"
}

View file

@ -1,6 +1,6 @@
{
"name": "@stirling-image/ai",
"version": "0.0.1",
"version": "0.2.1",
"private": true,
"type": "module",
"main": "./src/index.ts",

View file

@ -68,10 +68,8 @@ def main():
}
)
)
sys.exit(1)
except Exception as e:
print(json.dumps({"success": False, "error": str(e)}))
sys.exit(1)
if __name__ == "__main__":

View file

@ -1,6 +1,8 @@
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 { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
export interface RemoveBackgroundOptions {
model?: string;
@ -12,22 +14,30 @@ export async function removeBackground(
outputDir: string,
options: RemoveBackgroundOptions = {},
): Promise<Buffer> {
const inputPath = join(outputDir, "input_bg.png");
const outputPath = join(outputDir, "output_bg.png");
const id = randomUUID();
const inputPath = join(tmpdir(), `rembg_in_${id}.png`);
const outputPath = join(outputDir, `rembg_out_${id}.png`);
await writeFile(inputPath, inputBuffer);
// BiRefNet models need longer timeout (up to 10 min for first load)
const timeout = options.model?.startsWith("birefnet") ? 600000 : 300000;
const { stdout } = await runPythonScript("remove_bg.py", [
inputPath,
outputPath,
JSON.stringify(options),
], timeout);
try {
// BiRefNet models need longer timeout (up to 10 min for first load)
const timeout = options.model?.startsWith("birefnet") ? 600000 : 300000;
const { stdout } = await runPythonScript("remove_bg.py", [
inputPath,
outputPath,
JSON.stringify(options),
], timeout);
const result = JSON.parse(stdout);
if (!result.success) {
throw new Error(result.error || "Background removal failed");
const result = JSON.parse(stdout);
if (!result.success) {
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);
}

View file

@ -25,8 +25,8 @@ function extractPythonError(error: unknown): string {
stdout?: string;
message?: string;
};
// Try to parse JSON error from stderr or stdout
for (const output of [execError.stderr, execError.stdout]) {
// Try stdout first (Python scripts write JSON errors there), then stderr
for (const output of [execError.stdout, execError.stderr]) {
if (output) {
try {
const parsed = JSON.parse(output.trim());

View file

@ -1,5 +1,3 @@
export const AI_VERSION = "0.0.1";
export { runPythonScript } from "./bridge.js";
export { removeBackground } from "./background-removal.js";
export type { RemoveBackgroundOptions } from "./background-removal.js";

View file

@ -1,6 +1,6 @@
{
"name": "@stirling-image/image-engine",
"version": "0.0.1",
"version": "0.2.1",
"private": true,
"type": "module",
"main": "./src/index.ts",

View file

@ -1,6 +1,6 @@
{
"name": "@stirling-image/shared",
"version": "0.0.1",
"version": "0.2.1",
"private": true,
"type": "module",
"main": "./src/index.ts",

View file

@ -100,4 +100,4 @@ export const SUPPORTED_OUTPUT_FORMATS = [
export const DEFAULT_OUTPUT_FORMAT = "jpg" as const;
export const APP_VERSION = "0.1.0";
export const APP_VERSION = "0.2.1";

File diff suppressed because it is too large Load diff

47
scripts/sync-version.sh Executable file
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"