chore(ci): add GitHub CI workflow (#271)

* Add GitHub CI workflow

* Address CI workflow review feedback

Generated-By: looper 0.3.0 (runner=fixer, agent=openai/gpt-5.5)
This commit is contained in:
Marc Chan 2026-05-02 16:14:33 +08:00 committed by GitHub
parent 5a0f954297
commit a93246d892
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 218 additions and 24 deletions

87
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,87 @@
name: ci
on:
pull_request:
# Release validation is owned by the release workflows rather than this CI
# workflow: `release-stable` has a verify job before publishing, and
# `release-beta` builds from its selected release commit. Keep this trigger
# focused on PRs, main, and manual reruns instead of duplicating tag/release
# events that would run after those release workflows have already selected
# or validated their commit.
push:
branches:
- main
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
# Prefer current-head signal over preserving superseded logs: PR authors often
# push fixups while this workflow is still running, and stale runs can report
# failures for commits reviewers no longer need to evaluate. Release workflows
# use cancel-in-progress: false where preserving build evidence matters more.
cancel-in-progress: true
jobs:
validate:
name: Validate workspace
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
# `scripts/postinstall.mjs` only prebuilds package/tool entrypoints that
# are needed immediately after install for linked bins and shared
# sidecar/platform imports. It intentionally skips app outputs because
# building all apps would make every install run a Next/Electron-adjacent
# app build, even when a developer only needs packages/tools.
#
# Fresh CI typecheck/test still need these specific generated declarations:
# - `apps/daemon/dist/*.d.ts` for e2e runtime-adapter tests that import
# daemon runtime modules
# - `apps/desktop/dist/main/index.d.ts` for `apps/packaged` imports of
# `@open-design/desktop/main`
# - `apps/web/dist/sidecar/index.d.ts` for `apps/packaged` imports of
# `@open-design/web/sidecar`
# If postinstall grows a targeted app type-generation phase covering these
# three exports without broad app builds, this CI prebuild can be removed.
- name: Prebuild workspace type declarations
run: |
pnpm --filter @open-design/daemon build
pnpm --filter @open-design/desktop build
pnpm --filter @open-design/web build:sidecar
- name: Typecheck workspaces
run: pnpm -r --workspace-concurrency=1 --if-present run typecheck
- name: Check residual JS in TypeScript packages
run: pnpm check:residual-js
- name: Test
run: pnpm test
# Keep workspace builds serialized so generated dist output and local
# runtime artifacts are produced in a deterministic order. Parallel
# recursive builds would surface late-package failures sooner, but the
# current workspace is small enough that safer logs and fewer shared-FS
# races outweigh the lost parallelism; revisit if the package count grows.
- name: Build workspaces
run: pnpm -r --workspace-concurrency=1 --if-present run build

View file

@ -62,19 +62,23 @@ function attachParentMonitor(stop: () => Promise<void>): void {
}
export async function startDaemonSidecar(runtime: SidecarRuntimeContext<SidecarStamp>): Promise<DaemonSidecarHandle> {
const started = await startServer({ port: parsePort(process.env[DAEMON_PORT_ENV]), returnServer: true }) as
| string
| { server: Server; url: string };
if (typeof started === "string") {
const serverHandle = await startServer({ port: parsePort(process.env[DAEMON_PORT_ENV]), returnServer: true }) as
| Server
| undefined;
if (serverHandle == null) {
throw new Error("daemon startServer did not return a server handle");
}
const serverHandle = started;
const server = serverHandle;
const address = server.address();
if (address == null || typeof address === "string") {
throw new Error("daemon startServer did not bind to a TCP port");
}
const state: DaemonStatusSnapshot = {
pid: process.pid,
state: "running",
updatedAt: new Date().toISOString(),
url: serverHandle.url,
url: `http://127.0.0.1:${address.port}`,
};
let ipcServer: JsonIpcServerHandle | null = null;
let stopped = false;
@ -89,7 +93,7 @@ export async function startDaemonSidecar(runtime: SidecarRuntimeContext<SidecarS
state.state = "stopped";
state.updatedAt = new Date().toISOString();
await ipcServer?.close().catch(() => undefined);
await closeHttpServer(serverHandle.server).catch(() => undefined);
await closeHttpServer(server).catch(() => undefined);
resolveStopped();
}

View file

@ -7,12 +7,16 @@ describe('/api/version', () => {
let baseUrl: string;
beforeAll(async () => {
const started = await startServer({ port: 0, returnServer: true }) as {
url: string;
server: http.Server;
};
baseUrl = started.url;
server = started.server;
const started = await startServer({ port: 0, returnServer: true }) as http.Server | undefined;
if (started == null) {
throw new Error('startServer did not return a server handle');
}
const address = started.address();
if (address == null || typeof address === 'string') {
throw new Error('startServer did not bind to a TCP port');
}
server = started;
baseUrl = `http://127.0.0.1:${address.port}`;
});
afterAll(() => new Promise<void>((resolve) => server.close(() => resolve())));

View file

@ -28,6 +28,7 @@ const DE_SKILL_COPY: Record<string, { description?: string; examplePrompt?: stri
examplePrompt:
'Entwerfen Sie mutuals — eine Dating-Site für X-Poster. Tägliches Digest-Dashboard mit Statistiken, Balkendiagramm für gegenseitige Matches und Community-Ticker.',
},
'design-brief': {},
'digital-eguide': {
examplePrompt:
'Entwerfen Sie The Creator\'s Style & Format Guide — Coverseite und eine Innenseite für eine Lifestyle-Creator-Brand.',
@ -60,6 +61,22 @@ const DE_SKILL_COPY: Record<string, { description?: string; examplePrompt?: stri
examplePrompt:
'Erstellen Sie einen 30-Tage-Onboardingplan für einen neuen Product Designer in einem 40-Personen-Startup.',
},
'html-ppt': {},
'html-ppt-course-module': {},
'html-ppt-dir-key-nav-minimal': {},
'html-ppt-graphify-dark-graph': {},
'html-ppt-hermes-cyber-terminal': {},
'html-ppt-knowledge-arch-blueprint': {},
'html-ppt-obsidian-claude-gradient': {},
'html-ppt-pitch-deck': {},
'html-ppt-presenter-mode': {},
'html-ppt-product-launch': {},
'html-ppt-tech-sharing': {},
'html-ppt-testing-safety-alert': {},
'html-ppt-weekly-report': {},
'html-ppt-xhs-pastel-card': {},
'html-ppt-xhs-post': {},
'html-ppt-xhs-white-editorial': {},
'hyperframes': {
examplePrompt:
'Ein 5-Sekunden-Product-Reveal: ein minimalistisches High-End-Produkt auf einer sauberen cremefarbenen Fläche, weiches Seitenlicht, langsamer Kamera-Push-in, zurückhaltende Bewegung, keine Text-Overlays.',
@ -337,6 +354,7 @@ const DE_PROMPT_TEMPLATE_CATEGORIES: Record<string, string> = {
'Social / Meme': 'Social / Meme',
Branding: 'Branding',
Data: 'Daten',
'Game UI': 'Game UI',
Marketing: 'Marketing',
Product: 'Produkt',
'Short Form': 'Short Form',
@ -350,49 +368,89 @@ const DE_PROMPT_TEMPLATE_TAGS: Record<string, string> = {
anime: 'Anime',
'app-showcase': 'App-Showcase',
'audio-reactive': 'Audio-reaktiv',
'ancient-china': 'Ancient China',
archery: 'Archery',
arpg: 'ARPG',
'boss-fight': 'Boss Fight',
brand: 'Brand',
branding: 'Branding',
captions: 'Untertitel',
cavalry: 'Cavalry',
chart: 'Chart',
choreography: 'Choreography',
cinematic: 'Filmisch',
'cinematic-romance': 'Filmische Romanze',
combat: 'Combat',
combo: 'Combo',
'companion-to-image': 'Companion to Image',
counter: 'Counter',
cyberpunk: 'Cyberpunk',
dance: 'Dance',
'data-viz': 'Data-Viz',
editorial: 'Editorial',
'elden-ring': 'Elden Ring',
endcard: 'End Card',
escort: 'Escort',
'escort-mission': 'Escort Mission',
fantasy: 'Fantasy',
fashion: 'Fashion',
'fighting-game': 'Fighting Game',
food: 'Food',
'game-cinematic': 'Game Cinematic',
'game-ui': 'Game UI',
'grid-sheet': 'Grid Sheet',
guanyu: 'Guanyu',
hud: 'HUD',
'hud-safe': 'HUD Safe',
hype: 'Hype',
hyperframes: 'HyperFrames',
idol: 'Idol',
infographic: 'Infografik',
japanese: 'Japanese',
karaoke: 'Karaoke',
'key-visual': 'Key Visual',
'kinetic-typography': 'Kinetische Typografie',
'linear-style': 'Linear-Stil',
logo: 'Logo',
lyubu: 'Lyu Bu',
map: 'Karte',
marketing: 'Marketing',
minimal: 'Minimal',
mmo: 'MMO',
mobile: 'Mobile',
money: 'Geld',
'mounted-combat': 'Mounted Combat',
nature: 'Natur',
'open-world': 'Open World',
'otaku-dance': 'Otaku Dance',
outro: 'Outro',
overlay: 'Overlay',
pipeline: 'Pipeline',
'pose-reference': 'Pose Reference',
portrait: 'Porträt',
product: 'Produkt',
'product-promo': 'Produkt-Promo',
route: 'Route',
saas: 'SaaS',
sequence: 'Sequence',
sizzle: 'Sizzle',
social: 'Social',
storyboard: 'Storyboard',
'street-fighter': 'Street Fighter',
tekken: 'Tekken',
'three-kingdoms': 'Three Kingdoms',
tiktok: 'TikTok',
'title-card': 'Title Card',
travel: 'Reise',
tts: 'TTS',
typography: 'Typografie',
'unreal-engine-5': 'Unreal Engine 5',
vertical: 'Vertikal',
'video-reference': 'Video Reference',
'vs-screen': 'VS Screen',
'website-to-video': 'Website-zu-Video',
wuxia: 'Wuxia',
zhaoyun: 'Zhaoyun',
};
const DE_PROMPT_TEMPLATE_COPY: Record<string, Partial<Pick<PromptTemplateSummary, 'summary' | 'title'>>> = {
@ -416,6 +474,7 @@ const DE_PROMPT_TEMPLATE_COPY: Record<string, Partial<Pick<PromptTemplateSummary
summary:
'Erzeugt eine handgezeichnete Tourist Map im Aquarellstil mit nummerierten lokalen Spezialitäten, Sehenswürdigkeiten und Legende.',
},
'infographic-otaku-dance-choreography-breakdown-gokurakujodo-16-panels': {},
'momotaro-explainer-slide-in-hybrid-style': {
title: 'Momotaro-Erklärslide im Hybrid-Stil',
summary:
@ -556,6 +615,7 @@ const DE_PROMPT_TEMPLATE_COPY: Record<string, Partial<Pick<PromptTemplateSummary
summary:
'Warme Editorial-Seite zu einem japanischen Feiertag mit Anime-Charakterkunst, nostalgischer Showa-Straßenszene und Magazinlayout.',
},
'social-media-post-sensational-girl-dance-storyboard-8-shots': {},
'social-media-post-social-media-fashion-outfit-generation': {
title: 'Social-Media-Post - Fashion-Outfit-Generierung',
summary:
@ -671,6 +731,11 @@ const DE_PROMPT_TEMPLATE_COPY: Record<string, Partial<Pick<PromptTemplateSummary
summary:
'Komplexer Dark-Comedy-Prompt für Seedance 2.0 mit einem orangefarbenen Katzenbeamten und einem Hyänenkaiser in einer satirischen Qing-Dynastie-Szene.',
},
'game-screenshot-anime-fighting-game-captain-ryuuga-vs-kaze-renshin': {},
'game-screenshot-three-kingdoms-guanyu-slaying-yanliang': {},
'game-screenshot-three-kingdoms-lyubu-yuanmen-archery': {},
'game-screenshot-three-kingdoms-zhaoyun-cradle-escape': {},
'game-ui-ancient-china-open-world-mmo-hud': {},
'hollywood-haute-couture-fantasy-video-prompt': {
title: 'Hollywood-Haute-Couture-Fantasy-Video-Prompt',
summary:
@ -796,6 +861,9 @@ const DE_PROMPT_TEMPLATE_COPY: Record<string, Partial<Pick<PromptTemplateSummary
summary:
'Umfassender Seedance-2.0-Video-Prompt für einen anmutigen traditionellen Tanz auf Basis von Choreografie- und Identitätsreferenzbildern.',
},
'video-seedance-three-kingdoms-guanyu-slaying-yanliang': {},
'video-seedance-three-kingdoms-lyubu-yuanmen-archery': {},
'video-seedance-three-kingdoms-zhaoyun-cradle-escape': {},
'vintage-disney-style-pirate-crocodile-animation': {
title: 'Piraten-Krokodil-Animation im Vintage-Disney-Stil',
summary:

View file

@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import { en } from './locales/en';
import { LOCALES, LOCALE_LABEL, type Dict, type Locale } from './types';
const EXPECTED_LOCALES = ['en', 'de', 'zh-CN', 'zh-TW', 'pt-BR', 'es-ES', 'ru', 'fa', 'ja'];
const EXPECTED_LOCALES = ['en', 'de', 'zh-CN', 'zh-TW', 'pt-BR', 'es-ES', 'ru', 'fa', 'ja', 'ko'];
function placeholders(value: string): string[] {
const names: string[] = [];

View file

@ -471,6 +471,7 @@ export const de: Dict = {
'fileViewer.download': 'Herunterladen',
'fileViewer.open': 'Öffnen',
'fileViewer.imageMeta': 'Bild · {size}',
'fileViewer.reactMeta': 'React-Komponente · {size}',
'fileViewer.sketchMeta': 'Sketch · {size}',
'fileViewer.markdownStreamingMeta': 'Streaming-Vorschau…',
'fileViewer.markdownErrorMeta': 'Vorschau ist möglicherweise unvollständig (Generierungsfehler).',
@ -511,6 +512,8 @@ export const de: Dict = {
'fileViewer.exportPptxNa': 'PPTX-Export ist hier nicht verfügbar.',
'fileViewer.exportZip': 'Als .zip herunterladen',
'fileViewer.exportHtml': 'Als eigenständiges HTML exportieren',
'fileViewer.exportJsx': 'Als JSX exportieren',
'fileViewer.exportReactHtml': 'Vorschau als HTML exportieren',
'fileViewer.saveAsTemplate': 'Als Template speichern…',
'fileViewer.savingTemplate': 'Template wird gespeichert…',
'fileViewer.savedTemplate': 'Als „{name}“ gespeichert',

View file

@ -472,6 +472,7 @@ export const esES: Dict = {
'fileViewer.download': 'Descargar',
'fileViewer.open': 'Abrir',
'fileViewer.imageMeta': 'Imagen · {size}',
'fileViewer.reactMeta': 'Componente React · {size}',
'fileViewer.sketchMeta': 'Boceto · {size}',
'fileViewer.markdownStreamingMeta': 'Vista previa en streaming…',
'fileViewer.markdownErrorMeta': 'La vista previa puede estar incompleta (error de generación).',
@ -512,6 +513,8 @@ export const esES: Dict = {
'fileViewer.exportPptxNa': 'La exportación a PPTX no está disponible aquí.',
'fileViewer.exportZip': 'Descargar como .zip',
'fileViewer.exportHtml': 'Exportar como HTML independiente',
'fileViewer.exportJsx': 'Exportar como JSX',
'fileViewer.exportReactHtml': 'Exportar vista previa como HTML',
'fileViewer.saveAsTemplate': 'Guardar como plantilla…',
'fileViewer.savingTemplate': 'Guardando plantilla…',
'fileViewer.savedTemplate': 'Guardado como «{name}»',

View file

@ -470,6 +470,7 @@ export const ja: Dict = {
'fileViewer.download': 'ダウンロード',
'fileViewer.open': '開く',
'fileViewer.imageMeta': '画像 · {size}',
'fileViewer.reactMeta': 'React コンポーネント · {size}',
'fileViewer.sketchMeta': 'スケッチ · {size}',
'fileViewer.markdownStreamingMeta': 'ストリーミングプレビュー中…',
'fileViewer.markdownErrorMeta': 'プレビューが不完全な場合があります(生成エラー)。',
@ -510,6 +511,8 @@ export const ja: Dict = {
'fileViewer.exportPptxNa': 'ここでは PPTX エクスポートは利用できません。',
'fileViewer.exportZip': '.zip としてダウンロード',
'fileViewer.exportHtml': 'スタンドアロン HTML としてエクスポート',
'fileViewer.exportJsx': 'JSX としてエクスポート',
'fileViewer.exportReactHtml': 'プレビューを HTML としてエクスポート',
'fileViewer.saveAsTemplate': 'テンプレートとして保存…',
'fileViewer.savingTemplate': 'テンプレートを保存中…',
'fileViewer.savedTemplate': '"{name}" として保存しました',

View file

@ -72,6 +72,9 @@ export const ko: Dict = {
'settings.show': '표시',
'settings.hide': '숨기기',
'settings.model': '모델',
'settings.maxTokens': '최대 토큰 수 (선택 사항)',
'settings.maxTokensHint':
'응답 길이 상한입니다. 각 모델에는 기본값이 미리 조정되어 있으며(placeholder로 표시됨), 비워 두면 그 값을 사용하고 숫자를 입력하면 덮어씁니다.',
'settings.baseUrl': 'Base URL',
'settings.apiHint':
'이 브라우저에서 설정한 Base URL로 직접 호출됩니다. 프록시는 사용되지 않으며, 키는 localStorage에만 보관됩니다.',

View file

@ -139,6 +139,7 @@ export const tr: Dict = {
'promptTemplates.openSource': 'Orijinali görüntüle',
'promptTemplates.openFullscreen': 'Tam ekran ön izlemeyi aç',
'promptTemplates.closeFullscreen': 'Tam ekran ön izlemeyi kapat',
'promptTemplates.retry': 'Yeniden dene',
'newproj.tabPrototype': 'Prototip',
'newproj.tabDeck': 'Slayt gösterisi',
@ -209,6 +210,16 @@ export const tr: Dict = {
'newproj.audioDurationSeconds': '{n}s',
'newproj.voiceLabel': 'Ses',
'newproj.voicePlaceholder': 'Sağlayıcı ses kimliği, opsiyonel',
'newproj.promptTemplateLabel': 'Referans şablon',
'newproj.promptTemplateNoneTitle': 'Hiçbiri — kendin yaz',
'newproj.promptTemplateNoneSub': 'Galeriyi atla, kendi briefini açıkla',
'newproj.promptTemplateRefSub': 'Referans şablon',
'newproj.promptTemplateSearch': 'Şablon ara…',
'newproj.promptTemplateEmpty': 'Bu yüzey için henüz şablon yüklenmedi.',
'newproj.promptTemplateBodyLabel': 'İstem (istediğin gibi düzenleyebilirsin)',
'newproj.promptTemplateOptimizeHint':
'İstediğin her şeyi düzenle — değişikliklerin ajan briefine taşınır.',
'newproj.promptTemplateBodyEmpty': 'Gövde boş — ajana şablon referansı gitmeyecek.',
'designs.subRecent': 'Yakında',
'designs.subYours': 'Tasarımların',
@ -386,7 +397,7 @@ export const tr: Dict = {
'workspace.deleteFileConfirm': '"{name}"ı proje klasöründen sil?',
'workspace.openFromDesignFiles': 'bir dosya aç',
'workspace.designFilesLink': 'Tasarım Dosyaları',
'workspace.loadingSketch': 'Taslak yükleniyor…',',
'workspace.loadingSketch': 'Taslak yükleniyor…',
'designFiles.title': 'Tasarım Dosyaları',
'designFiles.upload': 'Dosyaları yükle',
'designFiles.pasteText': 'Metin dosyası olarak yapıştır',
@ -459,6 +470,7 @@ export const tr: Dict = {
'fileViewer.download': 'İndir',
'fileViewer.open': 'Aç',
'fileViewer.imageMeta': 'Görsel · {size}',
'fileViewer.reactMeta': 'React bileşeni · {size}',
'fileViewer.sketchMeta': 'Taslak · {size}',
'fileViewer.markdownStreamingMeta': 'Önizleme yayınlanıyor…',
'fileViewer.markdownErrorMeta': 'Önizleme eksik olabilir (oluşturma hatası).',
@ -499,6 +511,8 @@ export const tr: Dict = {
'fileViewer.exportPptxNa': 'PPTX dışa aktarma burada mevcut değil.',
'fileViewer.exportZip': 'ZIP olarak indir',
'fileViewer.exportHtml': 'Tekil HTML olarak dışa aktar',
'fileViewer.exportJsx': 'JSX olarak dışa aktar',
'fileViewer.exportReactHtml': 'Önizlemeyi HTML olarak dışa aktar',
'fileViewer.saveAsTemplate': 'Şablon olarak kaydet…',
'fileViewer.savingTemplate': 'Şablon kaydediliyor…',
'fileViewer.savedTemplate': '"{name}" olarak kaydedildi',

View file

@ -91,6 +91,8 @@ export interface Dict {
'settings.show': string;
'settings.hide': string;
'settings.model': string;
'settings.maxTokens': string;
'settings.maxTokensHint': string;
'settings.baseUrl': string;
'settings.apiHint': string;
'settings.skipForNow': string;

View file

@ -1,3 +1,4 @@
import type http from 'node:http';
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
@ -19,17 +20,12 @@ interface AgentsResponse {
agents: AgentInfo[];
}
interface StartServerResult {
url: string;
server: { close: (callback: (err?: Error) => void) => void };
}
interface ParsedSseEvent {
event: string;
data: Record<string, unknown>;
}
type StartServer = (options: { port: number; returnServer: true }) => Promise<StartServerResult>;
type StartServer = (options: { port: number; returnServer: true }) => Promise<http.Server | undefined>;
type CloseDatabase = () => void;
const liveTimeoutMs = Number(process.env.OD_RUNTIME_LIVE_TIMEOUT_MS || 180_000);
@ -38,7 +34,7 @@ const maxRuntimeCount = 8;
const marker = 'OD_RUNTIME_ADAPTER_LIVE_OK';
let baseUrl: string;
let server: StartServerResult['server'] | undefined;
let server: http.Server | undefined;
let startServer: StartServer;
let closeDatabase: CloseDatabase | undefined;
let detectedAgents: AgentInfo[] | undefined;
@ -50,8 +46,15 @@ test.before(async () => {
({ startServer } = await import('../../apps/daemon/dist/server.js') as { startServer: StartServer });
({ closeDatabase } = await import('../../apps/daemon/dist/db.js') as { closeDatabase: CloseDatabase });
const started = await startServer({ port: 0, returnServer: true });
baseUrl = started.url;
server = started.server;
if (started == null) {
throw new Error('startServer did not return a server handle');
}
const address = started.address();
if (address == null || typeof address === 'string') {
throw new Error('startServer did not bind to a TCP port');
}
server = started;
baseUrl = `http://127.0.0.1:${address.port}`;
});
test.after(async () => {