Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions config/templates/audit/lhci/.github/workflows/lhci.yml.tmpl
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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" }}
Expand All @@ -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 }}
Expand All @@ -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 }}
4 changes: 2 additions & 2 deletions pkg/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
21 changes: 21 additions & 0 deletions pkg/render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down
60 changes: 34 additions & 26 deletions pkg/scaffold.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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
}
}
Expand All @@ -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
}

Expand All @@ -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
}

Expand All @@ -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
}
}
Expand All @@ -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
}
}
Expand All @@ -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
}
}
Expand All @@ -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
}
}
Expand All @@ -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
}
}
Expand All @@ -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
}
}
Expand All @@ -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
}
}
Expand All @@ -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
}
}
Expand All @@ -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
}
}
Expand All @@ -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
}
}
Expand All @@ -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
}
}
Expand All @@ -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
}

Expand All @@ -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
}
}
Expand Down Expand Up @@ -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
}

Expand All @@ -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
}

Expand All @@ -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
}
}
Expand Down Expand Up @@ -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
Expand All @@ -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() {
Expand Down