From 4e26d58fd946170e2c79e3cdfdbe1c37b2dfe0d7 Mon Sep 17 00:00:00 2001 From: Spencer Date: Wed, 12 Aug 2026 22:57:31 +0800 Subject: [PATCH] fix: emit the monorepo LHCI workflow at the workspace root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit templates were copied into apps/web, so the Lighthouse workflow landed at apps/web/.github/workflows/ where GitHub never runs it. copyDir gains a ghRoot redirect (shared with add's git-root relocation): the audit copy now sends .github/** to the workspace root while lighthouserc.json stays in apps/web. The workflow itself is monorepo-aware — lhci runs with working-directory apps/web, the affected-pages diff uses apps/web-prefixed paths, and a paths filter skips apps/api-only PRs entirely. Closes #115 Co-Authored-By: Claude Fable 5 --- .../lhci/.github/workflows/lhci.yml.tmpl | 22 +++++-- pkg/add.go | 4 +- pkg/render_test.go | 21 +++++++ pkg/scaffold.go | 60 +++++++++++-------- 4 files changed, 73 insertions(+), 34 deletions(-) diff --git a/config/templates/audit/lhci/.github/workflows/lhci.yml.tmpl b/config/templates/audit/lhci/.github/workflows/lhci.yml.tmpl index 046172e..add722f 100644 --- a/config/templates/audit/lhci/.github/workflows/lhci.yml.tmpl +++ b/config/templates/audit/lhci/.github/workflows/lhci.yml.tmpl @@ -1,4 +1,5 @@ -{{- $urls := "" }}{{- if not .Base.IsVite }}{{- $urls = " ${{ steps.affected.outputs.urls }}" }}{{- end -}} +{{- $urls := "" }}{{- if not .Base.IsVite }}{{- $urls = " ${{ steps.affected.outputs.urls }}" }}{{- end }} +{{- $wd := "" }}{{- if .Layout.IsMonorepo }}{{- $wd = "\n working-directory: apps/web" }}{{- end -}} name: Lighthouse CI # Pull requests only. GitHub runs this against the merge result rather than the branch @@ -10,10 +11,19 @@ name: Lighthouse CI on: pull_request: branches: [main] +{{- if .Layout.IsMonorepo }} + # An apps/api-only PR can't change frontend scores — skip the audit. + paths: + - 'apps/web/**' + - 'packages/**' + - 'pnpm-lock.yaml' + - 'package.json' +{{- else }} paths-ignore: - '**/*.md' - 'LICENSE' - '.github/**' +{{- end }} workflow_dispatch: # Only the newest tree is worth measuring. Without this, several pushes in quick @@ -40,7 +50,7 @@ jobs: env: BASE_SHA: {{ "${{ github.event.pull_request.base.sha }}" }} run: | - pages_dir="{{ if .Base.IsNuxt }}app/pages{{ else }}src/pages{{ end }}" + pages_dir="{{ if .Layout.IsMonorepo }}apps/web/{{ end }}{{ if .Base.IsNuxt }}app/pages{{ else }}src/pages{{ end }}" git fetch --no-tags --depth=1 origin "$BASE_SHA" urls="" while IFS= read -r f; do @@ -66,14 +76,14 @@ jobs: cache: pnpm - run: pnpm install - run: pnpm run build - - run: pnpx lhci autorun{{ $urls }} + - run: pnpx lhci autorun{{ $urls }}{{ $wd }} env: LHCI_GITHUB_APP_TOKEN: {{ "${{ secrets.LHCI_GITHUB_APP_TOKEN }}" }} {{- else if eq .PM "bun" }} - uses: oven-sh/setup-bun@v2 - run: bun install - run: bun run build - - run: bunx lhci autorun{{ $urls }} + - run: bunx lhci autorun{{ $urls }}{{ $wd }} env: LHCI_GITHUB_APP_TOKEN: {{ "${{ secrets.LHCI_GITHUB_APP_TOKEN }}" }} {{- else if eq .PM "yarn" }} @@ -83,7 +93,7 @@ jobs: cache: yarn - run: yarn install - run: yarn build - - run: yarn dlx @lhci/cli@0.15.x autorun{{ $urls }} + - run: yarn dlx @lhci/cli@0.15.x autorun{{ $urls }}{{ $wd }} env: LHCI_GITHUB_APP_TOKEN: {{ "${{ secrets.LHCI_GITHUB_APP_TOKEN }}" }} {{- else }} @@ -93,7 +103,7 @@ jobs: cache: npm - run: npm install - run: npm run build - - run: npx --package=@lhci/cli@0.15.x lhci autorun{{ $urls }} + - run: npx --package=@lhci/cli@0.15.x lhci autorun{{ $urls }}{{ $wd }} env: LHCI_GITHUB_APP_TOKEN: {{ "${{ secrets.LHCI_GITHUB_APP_TOKEN }}" }} {{- end }} diff --git a/pkg/add.go b/pkg/add.go index 3d21d91..f17ce6a 100644 --- a/pkg/add.go +++ b/pkg/add.go @@ -349,14 +349,14 @@ func Add(dir string, templates fs.FS, cfg ProjectConfig, opt string) (*AddReport if err != nil { return nil, fmt.Errorf("failed to read %s templates: %w", cat.name, err) } - if err := copyDir(sub, dir, cfg, "", rep); err != nil { + if err := copyDir(sub, dir, cfg, "", rep, rep.GitRoot); err != nil { return nil, err } } // Playwright ships the agent MCP config too, same as create. if cfg.Test == "playwright" { if sub, err := fs.Sub(templates, "templates/agent/mcp"); err == nil { - if err := copyDir(sub, dir, cfg, "", rep); err != nil { + if err := copyDir(sub, dir, cfg, "", rep, rep.GitRoot); err != nil { return nil, err } } diff --git a/pkg/render_test.go b/pkg/render_test.go index 3487a11..0febc2c 100644 --- a/pkg/render_test.go +++ b/pkg/render_test.go @@ -73,6 +73,12 @@ func TestScaffoldRenders(t *testing.T) { c.PM, c.Layout, c.Desktop = "pnpm", LayoutMonorepo, "tauri" return c } + lhciMonorepo := func() ProjectConfig { + c := NewProjectConfig() + c.Base, c.Backend, c.ORM, c.Database = "astro-react", "hono", "drizzle", "sqlite" + c.PM, c.Layout, c.Audit = "pnpm", LayoutMonorepo, "lhci" + return c + } cases := []struct { name string @@ -159,6 +165,21 @@ func TestScaffoldRenders(t *testing.T) { "nuxt.config.ts": {"ssr: false"}, }, }, + { + // #115: the workflow must land at the workspace root (GitHub reads + // nothing else), run lhci inside apps/web, and diff apps/web paths. + name: "lhci_monorepo", + cfg: lhciMonorepo(), + present: []string{".github/workflows/lhci.yml", "apps/web/lighthouserc.json"}, + absent: []string{"apps/web/.github"}, + contains: map[string][]string{ + ".github/workflows/lhci.yml": { + "working-directory: apps/web", + `pages_dir="apps/web/src/pages"`, + "- 'apps/web/**'", + }, + }, + }, { name: "tauri_monorepo", cfg: tauriMonorepo(), diff --git a/pkg/scaffold.go b/pkg/scaffold.go index 0650eb5..b51e70f 100644 --- a/pkg/scaffold.go +++ b/pkg/scaffold.go @@ -58,7 +58,7 @@ func Scaffold(destDir string, templates fs.FS, cfg ProjectConfig) error { if entry.Group == "vite" && entry.EntryPoint != "" { skip = "main.ts" } - if err := copyDir(baseFS, webDir, cfg, skip, nil); err != nil { + if err := copyDir(baseFS, webDir, cfg, skip, nil, ""); err != nil { return err } @@ -70,7 +70,7 @@ func Scaffold(destDir string, templates fs.FS, cfg ProjectConfig) error { if err != nil { return fmt.Errorf("failed to read %v integration templates: %w", entry.Integration, err) } - if err := copyDir(integrationFS, webDir, cfg, "", nil); err != nil { + if err := copyDir(integrationFS, webDir, cfg, "", nil, ""); err != nil { return err } } @@ -83,7 +83,7 @@ func Scaffold(destDir string, templates fs.FS, cfg ProjectConfig) error { } stylesDir := filepath.Join(webDir, entry.StylesDir) - if err := copyDir(cssFS, stylesDir, cfg, "", nil); err != nil { + if err := copyDir(cssFS, stylesDir, cfg, "", nil, ""); err != nil { return err } @@ -94,7 +94,7 @@ func Scaffold(destDir string, templates fs.FS, cfg ProjectConfig) error { return fmt.Errorf("failed to read formatter templates: %w", err) } - if err := copyDir(fmtFS, webDir, cfg, "", nil); err != nil { + if err := copyDir(fmtFS, webDir, cfg, "", nil, ""); err != nil { return err } @@ -105,7 +105,7 @@ func Scaffold(destDir string, templates fs.FS, cfg ProjectConfig) error { if err != nil { return fmt.Errorf("failed to read linter templates: %w", err) } - if err := copyDir(linterFS, webDir, cfg, "", nil); err != nil { + if err := copyDir(linterFS, webDir, cfg, "", nil, ""); err != nil { return err } } @@ -117,7 +117,7 @@ func Scaffold(destDir string, templates fs.FS, cfg ProjectConfig) error { if err != nil { return fmt.Errorf("failed to read package manager templates: %w", err) } - if err := copyDir(pmFS, destDir, cfg, "", nil); err != nil { + if err := copyDir(pmFS, destDir, cfg, "", nil, ""); err != nil { return err } } @@ -129,7 +129,7 @@ func Scaffold(destDir string, templates fs.FS, cfg ProjectConfig) error { if err != nil { return fmt.Errorf("failed to read test templates: %w", err) } - if err := copyDir(testFS, webDir, cfg, "", nil); err != nil { + if err := copyDir(testFS, webDir, cfg, "", nil, ""); err != nil { return err } } @@ -142,7 +142,14 @@ func Scaffold(destDir string, templates fs.FS, cfg ProjectConfig) error { if err != nil { return fmt.Errorf("failed to read audit templates: %w", err) } - if err := copyDir(auditFS, webDir, cfg, "", nil); err != nil { + // In a monorepo the audit config (lighthouserc.json) belongs to + // apps/web, but its workflow must live at the workspace root or + // GitHub never runs it (#115). + ghRoot := "" + if cfg.Layout.IsMonorepo() { + ghRoot = destDir + } + if err := copyDir(auditFS, webDir, cfg, "", nil, ghRoot); err != nil { return err } } @@ -163,7 +170,7 @@ func Scaffold(destDir string, templates fs.FS, cfg ProjectConfig) error { if err != nil { return fmt.Errorf("failed to read CMS templates: %w", err) } - if err := copyDir(cmsFS, webDir, cfg, "", nil); err != nil { + if err := copyDir(cmsFS, webDir, cfg, "", nil, ""); err != nil { return err } } @@ -175,7 +182,7 @@ func Scaffold(destDir string, templates fs.FS, cfg ProjectConfig) error { if err != nil { return fmt.Errorf("failed to read deployment templates: %w", err) } - if err := copyDir(deployFS, webDir, cfg, "", nil); err != nil { + if err := copyDir(deployFS, webDir, cfg, "", nil, ""); err != nil { return err } } @@ -188,7 +195,7 @@ func Scaffold(destDir string, templates fs.FS, cfg ProjectConfig) error { if err != nil { return fmt.Errorf("failed to read cicd templates: %w", err) } - if err := copyDir(cicdFS, webDir, cfg, "", nil); err != nil { + if err := copyDir(cicdFS, webDir, cfg, "", nil, ""); err != nil { return err } } @@ -201,7 +208,7 @@ func Scaffold(destDir string, templates fs.FS, cfg ProjectConfig) error { if err != nil { return fmt.Errorf("failed to read desktop templates: %w", err) } - if err := copyDir(desktopFS, webDir, cfg, "", nil); err != nil { + if err := copyDir(desktopFS, webDir, cfg, "", nil, ""); err != nil { return err } } @@ -214,7 +221,7 @@ func Scaffold(destDir string, templates fs.FS, cfg ProjectConfig) error { if err != nil { return fmt.Errorf("failed to read backend templates: %w", err) } - if err := copyDir(backendFS, apiDir, cfg, "", nil); err != nil { + if err := copyDir(backendFS, apiDir, cfg, "", nil, ""); err != nil { return err } } @@ -227,7 +234,7 @@ func Scaffold(destDir string, templates fs.FS, cfg ProjectConfig) error { if err != nil { return fmt.Errorf("failed to read orm templates: %w", err) } - if err := copyDir(ormFS, apiDir, cfg, "", nil); err != nil { + if err := copyDir(ormFS, apiDir, cfg, "", nil, ""); err != nil { return err } } @@ -243,7 +250,7 @@ func Scaffold(destDir string, templates fs.FS, cfg ProjectConfig) error { if err != nil { return fmt.Errorf("failed to read database templates: %w", err) } - if err := copyDir(dbFS, destDir, cfg, "", nil); err != nil { + if err := copyDir(dbFS, destDir, cfg, "", nil, ""); err != nil { return err } } @@ -254,7 +261,7 @@ func Scaffold(destDir string, templates fs.FS, cfg ProjectConfig) error { if err != nil { return fmt.Errorf("failed to read shared templates: %w", err) } - if err := copyDir(sharedFS, destDir, cfg, "", nil); err != nil { + if err := copyDir(sharedFS, destDir, cfg, "", nil, ""); err != nil { return err } @@ -265,7 +272,7 @@ func Scaffold(destDir string, templates fs.FS, cfg ProjectConfig) error { if err != nil { return fmt.Errorf("failed to read mcp templates: %w", err) } - if err := copyDir(mcpFS, destDir, cfg, "", nil); err != nil { + if err := copyDir(mcpFS, destDir, cfg, "", nil, ""); err != nil { return err } } @@ -294,7 +301,7 @@ func scaffoldMonorepo(destDir, apiDir string, templates fs.FS, cfg ProjectConfig if err != nil { return fmt.Errorf("failed to read monorepo root templates: %w", err) } - if err := copyDir(rootFS, destDir, cfg, "", nil); err != nil { + if err := copyDir(rootFS, destDir, cfg, "", nil, ""); err != nil { return err } @@ -314,7 +321,7 @@ func scaffoldMonorepo(destDir, apiDir string, templates fs.FS, cfg ProjectConfig if err != nil { return fmt.Errorf("failed to read domain templates: %w", err) } - if err := copyDir(domainFS, domainDir, cfg, "", nil); err != nil { + if err := copyDir(domainFS, domainDir, cfg, "", nil, ""); err != nil { return err } @@ -334,7 +341,7 @@ func scaffoldMonorepo(destDir, apiDir string, templates fs.FS, cfg ProjectConfig if err != nil { return fmt.Errorf("failed to read api templates: %w", err) } - if err := copyDir(apiFS, apiDir, cfg, "", nil); err != nil { + if err := copyDir(apiFS, apiDir, cfg, "", nil, ""); err != nil { return err } } @@ -380,8 +387,11 @@ func PostScaffold(destDir string, cfg ProjectConfig) error { // copyDir renders/copies srcFS into destDir. A non-nil rep switches to `add` // semantics: existing files are never overwritten, and every write or skip is -// recorded in the report. -func copyDir(srcFS fs.FS, destDir string, cfg ProjectConfig, skip string, rep *AddReport) error { +// recorded in the report. A non-empty ghRoot redirects .github/** there — +// GitHub only reads workflows from the repo root's .github/, so templates +// rendered into a subdirectory (monorepo apps/web, `add` in a workspace app) +// must emit their workflows at the root instead. +func copyDir(srcFS fs.FS, destDir string, cfg ProjectConfig, skip string, rep *AddReport, ghRoot string) error { return fs.WalkDir(srcFS, ".", func(path string, d fs.DirEntry, err error) error { if err != nil { return err @@ -406,10 +416,8 @@ func copyDir(srcFS fs.FS, destDir string, cfg ProjectConfig, skip string, rep *A } destPath := filepath.Join(destDir, path) - // The `add` path writes .github/** at the git root — GitHub only reads - // workflows from the repo root's .github/, not a subdirectory's. - if rep != nil && rep.GitRoot != "" && strings.HasPrefix(filepath.ToSlash(path)+"/", ".github/") { - destPath = filepath.Join(rep.GitRoot, path) + if ghRoot != "" && strings.HasPrefix(filepath.ToSlash(path)+"/", ".github/") { + destPath = filepath.Join(ghRoot, path) } if d.IsDir() {