diff --git a/.cursor/rules/codebase-context.mdc b/.cursor/rules/codebase-context.mdc new file mode 100644 index 000000000..9b1ef995d --- /dev/null +++ b/.cursor/rules/codebase-context.mdc @@ -0,0 +1,191 @@ +--- +description: Comprehensive Homarr codebase architecture and conventions reference +alwaysApply: true +--- + +# Homarr Codebase Context + +Homarr is an open-source, self-hosted dashboard for managing homelab services. It integrates with 50+ self-hosted apps (media servers, download clients, DNS, NAS, etc.) and provides a drag-and-drop widget-based UI. + +## Tech Stack + +- **Monorepo**: pnpm workspaces + Turborepo +- **Framework**: Next.js (App Router, `output: "standalone"`) — T3 Stack +- **Language**: TypeScript throughout +- **ORM**: Drizzle (supports SQLite via better-sqlite3, MySQL, PostgreSQL) +- **API**: tRPC (HTTP + WebSocket subscriptions) with superjson + OpenAPI bridge via `trpc-to-openapi` +- **Auth**: NextAuth v5 (database sessions, Credentials/LDAP/OIDC providers) +- **UI**: Mantine v7 (not Tailwind) + Tabler icons +- **State**: Jotai (atoms), TanStack Query (server state via tRPC) +- **Realtime**: WebSocket server (`ws`) for tRPC subscriptions, backed by Redis pub/sub +- **Cron**: `node-cron` in a standalone Fastify service (`apps/tasks`) +- **i18n**: next-intl with `[locale]` dynamic segment (prefix mode: `"never"`, locale from cookie) +- **Testing**: Vitest + jsdom, Playwright for E2E +- **Package manager**: pnpm 10.33.2, Node >= 24.15.0 + +## Repository Structure + +``` +homarr/ +├── apps/ +│ ├── nextjs/ # Main Next.js application (port 3000) +│ ├── tasks/ # Cron job runner + Fastify tRPC API (port 3002) +│ └── websocket/ # Standalone tRPC WebSocket server (port 3001) +├── packages/ +│ ├── api/ # tRPC appRouter, procedures, OpenAPI +│ ├── auth/ # NextAuth config, providers, session, API keys +│ ├── db/ # Drizzle schema (3 DB drivers), migrations, queries +│ ├── core/ # Env validation, DB/Redis driver factories, logging +│ ├── definitions/ # Domain enums: WidgetKind, IntegrationKind, permissions +│ ├── widgets/ # All 39 dashboard widgets (definitions + components) +│ ├── integrations/ # Integration classes (HTTP clients to external apps) +│ ├── redis/ # Redis pub/sub channels, caching abstractions +│ ├── translation/ # next-intl setup, locale configs, lang JSON files +│ ├── ui/ # Shared Mantine components, theme, hooks +│ ├── validation/ # Shared zod schemas for API/forms +│ ├── common/ # Shared utilities, IDs, errors +│ ├── cron-jobs/ # Cron job implementations (25+ jobs) +│ ├── cron-jobs-core/ # Cron scheduling primitives +│ ├── cron-job-api/ # tRPC router for job management (start/stop/trigger) +│ ├── cron-job-status/ # Cron status via Redis +│ ├── boards/ # Board context, edit mode, cache updater +│ ├── modals/ # Modal primitives on Mantine +│ ├── modals-collection/ # Feature modals (apps, boards, docker, etc.) +│ ├── form/ # useZodForm (Mantine + zod resolver) +│ ├── forms-collection/# Reusable form UIs (new app, icon picker, upload) +│ ├── spotlight/ # Command palette / search with multiple modes +│ ├── request-handler/ # Server request handlers (feeds, integrations) +│ ├── notifications/ # Mantine notifications wrapper +│ ├── docker/ # Dockerode-based Docker access +│ ├── icons/ # Icon DB/repo integration +│ ├── image-proxy/ # Image proxy + caching +│ ├── ping/ # Reachability / ping utilities +│ ├── analytics/ # Server-side analytics (Umami) +│ ├── server-settings/ # Server setting keys/types +│ ├── settings/ # User-facing settings UI context +│ ├── old-import/ # Legacy Homarr import +│ ├── old-schema/ # Legacy Homarr zod schemas +│ └── cli/ # Node CLI for ops (brocli) +├── tooling/ +│ ├── eslint/ # Shared ESLint configs +│ ├── prettier/ # Shared Prettier config +│ ├── typescript/ # Base tsconfig +│ └── github/ # CI setup action +├── development/ # Dev docker-compose (Redis, MySQL, PostgreSQL) +├── e2e/ # E2E test specs +└── Dockerfile # Multi-stage production build +``` + +## Three Runtime Services + +| Service | Port | Role | +|---------|------|------| +| Next.js | 3000 | Main web app (SSR + API routes) | +| WebSocket | 3001 | tRPC subscriptions via `ws` | +| Tasks | 3002 | Cron job runner + management API via Fastify | + +In Docker, nginx listens on **7575** and proxies to all three. The tasks service authenticates via `CRON_JOB_API_KEY` header. + +## Package Dependency Layers + +1. **Foundation**: `core` (env, DB drivers, Redis client, logging) → no `@homarr` deps +2. **Domain**: `definitions` → `common` → `core` +3. **Data**: `db` → `core`, `definitions`, `common` +4. **Auth**: `auth` → `db`, `definitions`, `core`, `validation` +5. **Cross-cutting**: `translation`, `validation`, `redis`, `server-settings` +6. **Feature backends**: `integrations`, `request-handler`, `cron-jobs`, `docker`, `ping` +7. **API surface**: `api` → pulls in auth, db, redis, integrations, etc. +8. **UI stack**: `ui`, `modals`, `form`, `notifications`, `boards`, `spotlight` +9. **Composed features**: `widgets`, `modals-collection`, `forms-collection` + +## Key Patterns + +### tRPC + +- **Server caller** (RSC): `import { api } from "@homarr/api/server"` → cached caller with auth context +- **Client hooks**: `import { clientApi } from "@homarr/api/client"` → React Query hooks +- **Router structure**: `packages/api/src/root.ts` defines top-level namespaces (`user`, `board`, `widget`, `integration`, `docker`, `kubernetes`, etc.) +- **Procedures**: `publicProcedure`, `protectedProcedure`, `permissionRequiredProcedure`, `onboardingProcedure` +- **WebSocket subscriptions**: Client uses `wsLink` when `type === "subscription"`, connects to port 3001 + +### Multi-Database Support + +Drizzle schemas exist in three parallel implementations: +- `packages/db/schema/sqlite.ts` +- `packages/db/schema/mysql.ts` +- `packages/db/schema/postgresql.ts` + +Selected at runtime via `DB_DRIVER` env (`better-sqlite3` | `mysql2` | `node-postgres`). Migrations are per-engine in `packages/db/migrations/{sqlite,mysql,postgresql}/`. + +### Widget System + +Widgets are registered in `packages/widgets/src/index.tsx` as `widgetImports` (must satisfy `Record`). Each widget: +- Has a `definition` created via `createWidgetDefinition(kind, { icon, createOptions, supportedIntegrations? })` +- Uses `optionsBuilder.from(factory => ({ ... }))` for typed options +- Loads dynamically via `next/dynamic` +- Fetches data via tRPC client hooks (backed by cron job caches) + +### Integration System + +Integrations are defined in `packages/definitions/src/integration.ts` (`integrationDefs`) and instantiated via `createIntegrationAsync` in `packages/integrations/src/base/creator.ts`. Each extends abstract `Integration` class with `testConnectionAsync` and typed methods. + +### Cron Job System + +Jobs defined in `packages/cron-jobs/src/jobs/`. Each job: +- Uses `createCronJob` with a cron expression +- Publishes results to Redis channels +- Widget subscriptions consume those channels via tRPC subscriptions +- Job management (start/stop/trigger) via `cronJobApi` HTTP client to port 3002 + +### Modals + +Created with `createModal(component).withOptions({ defaultTitle })` from `@homarr/modals`. Opened via `useModalAction(SomeModal)`. Feature modals live in `packages/modals-collection/`. + +### Forms + +`useZodForm` from `@homarr/form` wraps Mantine `useForm` with zod validation. Shared form UIs in `packages/forms-collection/`. + +### Routing (App Router) + +- `apps/nextjs/src/app/[locale]/` — all UI routes under locale segment +- Route groups: `(home)`, `(content)`, `(board)` — don't affect URLs +- Key routes: `boards/[name]`, `manage/...` (admin), `auth/login`, `init` (onboarding), `widgets/[kind]` +- Locale driven by cookie (no URL prefix) + +### Auth Providers + +Configured via `AUTH_PROVIDERS` env. Supports: `credentials` (local), `ldap`, `oidc`. Database sessions with httpOnly cookies. API key auth for programmatic access. + +## Environment Variables + +Key env vars (from `.env.example`): +- `DB_DRIVER`: `better-sqlite3` | `mysql2` | `node-postgres` +- `DB_URL`: Database connection string +- `AUTH_SECRET`: NextAuth secret +- `SECRET_ENCRYPTION_KEY`: AES-256-CBC for integration secrets +- `AUTH_PROVIDERS`: Comma-separated (`credentials`, `ldap`, `oidc`) +- `CRON_JOB_API_KEY`: Shared key between Next.js and tasks service +- `REDIS_*`: Redis connection (host, port, password) +- `ENABLE_KUBERNETES`: Optional Kubernetes integration + +## Development Commands + +- `pnpm dev` — runs all three services in parallel via Turborepo +- `pnpm docker:dev` — starts Redis + MySQL + PostgreSQL containers +- `pnpm db:push` — push schema to SQLite (dev) +- `pnpm db:studio` — Drizzle Studio +- `pnpm test` — Vitest unit tests +- `pnpm test:e2e` — Playwright E2E +- `pnpm lint` / `pnpm format` — ESLint + Prettier +- `pnpm typecheck` — tsc --noEmit across all packages + +## Conventions + +- Workspace packages use `@homarr/` scope +- All versions managed via pnpm `catalog:` in workspace +- Path alias `~/*` → `./src/*` in Next.js app +- Mantine for all UI (no Tailwind) — primary color is red, `autoContrast: true` +- Icons from `@tabler/icons-react` +- Drag-and-drop via `@dnd-kit/*` +- `typescript.ignoreBuildErrors: true` in next.config (types checked separately via `typecheck`) +- `serverExternalPackages`: `dockerode`, `isomorphic-dompurify`, `jsdom` diff --git a/.deepsource.toml b/.deepsource.toml index edc4a8907..e2f2ffcf8 100644 --- a/.deepsource.toml +++ b/.deepsource.toml @@ -3,9 +3,6 @@ version = 1 [[analyzers]] name = "javascript" - [analyzers.meta] - plugins = ["react"] - environment = ["nodejs"] - -[[transformers]] -name = "prettier" \ No newline at end of file +[analyzers.meta] +plugins = ["react"] +environment = ["nodejs"] diff --git a/.dockerignore b/.dockerignore index 6f0d55aee..d75e30111 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,7 +5,12 @@ node_modules npm-debug.log README.md .next +.turbo .git dev .build -e2e \ No newline at end of file +e2e +apps/nextjs/.next +.env +.env.* +!.env.example diff --git a/.env.example b/.env.example index ae3d1e38b..318b171e1 100644 --- a/.env.example +++ b/.env.example @@ -30,11 +30,33 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE' # DB_PASSWORD='password' # DB_NAME='name-of-database' -# The below path can be used to store trusted certificates during development, it is not required and can be left empty. +# The following is an example on how to use the node-postgres driver: +# DB_DRIVER='node-postgres' +# DB_URL='postgres://user:password@host:port/database' + +# The below path can be used to store trusted certificates, it is not required and can be left empty. +# If it is empty, it will default to `/appdata/trusted-certificates` in production. # If it is used, please use the full path to the directory where the certificates are stored. # LOCAL_CERTIFICATE_PATH='FULL_PATH_TO_CERTIFICATES' TURBO_TELEMETRY_DISABLED=1 +# Turborepo remote cache (optional, for faster CI/local builds) +# Uncomment and fill in to enable remote caching locally. +# CI uses GitHub secrets for these values automatically. +# TURBO_API=https://your-cache-worker.workers.dev +# TURBO_TEAM=team_your_team +# TURBO_TOKEN=your-turbo-token +# TURBO_REMOTE_CACHE_SIGNATURE_KEY=your-signature-key + +# Comma-separated paths to Docker-compatible Unix sockets. +# Useful for connecting to multiple runtimes (Docker + Podman) simultaneously. +# When unset and no TCP hosts configured, defaults to /var/run/docker.sock. +# Example: DOCKER_SOCKET_PATHS=/var/run/docker.sock,/run/user/1000/podman/podman.sock +# DOCKER_SOCKET_PATHS= + # Enable kubernetes tool -# ENABLE_KUBERNETES=true \ No newline at end of file +# ENABLE_KUBERNETES=true + +# Enable mock integration +UNSAFE_ENABLE_MOCK_INTEGRATION=true \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index b35fd56f4..973aef4ab 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -31,6 +31,63 @@ body: label: Version description: What version of Homarr are you running? options: + # The below comment is used to insert a new version with on-release.yml + #NEXT_VERSION# + - 1.62.0 + - 1.61.0 + - 1.60.1-beta.1 + - 1.60.0 + - 1.59.3 + - 1.59.2 + - 1.59.1 + - 1.59.0 + - 1.58.1 + - 1.58.0 + - 1.57.1 + - 1.57.0 + - 1.56.1 + - 1.56.0 + - 1.55.0 + - 1.54.0 + - 1.53.0 + - 1.52.0 + - 1.51.0 + - 1.50.1 + - 1.50.0 + - 1.49.1 + - 1.49.0 + - 1.48.0 + - 1.47.0 + - 1.46.0 + - 1.45.3 + - 1.45.2 + - 1.45.1 + - 1.45.0 + - 1.44.0 + - 1.43.1 + - 1.43.0 + - 1.42.1 + - 1.42.0 + - 1.41.0 + - 1.40.0 + - 1.39.0 + - 1.38.0 + - 1.37.0 + - 1.36.1 + - 1.36.0 + - 1.35.1 + - 1.35.0 + - 1.34.0 + - 1.33.0 + - 1.32.0 + - 1.31.0 + - 1.30.1 + - 1.30.0 + - 1.29.0 + - 1.28.1 + - 1.28.0 + - 1.27.0 + - 1.26.0 - 1.25.0 - 1.24.0 - 1.23.0 diff --git a/.github/ISSUE_TEMPLATE/integration.yml b/.github/ISSUE_TEMPLATE/integration.yml index 3aeb492e7..549808df3 100644 --- a/.github/ISSUE_TEMPLATE/integration.yml +++ b/.github/ISSUE_TEMPLATE/integration.yml @@ -25,16 +25,16 @@ body: label: Public API available? description: Is there a public API available, that we can consume in Homarr? options: - - Yes, available on a website - - Yes, available in the application itself - - No + - "Yes, available on a website" + - "Yes, available in the application itself" + - "No" validations: required: true - type: dropdown attributes: label: Are you willing to contribute this yourself? options: - - Yes - - No + - "Yes" + - "No" validations: required: true diff --git a/.github/ISSUE_TEMPLATE/widget.yml b/.github/ISSUE_TEMPLATE/widget.yml index 9ede8d0ba..aa1d0f6a1 100644 --- a/.github/ISSUE_TEMPLATE/widget.yml +++ b/.github/ISSUE_TEMPLATE/widget.yml @@ -24,7 +24,7 @@ body: attributes: label: Are you willing to contribute this yourself? options: - - Yes - - No + - "Yes" + - "No" validations: required: true diff --git a/.github/actions/extract-build-artifact/action.yaml b/.github/actions/extract-build-artifact/action.yaml new file mode 100644 index 000000000..4751c9029 --- /dev/null +++ b/.github/actions/extract-build-artifact/action.yaml @@ -0,0 +1,64 @@ +name: Extract Build Artifact +description: Extracts artifacts from an existing Docker image to be used for from source installation +inputs: + digest: + description: Digest of Docker image to use + required: true + architecture: + description: Name of architecture, will be used to build artifact content (amd64 or arm64) + required: true + release-tag: + description: Tag of the release to which the artifact will be attached + required: true + repository: + description: Repository to which the release belongs, e.g. owner/repo + required: true + token: + description: GitHub token with permissions to upload release assets + required: true +runs: + using: "composite" + steps: + - name: Prebuilt debian dependencies + uses: homarr-labs/homarr/.github/actions/prebuilt-debian@dev + id: prebuilt-debian + with: + architecture: ${{ inputs.architecture }} + - name: Start docker container for ${{ inputs.architecture }} + run: | + docker run --name homarr \ + -e "SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000" \ + --detach --rm ${{ inputs.digest }} + shell: bash + - name: Prepare extraction + run: | + docker exec homarr cp /etc/nginx/templates/nginx.conf /app && \ + mkdir -p ${{ runner.temp }}/extraction/${{ inputs.architecture }} + shell: bash + - name: Extract source from ${{ inputs.architecture }} container (alpine) + run: | + docker exec homarr tar -czf extraction-alpine.tar.gz -C /app . && \ + docker cp homarr:/app/extraction-alpine.tar.gz ${{ runner.temp }}/extraction/${{ inputs.architecture }}/build-alpine-${{ inputs.architecture }}.tar.gz && \ + docker exec homarr rm /app/extraction-alpine.tar.gz + shell: bash + - name: Extract source from ${{ inputs.architecture }} container (debian) + run: | + docker cp ${{ steps.prebuilt-debian.outputs.path }}/. homarr:/app/build && \ + docker cp ${{ steps.prebuilt-debian.outputs.path }}/. homarr:/app/node_modules/better-sqlite3/build/Release && \ + docker exec homarr tar -czf extraction-debian.tar.gz -C /app . && \ + docker cp homarr:/app/extraction-debian.tar.gz ${{ runner.temp }}/extraction/${{ inputs.architecture }}/build-debian-${{ inputs.architecture }}.tar.gz + shell: bash + - name: Stop ${{ inputs.architecture }} container + if: always() + run: docker container remove --force --volumes homarr + shell: bash + - name: Add build archive to release (alpine) + env: + GH_TOKEN: ${{ inputs.token }} + run: gh release upload --repo ${{ inputs.repository }} ${{ inputs.release-tag }} ${{ runner.temp }}/extraction/${{ inputs.architecture }}/build-alpine-${{ inputs.architecture }}.tar.gz --clobber + shell: bash + - name: Add build archive to release (debian) + env: + GH_TOKEN: ${{ inputs.token }} + run: gh release upload --repo ${{ inputs.repository }} ${{ inputs.release-tag }} ${{ runner.temp }}/extraction/${{ inputs.architecture }}/build-debian-${{ inputs.architecture }}.tar.gz --clobber + shell: bash diff --git a/.github/actions/prebuilt-debian/action.yaml b/.github/actions/prebuilt-debian/action.yaml new file mode 100644 index 000000000..af1b7fdad --- /dev/null +++ b/.github/actions/prebuilt-debian/action.yaml @@ -0,0 +1,42 @@ +name: Prebuilt dependencies for debian +description: Provides prebuilt dependencies for debian based docker images +inputs: + architecture: + description: Name of architecture, will be used to build docker image (e.g. amd64, arm64) + required: true +outputs: + path: + description: Path to extracted prebuilt dependencies + value: ${{ runner.temp }}/prebuilts +runs: + using: "composite" + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Build docker image for ${{ inputs.architecture }} + id: build + uses: docker/build-push-action@v7 + with: + push: false + load: true + context: ./deployments/prebuilt-debian + platforms: linux/${{ inputs.architecture }} + tags: prebuilt-debian + - name: Start docker container for ${{ inputs.architecture }} + run: | + docker run --name prebuilt-debian \ + --detach --rm prebuilt-debian + shell: bash + - name: Extract prebuilt dependencies from ${{ inputs.architecture }} container + run: | + mkdir -p ${{ runner.temp }}/prebuilts && \ + docker cp prebuilt-debian:/app/node_modules/better-sqlite3/build/Release/better_sqlite3.node ${{ runner.temp }}/prebuilts/better_sqlite3.node + shell: bash + - name: Stop ${{ inputs.architecture }} container + if: always() + run: docker container remove --force --volumes prebuilt-debian + shell: bash diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 377002605..269a4d2ff 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -3,17 +3,51 @@ extends: ["config:recommended"], packageRules: [ { - matchPackagePatterns: ["^@homarr/"], + matchPackageNames: ["^@homarr/"], + enabled: false, + }, + // Reenable once https://github.com/privatenumber/tsx/issues/737 is fixed + { + matchPackageNames: ["tsx"], + enabled: false, + }, + // Reenable once bug with undici v8 is fixed (https://github.com/homarr-labs/homarr/issues/5467) + { + matchPackageNames: ["undici"], + matchNewValue: "8.*", enabled: false, }, { matchUpdateTypes: ["minor", "patch", "pin", "digest"], automerge: true, }, + { + // disallow automerge for onlyBuiltDependencies + matchPackageNames: [ + "@tree-sitter-grammars/tree-sitter-yaml", + "bcrypt", + "better-sqlite3", + "cpu-features", + "esbuild", + "sharp", + "ssh2", + "tree-sitter", + "tree-sitter-json", + ], + automerge: false, + }, ], + // Updates lockfile for prebuilt-debian after if package.json is updated, otherwise only root lockfile is updated + postUpgradeTasks: { + commands: ["cd deployments/prebuilt-debian && pnpm install --ignore-workspace --lockfile-only"], + fileFilters: ["deployments/prebuilt-debian/pnpm-lock.yaml"], + executionMode: "update", + }, updateInternalDeps: true, rangeStrategy: "bump", automerge: false, baseBranches: ["dev"], dependencyDashboard: false, + // prevent special characters in branch names (otherwise git errors occur on windows) + branchNameStrict: true, } diff --git a/.github/workflows/automatic-approval.yml b/.github/workflows/automatic-approval.yml index 5893a5d7d..6647bad82 100644 --- a/.github/workflows/automatic-approval.yml +++ b/.github/workflows/automatic-approval.yml @@ -3,19 +3,29 @@ on: pull_request: types: [opened, synchronize] +permissions: {} + jobs: approve-automatic-prs: runs-on: ubuntu-latest - if: github.actor_id == 158783068 || github.actor_id == 190541745 || github.actor_id == 210161987 # Id of renovate bot and crowdin bot see https://api.github.com/users/homarr-renovate%5Bbot%5D and https://api.github.com/users/homarr-crowdin%5Bbot%5D and https://api.github.com/users/homarr-update-contributors%5Bbot%5D + timeout-minutes: 2 + # 158783068: homarr-renovate[bot] + # 190541745: homarr-crowdin[bot] + # 210161987: homarr-update-contributors[bot] + # 175486441: homarr-releases[bot] + # Extracted from https://api.github.com/users/ + if: github.actor_id == 158783068 || github.actor_id == 190541745 || github.actor_id == 210161987 || github.actor_id == 175486441 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Obtain token id: obtainToken - uses: tibdex/github-app-token@v2 + uses: actions/create-github-app-token@v3 with: - private_key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }} - app_id: ${{ secrets.RENOVATE_APPROVE_APP_ID }} + private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }} + client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }} + permission-pull-requests: write # required to approve pull request + - name: Install GitHub CLI run: sudo apt-get install -y gh - name: Approve automatic PRs diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 92efa0d03..40d4ceb08 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -1,4 +1,6 @@ name: "[Quality] Code Analysis" +permissions: + contents: read on: pull_request: @@ -9,20 +11,58 @@ on: concurrency: group: "${{ github.workflow }}-${{ github.ref }}" - cancel-in-progress: ${{ github.ref != 'refs/heads/master' }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} -# You can leverage Vercel Remote Caching with Turbo to speed up your builds -# @link https://turborepo.org/docs/core-concepts/remote-caching#remote-caching-on-vercel-builds env: FORCE_COLOR: 3 + SKIP_ENV_VALIDATION: true + TURBO_API: ${{ secrets.TURBO_API }} + TURBO_TEAM: ${{ secrets.TURBO_TEAM }} TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - TURBO_TEAM: ${{ vars.TURBO_TEAM }} + TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} + TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before || 'HEAD~1' }} + TURBO_SCM_HEAD: ${{ github.sha }} jobs: + lint-format-autofix: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.head_ref }} + + - name: Setup + uses: ./tooling/github/setup + + - name: Get changed files + id: changed + run: | + git diff --name-only --diff-filter=ACMR origin/${{ github.base_ref }}...HEAD -- '*.ts' '*.tsx' '*.js' '*.jsx' > /tmp/changed-files + echo "count=$(wc -l < /tmp/changed-files | tr -d '[:space:]')" >> $GITHUB_OUTPUT + + - name: Auto-fix lint + format + if: steps.changed.outputs.count != '0' + run: | + xargs -r -a /tmp/changed-files pnpm exec oxlint --fix || true + xargs -r -a /tmp/changed-files pnpm exec oxfmt --write + + - name: Commit fixes + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: "style: auto-fix lint and format" + lint: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 + with: + fetch-depth: 0 - name: Setup uses: ./tooling/github/setup @@ -32,34 +72,41 @@ jobs: run: cp .env.example .env - name: Lint - run: pnpm lint && pnpm lint:ws + run: pnpm turbo lint --affected && pnpm lint:ws format: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 + with: + fetch-depth: 0 - name: Setup uses: ./tooling/github/setup - name: Format - run: pnpm format + run: pnpm turbo format --affected --continue typecheck: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 + with: + fetch-depth: 0 - name: Setup uses: ./tooling/github/setup - name: Typecheck - run: turbo typecheck + run: pnpm turbo typecheck --affected test: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup uses: ./tooling/github/setup @@ -75,20 +122,25 @@ jobs: e2e: runs-on: ubuntu-latest - + timeout-minutes: 15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup uses: ./tooling/github/setup + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 - name: Build docker image id: build-docker-image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: platforms: linux/amd64 context: . push: false + load: true tags: homarr-e2e network: host + cache-from: type=gha + cache-to: type=gha,mode=max env: SKIP_ENV_VALIDATION: true - name: Install playwright browsers @@ -99,12 +151,15 @@ jobs: build: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 + with: + fetch-depth: 0 - name: Setup uses: ./tooling/github/setup - name: Copy env shell: bash run: cp .env.example .env - name: Build - run: pnpm build + run: pnpm turbo build --affected diff --git a/.github/workflows/conventions-semantic-commits.yml b/.github/workflows/conventions-semantic-commits.yml deleted file mode 100644 index d26307ba0..000000000 --- a/.github/workflows/conventions-semantic-commits.yml +++ /dev/null @@ -1,14 +0,0 @@ -# https://github.com/webiny/action-conventional-commits?tab=readme-ov-file -name: "[Conventions] Semantic Commits" - -on: - pull_request: - branches: [ dev ] - -jobs: - build: - name: Conventional Commits - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: webiny/action-conventional-commits@v1.3.0 \ No newline at end of file diff --git a/.github/workflows/conventions-semantic-pull-requests.yml b/.github/workflows/conventions-semantic-pull-requests.yml index cec459930..89aa7d9b2 100644 --- a/.github/workflows/conventions-semantic-pull-requests.yml +++ b/.github/workflows/conventions-semantic-pull-requests.yml @@ -13,7 +13,8 @@ permissions: jobs: validate-pull-request-title: runs-on: ubuntu-latest + timeout-minutes: 1 steps: - - uses: amannn/action-semantic-pull-request@v5 + - uses: amannn/action-semantic-pull-request@v6 env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/crowdin-schedule-download.yml b/.github/workflows/crowdin-schedule-download.yml index 2891536a4..a5dec8869 100644 --- a/.github/workflows/crowdin-schedule-download.yml +++ b/.github/workflows/crowdin-schedule-download.yml @@ -5,20 +5,26 @@ on: schedule: - cron: "0 0 * * *" # every day at midnight +permissions: + contents: write # required to push changes + jobs: download-crowdin-translations: + timeout-minutes: 5 runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Obtain token id: obtainToken - uses: tibdex/github-app-token@v2 + uses: actions/create-github-app-token@v3 with: - private_key: ${{ secrets.CROWDIN_APP_PRIVATE_KEY }} - app_id: ${{ secrets.CROWDIN_APP_ID }} + private-key: ${{ secrets.CROWDIN_APP_PRIVATE_KEY }} + client-id: ${{ secrets.CROWDIN_APP_ID }} + permission-contents: write # required to commit to crowdin branch + permission-pull-requests: write # required to create pull request - name: Download Crowdin translations id: crowdin-download diff --git a/.github/workflows/crowdin-upload.yml b/.github/workflows/crowdin-upload.yml index 9a9972095..4f8d48de6 100644 --- a/.github/workflows/crowdin-upload.yml +++ b/.github/workflows/crowdin-upload.yml @@ -1,4 +1,6 @@ name: "[Crowdin] Upload translations" +permissions: + contents: read on: workflow_dispatch: @@ -12,10 +14,11 @@ jobs: # Don't run this action if the downloaded translations are being pushed if: "!contains(github.event.head_commit.message, 'chore(lang)')" runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Upload Crowdin translations uses: crowdin/github-action@v2 diff --git a/.github/workflows/deployment-beta-release.yml b/.github/workflows/deployment-beta-release.yml new file mode 100644 index 000000000..fd79d1502 --- /dev/null +++ b/.github/workflows/deployment-beta-release.yml @@ -0,0 +1,104 @@ +name: "[Deployment] Beta Release" + +on: + workflow_dispatch: + inputs: + send-notifications: + type: boolean + required: false + default: true + description: Send notifications + +permissions: + contents: write + pull-requests: write + +jobs: + create-and-merge-pr: + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - name: Discord notification + if: ${{ github.event.inputs.send-notifications }} + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + uses: Ilshidur/action-discord@0.4.0 + with: + args: "Beta release has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})" + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Get Next Version + id: semver + uses: ietf-tools/semver-action@v1 + with: + token: ${{ github.token }} + branch: next + - name: Create pull request + id: create-pull-request + run: 'gh pr create --title "chore(release): release ${{ steps.semver.outputs.next }}-beta" --body "Detected change to be ``${{ steps.semver.outputs.bump }}``
Bump version from ``${{ steps.semver.outputs.current }}`` to ``${{ steps.semver.outputs.next }}-beta``" --base beta --head next --label automerge' + env: + GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + - name: Discord notification + if: ${{ github.event.inputs.send-notifications }} + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + uses: Ilshidur/action-discord@0.4.0 + with: + args: "Created a release PR ${{ steps.create-pull-request.outputs.url }} for version ${{ steps.semver.outputs.next }}-beta (new behaviour: ${{ steps.semver.outputs.bump }})" + - name: Obtain token + id: obtainApprovalToken + uses: actions/create-github-app-token@v3 + with: + private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }} + client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }} + permission-pull-requests: write + - name: Approve PR + env: + GITHUB_TOKEN: ${{ steps.obtainApprovalToken.outputs.token }} + run: | + gh pr review --approve --body "Automatically approved by GitHub Action" + - name: Obtain token + id: obtainMergeToken + uses: actions/create-github-app-token@v3 + with: + private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }} + client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }} + permission-contents: write # write to beta branch (due to merge) + permission-pull-requests: write # merge pull request + - id: automerge + if: ${{ steps.semver.outputs.bump != 'major' }} + name: automerge + uses: "pascalgn/automerge-action@v0.16.4" + env: + GITHUB_TOKEN: ${{ steps.obtainMergeToken.outputs.token }} + MERGE_METHOD: merge # we prefer merge commits for merging to beta + MERGE_COMMIT_MESSAGE: "chore(release): release ${{ steps.semver.outputs.next }}-beta" + MERGE_DELETE_BRANCH: false # never set to true! + PULL_REQUEST: "${{ steps.create-pull-request.outputs.pr_number }}" + MERGE_RETRIES: 20 # 20 retries * MERGE_RETRY_SLEEP until step fails + MERGE_RETRY_SLEEP: 10000 # 10 seconds * MERGE_RETRIES until step fails + MERGE_REQUIRED_APPROVALS: 0 # do not require approvals + + - name: Merged Discord notification + if: ${{ steps.automerge.outputs.mergeResult == 'merged' && github.event.inputs.send-notifications }} + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + uses: Ilshidur/action-discord@0.4.0 + with: + args: "Merged PR ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}-beta" + - name: Major Bump Discord notification + if: ${{ steps.semver.outputs.bump == 'major' && github.event.inputs.send-notifications }} + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + uses: Ilshidur/action-discord@0.4.0 + with: + args: "The release PR must be manually merged because the next version is a major version: ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}-beta" + - name: Discord Fail Notification + if: failure() && github.event.inputs.send-notifications + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + uses: Ilshidur/action-discord@0.4.0 + with: + args: "The beta release workflow [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) has failed" diff --git a/.github/workflows/deployment-docker-image.yml b/.github/workflows/deployment-docker-image.yml index f70798295..98f9357db 100644 --- a/.github/workflows/deployment-docker-image.yml +++ b/.github/workflows/deployment-docker-image.yml @@ -5,6 +5,7 @@ on: branches: - main - beta + - next - dev workflow_dispatch: inputs: @@ -32,12 +33,13 @@ jobs: release: name: Create tag and release runs-on: ubuntu-latest + timeout-minutes: 5 env: - SKIP_RELEASE: ${{ github.event_name == 'workflow_dispatch' || github.ref_name == 'dev' }} + SKIP_RELEASE: ${{ github.event_name == 'workflow_dispatch' || github.ref_name == 'dev' || github.ref_name == 'next' }} outputs: version: ${{ steps.read-semver.outputs.version || steps.version-fallback.outputs.version }} git_ref: ${{ steps.read-git-ref.outputs.ref || github.ref }} - + skipped: ${{ env.SKIP_RELEASE }} steps: - run: echo "Skipping release for workflow_dispatch event" if: env.SKIP_RELEASE == 'true' @@ -49,18 +51,23 @@ jobs: - name: Obtain token if: env.SKIP_RELEASE == 'false' id: obtainToken - uses: tibdex/github-app-token@v2 + uses: actions/create-github-app-token@v3 with: - private_key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }} - app_id: ${{ secrets.RENOVATE_MERGE_APP_ID }} - - uses: actions/checkout@v4 + private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }} + client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }} + permission-contents: write # required to commit package.json & changelog changes, merge them to dev / next and publish the release + + - uses: actions/checkout@v6 if: env.SKIP_RELEASE == 'false' with: persist-credentials: false - - uses: actions/setup-node@v4 + - uses: pnpm/action-setup@v6 + if: env.SKIP_RELEASE == 'false' + - uses: actions/setup-node@v6 if: env.SKIP_RELEASE == 'false' with: - node-version: 22 + node-version: 24.16.0 + package-manager-cache: false - run: npm i -g pnpm if: env.SKIP_RELEASE == 'false' - name: Install dependencies @@ -89,50 +96,52 @@ jobs: id: read-git-ref run: | echo "ref=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - - name: Update dev branch + - name: Update dev / next branch if: env.SKIP_RELEASE == 'false' continue-on-error: true # Prevent pipeline from failing when merge fails env: GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }} + UPSTREAM_BRANCH: ${{ github.ref_name == 'main' && 'dev' || 'next' }} run: | git config user.name "Releases Homarr" git config user.email "175486441+homarr-releases[bot]@users.noreply.github.com" git remote set-url origin https://x-access-token:${{ steps.obtainToken.outputs.token }}@github.com/${{ github.repository }}.git - git fetch origin dev - git checkout dev - git pull origin dev + git fetch origin ${{ env.UPSTREAM_BRANCH }} + git checkout ${{ env.UPSTREAM_BRANCH }} + git pull origin ${{ env.UPSTREAM_BRANCH }} git rebase ${{ github.ref_name }} - git push origin dev + git push origin ${{ env.UPSTREAM_BRANCH }} build-amd64: name: Build docker image for amd64 needs: release runs-on: ubuntu-latest + timeout-minutes: 15 outputs: digest: ${{ steps.build.outputs.digest }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: ref: ${{ needs.release.outputs.git_ref }} - name: Docker meta id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: "${{ env.GHCR_REPO }}" - name: Log in to the Container registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Build and push by digest id: build - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . network: host @@ -141,37 +150,37 @@ jobs: outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true env: SKIP_ENV_VALIDATION: true - build-arm64: name: Build docker image for arm64 needs: release runs-on: ubuntu-24.04-arm + timeout-minutes: 20 outputs: digest: ${{ steps.build.outputs.digest }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: ref: ${{ needs.release.outputs.git_ref }} - name: Docker meta id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: "${{ env.GHCR_REPO }}" - name: Log in to the Container registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Build and push by digest id: build - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . network: host @@ -180,18 +189,58 @@ jobs: outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true env: SKIP_ENV_VALIDATION: true + extract-asset-amd64: + name: Extract amd64 asset from docker image + needs: [release, build-amd64] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Extract amd64 + if: needs.release.outputs.skipped == 'false' + uses: homarr-labs/homarr/.github/actions/extract-build-artifact@dev + with: + digest: "${{ env.GHCR_REPO }}@${{ needs.build-amd64.outputs.digest }}" + architecture: amd64 + release-tag: ${{ needs.release.outputs.version }} + token: ${{ secrets.GITHUB_TOKEN }} + repository: ${{ github.repository }} + extract-asset-arm64: + name: Extract arm64 asset from docker image + needs: [release, build-arm64] + runs-on: ubuntu-24.04-arm + timeout-minutes: 5 + steps: + - name: Extract arm64 + if: needs.release.outputs.skipped == 'false' + uses: homarr-labs/homarr/.github/actions/extract-build-artifact@dev + with: + digest: "${{ env.GHCR_REPO }}@${{ needs.build-arm64.outputs.digest }}" + architecture: arm64 + release-tag: ${{ needs.release.outputs.version }} + token: ${{ secrets.GITHUB_TOKEN }} + repository: ${{ github.repository }} publish: name: Complete deployment and notify - needs: [release, build-amd64, build-arm64] + needs: [release, build-amd64, build-arm64, extract-asset-amd64, extract-asset-arm64] runs-on: ubuntu-latest + timeout-minutes: 5 env: NEXT_VERSION: ${{ needs.release.outputs.version }} DEPLOY_LATEST: ${{ github.ref_name == 'main' }} DEPLOY_BETA: ${{ github.ref_name == 'beta' }} steps: + - name: Obtain token + if: needs.release.outputs.skipped == 'false' + id: obtainToken + uses: actions/create-github-app-token@v3 + with: + private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }} + client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }} + permission-contents: write # required to publish the release + - name: Log in to the Container registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -217,9 +266,16 @@ jobs: ${{ env.GHCR_REPO }}@${{ needs.build-amd64.outputs.digest }} \ ${{ env.GHCR_REPO }}@${{ needs.build-arm64.outputs.digest }} + - name: Publish release + if: needs.release.outputs.skipped == 'false' + env: + GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }} + run: | + gh release edit --repo ${{ github.repository }} ${{ needs.release.outputs.version }} --draft=false + - name: Discord notification env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} - uses: Ilshidur/action-discord@master + uses: Ilshidur/action-discord@0.4.0 with: args: "Successfully deployed images for branch **${{ github.ref_name }}**. Tagged as **${{env.NEXT_VERSION}}**." diff --git a/.github/workflows/deployment-weekly-release.yml b/.github/workflows/deployment-weekly-release.yml index dc5fa0a2e..8cb117b8c 100644 --- a/.github/workflows/deployment-weekly-release.yml +++ b/.github/workflows/deployment-weekly-release.yml @@ -18,16 +18,17 @@ permissions: jobs: create-and-merge-pr: runs-on: ubuntu-latest + timeout-minutes: 2 steps: - name: Discord notification - if: ${{ github.events.inputs.send-notifications }} + if: ${{ github.event.inputs.send-notifications }} env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} - uses: Ilshidur/action-discord@master + uses: Ilshidur/action-discord@0.4.0 with: args: "Automatic release has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})" - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - name: Get Next Version @@ -37,22 +38,24 @@ jobs: token: ${{ github.token }} branch: dev - name: Create pull request + id: create-pull-request run: 'gh pr create --title "chore(release): automatic release ${{ steps.semver.outputs.next }}" --body "**This is an automatic release**.
Manual action may be required for major bumps.
Detected change to be ``${{ steps.semver.outputs.bump }}``
Bump version from ``${{ steps.semver.outputs.current }}`` to ``${{ steps.semver.outputs.next }}``" --base main --head dev --label automerge' env: GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" - name: Discord notification - if: ${{ github.events.inputs.send-notifications }} + if: ${{ github.event.inputs.send-notifications }} env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} - uses: Ilshidur/action-discord@master + uses: Ilshidur/action-discord@0.4.0 with: args: "Created a release PR ${{ steps.create-pull-request.outputs.url }} for version ${{ steps.semver.outputs.next }} (new behaviour: ${{ steps.semver.outputs.bump }})" - name: Obtain token id: obtainApprovalToken - uses: tibdex/github-app-token@v2 + uses: actions/create-github-app-token@v3 with: - private_key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }} - app_id: ${{ secrets.RENOVATE_APPROVE_APP_ID }} + private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }} + client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }} + permission-pull-requests: write - name: Approve PR env: GITHUB_TOKEN: ${{ steps.obtainApprovalToken.outputs.token }} @@ -60,17 +63,19 @@ jobs: gh pr review --approve --body "Automatically approved by GitHub Action" - name: Obtain token id: obtainMergeToken - uses: tibdex/github-app-token@v2 + uses: actions/create-github-app-token@v3 with: - private_key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }} - app_id: ${{ secrets.RENOVATE_MERGE_APP_ID }} + private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }} + client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }} + permission-contents: write # write to main branch (due to merge) + permission-pull-requests: write # merge pull request - id: automerge if: ${{ steps.semver.outputs.bump != 'major' }} name: automerge uses: "pascalgn/automerge-action@v0.16.4" env: GITHUB_TOKEN: ${{ steps.obtainMergeToken.outputs.token }} - MERGE_METHOD: merge # we prefer merge commits for merging to master + MERGE_METHOD: merge # we prefer merge commits for merging to main MERGE_COMMIT_MESSAGE: "chore(release): automatic release ${{ steps.semver.outputs.next }}" MERGE_DELETE_BRANCH: false # never set to true! PULL_REQUEST: "${{ steps.create-pull-request.outputs.pr_number }}" @@ -79,23 +84,23 @@ jobs: MERGE_REQUIRED_APPROVALS: 0 # do not require approvals - name: Merged Discord notification - if: ${{ steps.automerge.outputs.mergeResult == 'merged' && github.events.inputs.send-notifications }} + if: ${{ steps.automerge.outputs.mergeResult == 'merged' && github.event.inputs.send-notifications }} env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} - uses: Ilshidur/action-discord@master + uses: Ilshidur/action-discord@0.4.0 with: args: "Merged PR ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}" - name: Major Bump Discord notification - if: ${{ steps.semver.outputs.bump == 'major' && github.events.inputs.send-notifications }} + if: ${{ steps.semver.outputs.bump == 'major' && github.event.inputs.send-notifications }} env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} - uses: Ilshidur/action-discord@master + uses: Ilshidur/action-discord@0.4.0 with: args: "The release PR must be manually merged because the next version is a major version: ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}" - name: Discord Fail Notification - if: failure() && github.events.inputs.send-notifications + if: failure() && github.event.inputs.send-notifications env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} - uses: Ilshidur/action-discord@master + uses: Ilshidur/action-discord@0.4.0 with: args: "The automatic release workflow [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) has failed" diff --git a/.github/workflows/on-pr-prebuilt-debian-validate.yml b/.github/workflows/on-pr-prebuilt-debian-validate.yml new file mode 100644 index 000000000..025f513e3 --- /dev/null +++ b/.github/workflows/on-pr-prebuilt-debian-validate.yml @@ -0,0 +1,45 @@ +name: "[Deployments] Validate prebuilt debian dependencies" + +permissions: + contents: read + +on: + pull_request: + branches: ["*"] + paths: [".github/actions/prebuilt-debian/**", "deployments/prebuilt-debian/**"] + +jobs: + prebuilt-debian-validate-amd64: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + - name: Validate prebuilt dependencies for amd64 + id: validate-amd64 + uses: ./.github/actions/prebuilt-debian + with: + architecture: amd64 + + - name: Check extracted files for amd64 + run: | + if [ ! -f "${{ steps.validate-amd64.outputs.path }}/better_sqlite3.node" ]; then + echo "better_sqlite3.node not found for amd64!" + exit 1 + fi + prebuilt-debian-validate-arm64: + runs-on: ubuntu-24.04-arm + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + - name: Validate prebuilt dependencies for arm64 + id: validate-arm64 + uses: ./.github/actions/prebuilt-debian + with: + architecture: arm64 + + - name: Check extracted files for arm64 + run: | + if [ ! -f "${{ steps.validate-arm64.outputs.path }}/better_sqlite3.node" ]; then + echo "better_sqlite3.node not found for arm64!" + exit 1 + fi diff --git a/.github/workflows/on-pr-renovate-validate.yml b/.github/workflows/on-pr-renovate-validate.yml new file mode 100644 index 000000000..cd5b27c49 --- /dev/null +++ b/.github/workflows/on-pr-renovate-validate.yml @@ -0,0 +1,19 @@ +name: "[Renovate] Validate configuration" + +permissions: + contents: read + +on: + pull_request: + branches: ["*"] + paths: [".github/renovate.json5"] + +jobs: + renovate-validate: + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - uses: actions/checkout@v6 + - run: | + npx --yes --package renovate -- \ + renovate-config-validator --strict .github/renovate.json5 diff --git a/.github/workflows/on-release.yml b/.github/workflows/on-release.yml new file mode 100644 index 000000000..514f225b4 --- /dev/null +++ b/.github/workflows/on-release.yml @@ -0,0 +1,87 @@ +permissions: {} + +on: + release: + types: [released] + +jobs: + trigger-docs-release: + name: Trigger Documentation Release + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - name: Obtain token + id: obtainToken + uses: actions/create-github-app-token@v3 + with: + private-key: ${{ secrets.HOMARR_DOCS_RELEASE_APP_PRIVATE_KEY }} + client-id: ${{ vars.HOMARR_DOCS_RELEASE_APP_ID }} + owner: homarr-labs + repositories: | + documentation + permission-contents: write # required to dispatch repository workflow + - name: Trigger documentation release + env: + GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }} + SOURCE_TAG: ${{ github.event.release.tag_name }} + run: | + curl -X POST \ + -H "Authorization: token $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + https://api.github.com/repos/homarr-labs/documentation/dispatches \ + -d @- < + token: ${{ steps.obtainToken.outputs.token }} + branch: update-bug-report-template + base: dev + title: "chore: update bug report template" + delete-branch: true + body: | + This PR automatically updates the bug report template of Homarr in the .github/ISSUE_TEMPLATE/bug_report.yml + + - name: Install GitHub CLI + run: sudo apt-get install -y gh + + - name: Enable auto-merge + env: + GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }} + run: | + gh pr merge ${{steps.create-pull-request.outputs.pull-request-number}} --auto --squash diff --git a/.github/workflows/skip-renovate-stability-days.yml b/.github/workflows/skip-renovate-stability-days.yml new file mode 100644 index 000000000..e85b2e33e --- /dev/null +++ b/.github/workflows/skip-renovate-stability-days.yml @@ -0,0 +1,27 @@ +permissions: + statuses: write + +on: + pull_request: + types: [opened, synchronize] + +jobs: + skip-stability-days: + if: ${{ !startsWith(github.head_ref, 'renovate/') && github.event.pull_request.head.repo.full_name == github.repository }} + name: Skip Stability Days + runs-on: ubuntu-latest + timeout-minutes: 1 + steps: + - name: Add status check + env: + GH_TOKEN: ${{secrets.GITHUB_TOKEN}} + run: | + gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + /repos/${{github.repository}}/statuses/${{github.event.pull_request.head.sha}} \ + -f 'state=success' \ + -f 'target_url=https://docs.renovatebot.com/configuration-options#minimumreleaseage' \ + -f 'description=Skipped for non renovate branches' \ + -f 'context=renovate/stability-days' diff --git a/.github/workflows/update-contributors.yml b/.github/workflows/update-contributors.yml index adfd68ad5..925e490c7 100644 --- a/.github/workflows/update-contributors.yml +++ b/.github/workflows/update-contributors.yml @@ -10,31 +10,35 @@ env: CROWDIN_TOKEN: "${{ secrets.CROWDIN_UPDATE_CONTRIBUTORS_TOKEN }}" permissions: - contents: write - + contents: read jobs: update-contributors: runs-on: ubuntu-latest + timeout-minutes: 2 strategy: matrix: node-version: [22] steps: - name: Obtain token id: obtainToken - uses: tibdex/github-app-token@v2 + uses: actions/create-github-app-token@v3 with: - private_key: ${{ secrets.HOMARR_UPDATE_CONTRIBUTORS_PRIVATE_KEY }} - app_id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }} + private-key: ${{ secrets.HOMARR_UPDATE_CONTRIBUTORS_PRIVATE_KEY }} + client-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }} + permission-contents: write # required to commit to branch + permission-pull-requests: write # required to create pr & enable automerge - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 env: GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }} + - uses: pnpm/action-setup@v6 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} + cache: "pnpm" - name: Run update script run: node ./scripts/update-contributors.mjs @@ -50,7 +54,7 @@ jobs: - name: Create Pull Request id: create-pull-request - uses: peter-evans/create-pull-request@v7 + uses: peter-evans/create-pull-request@v8 with: token: ${{ steps.obtainToken.outputs.token }} branch: update-contributors diff --git a/.github/workflows/update-integration-list.yml b/.github/workflows/update-integration-list.yml new file mode 100644 index 000000000..05bb05d27 --- /dev/null +++ b/.github/workflows/update-integration-list.yml @@ -0,0 +1,65 @@ +name: Update integration list + +on: + workflow_dispatch: {} + push: + paths: + - packages/definitions/src/integration.ts + branches: + - dev + +permissions: + pull-requests: write + +jobs: + update-readme: + concurrency: + group: update-integration + cancel-in-progress: false + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Obtain token + id: obtainToken + uses: actions/create-github-app-token@v3 + with: + private-key: ${{ secrets.HOMARR_UPDATE_CONTRIBUTORS_PRIVATE_KEY }} + client-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }} + permission-contents: write # required to commit to branch + permission-pull-requests: write # required to create pr & enable automerge + - name: Checkout code + uses: actions/checkout@v6 + env: + GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }} + - name: Setup + uses: ./tooling/github/setup + - run: pnpm run scripts:update-readme-integrations + - name: Commit changes + env: + GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }} + run: | + git config --global user.email "210161987+homarr-update-contributors[bot]@users.noreply.github.com" + git config --global user.name "Homarr Update Contributors" + git add . + git commit -m "chore: update integrations list readme" + + - name: Create Pull Request + id: create-pull-request + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ steps.obtainToken.outputs.token }} + branch: update-integrations-readme + base: dev + title: "chore: update integrations list readme" + delete-branch: true + body: | + This PR automatically updates the list of integrations of Homarr in the README.md + + - name: Install GitHub CLI + run: sudo apt-get install -y gh + + - name: Enable auto-merge + env: + GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }} + run: | + gh pr merge ${{steps.create-pull-request.outputs.pull-request-number}} --auto --squash diff --git a/.gitignore b/.gitignore index 0aa3ed4c7..0e991cc6c 100644 --- a/.gitignore +++ b/.gitignore @@ -67,4 +67,8 @@ e2e/shared/tmp apps/nextjs/public/images/background.png # next-intl -en.d.json.ts \ No newline at end of file +en.d.json.ts + +# personal agent config +AGENTS.md +.opencode diff --git a/.npmrc b/.npmrc index cfcb8e31b..34451fb2f 100644 --- a/.npmrc +++ b/.npmrc @@ -3,3 +3,4 @@ # @link https://docs.expo.dev/guides/monorepos/#common-issues node-linker=hoisted strict-peer-dependencies=false +engine-strict=true \ No newline at end of file diff --git a/.nvmrc b/.nvmrc index 5b540673a..b832e4001 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22.16.0 +24.16.0 diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 000000000..88c14e2f9 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,7 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "printWidth": 120, + "endOfLine": "lf", + "sortPackageJson": true, + "ignorePatterns": ["**/*.cjs", "**/lang/**", "static-data/**", "CHANGELOG.md"] +} diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 000000000..1dbc54c92 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,37 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["eslint", "typescript", "unicorn", "oxc", "react", "import", "jsx-a11y"], + "categories": { + "correctness": "error", + "suspicious": "warn", + "pedantic": "off", + "perf": "off", + "style": "off", + "restriction": "off", + "nursery": "off" + }, + "rules": { + "eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }], + "typescript/consistent-type-imports": ["warn", { "prefer": "type-imports", "fixStyle": "separate-type-imports" }], + "typescript/no-non-null-assertion": "error", + "import/consistent-type-specifier-style": ["error", "prefer-top-level"], + "unicorn/no-new-array": "warn", + "unicorn/no-useless-spread": "warn", + "unicorn/no-useless-length-check": "warn", + "unicorn/no-useless-fallback-in-spread": "warn", + "jsx-a11y/prefer-tag-over-role": "warn", + "oxc/const-comparisons": "warn", + "react-hooks/exhaustive-deps": "warn", + "react/no-children-prop": "warn", + "jsx-a11y/no-autofocus": "warn", + "jsx-a11y/img-redundant-alt": "warn", + "react/react-in-jsx-scope": "off" + }, + "options": { + "reportUnusedDisableDirectives": "off" + }, + "ignorePatterns": ["**/*.config.js", "**/*.config.mjs", "**/*.cjs", "**/lang/*.d.json.ts", ".next/**"], + "settings": { + "react": { "version": "19" } + } +} diff --git a/.releaserc.json b/.releaserc.json index e2414a583..0f2b07b43 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -44,6 +44,7 @@ { "successComment": false, "failComment": false, + "draftRelease": true, "releaseBodyTemplate": "<%= _.truncate(nextRelease.notes, { 'length': 124000, 'omission': '' }) %>" } ] diff --git a/.run/db_migration_postgresql_generate.run copy.xml b/.run/db_migration_postgresql_generate.run copy.xml new file mode 100644 index 000000000..4bc02bdba --- /dev/null +++ b/.run/db_migration_postgresql_generate.run copy.xml @@ -0,0 +1,13 @@ + + + + + + + + + + ); +}; diff --git a/apps/nextjs/src/components/layout/header.tsx b/apps/nextjs/src/components/layout/header.tsx index dd5d51243..f1e513f30 100644 --- a/apps/nextjs/src/components/layout/header.tsx +++ b/apps/nextjs/src/components/layout/header.tsx @@ -1,8 +1,8 @@ import type { ReactNode } from "react"; -import Link from "next/link"; import { AppShellHeader, Group, UnstyledButton } from "@mantine/core"; import { Spotlight } from "@homarr/spotlight"; +import { Link } from "@homarr/ui"; import { ClientBurger } from "./header/burger"; import { DesktopSearchInput, MobileSearchButton } from "./header/search"; @@ -17,7 +17,7 @@ interface Props { export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => { return ( - + {hasNavigation && } diff --git a/apps/nextjs/src/components/layout/header/button.tsx b/apps/nextjs/src/components/layout/header/button.tsx index 98bdbe0b7..e8d858b6a 100644 --- a/apps/nextjs/src/components/layout/header/button.tsx +++ b/apps/nextjs/src/components/layout/header/button.tsx @@ -1,9 +1,10 @@ import type { ForwardedRef, ReactNode } from "react"; import { forwardRef } from "react"; -import Link from "next/link"; import type { ActionIconProps } from "@mantine/core"; import { ActionIcon } from "@mantine/core"; +import { Link } from "@homarr/ui"; + type HeaderButtonProps = ( | { onClick?: () => void; diff --git a/apps/nextjs/src/components/layout/navigation-link.tsx b/apps/nextjs/src/components/layout/navigation-link.tsx index 6aec0a9f1..2d98d42ec 100644 --- a/apps/nextjs/src/components/layout/navigation-link.tsx +++ b/apps/nextjs/src/components/layout/navigation-link.tsx @@ -1,26 +1,18 @@ "use client"; import type { ReactNode } from "react"; -import Link from "next/link"; import { usePathname } from "next/navigation"; import { NavLink } from "@mantine/core"; +import { Link } from "@homarr/ui"; + export const CommonNavLink = (props: ClientNavigationLink) => "href" in props ? : ; const NavLinkHref = (props: NavigationLinkHref) => { const pathname = usePathname(); return props.external ? ( - + ) : ( { leftSection={props.icon} href={props.href} active={pathname === props.href} - style={{ - borderRadius: 5, - }} /> ); }; @@ -39,14 +28,7 @@ const NavLinkWithItems = (props: NavigationLinkWithItems) => { const pathname = usePathname(); const isActive = props.items.some((item) => item.href === pathname); return ( - + {props.items.map((item) => ( ))} diff --git a/apps/nextjs/src/components/layout/service-worker-registration.tsx b/apps/nextjs/src/components/layout/service-worker-registration.tsx new file mode 100644 index 000000000..ef7fd1de1 --- /dev/null +++ b/apps/nextjs/src/components/layout/service-worker-registration.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { useEffect } from "react"; + +export const ServiceWorkerRegistration = () => { + useEffect(() => { + if (!("serviceWorker" in navigator)) return; + navigator.serviceWorker.register("/sw.js", { scope: "/", updateViaCache: "none" }).catch((error) => { + console.error("Service worker registration failed", error); + }); + }, []); + + return null; +}; diff --git a/apps/nextjs/src/components/manage/danger-zone.tsx b/apps/nextjs/src/components/manage/danger-zone.tsx index 30bfb3e37..38f63185b 100644 --- a/apps/nextjs/src/components/manage/danger-zone.tsx +++ b/apps/nextjs/src/components/manage/danger-zone.tsx @@ -15,7 +15,7 @@ export const DangerZoneRoot = async ({ children }: DangerZoneRootProps) => { {t("common.dangerZone")} - + {Array.isArray(children) ? children.map((child, index) => ( diff --git a/apps/nextjs/src/components/manage/manage-mobile-primary-action.tsx b/apps/nextjs/src/components/manage/manage-mobile-primary-action.tsx new file mode 100644 index 000000000..40192e0f8 --- /dev/null +++ b/apps/nextjs/src/components/manage/manage-mobile-primary-action.tsx @@ -0,0 +1,15 @@ +import type { PropsWithChildren } from "react"; +import { Affix, Box } from "@mantine/core"; + +import { MANAGE_ACTION_BUTTON_MIN_WIDTH, MANAGE_AFFIX_POSITION } from "./manage-page.constants"; + +export const ManageMobilePrimaryAction = ({ children }: PropsWithChildren) => ( + <> + + {children} + + + {children} + + +); diff --git a/apps/nextjs/src/components/manage/manage-page-layout.tsx b/apps/nextjs/src/components/manage/manage-page-layout.tsx new file mode 100644 index 000000000..d8655c58c --- /dev/null +++ b/apps/nextjs/src/components/manage/manage-page-layout.tsx @@ -0,0 +1,44 @@ +import type { ReactNode } from "react"; +import type { MantineSize } from "@mantine/core"; +import { Group, Stack, Title } from "@mantine/core"; + +import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; +import { ManageContainer } from "./manage-container"; +import { MANAGE_FLOATING_ACTION_BOTTOM_OFFSET } from "./manage-page.constants"; + +interface ManagePageLayoutProps { + title: ReactNode; + primaryAction?: ReactNode; + toolbar?: ReactNode; + footer?: ReactNode; + floatingPrimaryAction?: boolean; + size?: MantineSize; + children: ReactNode; +} + +export const ManagePageLayout = ({ + title, + primaryAction, + toolbar, + footer, + floatingPrimaryAction, + size, + children, +}: ManagePageLayoutProps) => { + const titleNode = typeof title === "string" ? {title} : title; + + return ( + + + + + {titleNode} + {primaryAction} + + {toolbar} + {children} + {footer && {footer}} + + + ); +}; diff --git a/apps/nextjs/src/components/manage/manage-page.constants.ts b/apps/nextjs/src/components/manage/manage-page.constants.ts new file mode 100644 index 000000000..7b3a59884 --- /dev/null +++ b/apps/nextjs/src/components/manage/manage-page.constants.ts @@ -0,0 +1,5 @@ +export const MANAGE_AFFIX_POSITION = { bottom: 20, right: 20 } as const; + +export const MANAGE_FLOATING_ACTION_BOTTOM_OFFSET = 48; + +export const MANAGE_ACTION_BUTTON_MIN_WIDTH = 160; diff --git a/apps/nextjs/src/components/manage/mobile-affix-button.tsx b/apps/nextjs/src/components/manage/mobile-affix-button.tsx index abe4f017c..f711751de 100644 --- a/apps/nextjs/src/components/manage/mobile-affix-button.tsx +++ b/apps/nextjs/src/components/manage/mobile-affix-button.tsx @@ -2,13 +2,15 @@ import { forwardRef } from "react"; import type { ButtonProps } from "@mantine/core"; import { Affix, Button, createPolymorphicComponent } from "@mantine/core"; +import { MANAGE_ACTION_BUTTON_MIN_WIDTH, MANAGE_AFFIX_POSITION } from "./manage-page.constants"; + type MobileAffixButtonProps = Omit; export const MobileAffixButton = createPolymorphicComponent<"button", MobileAffixButtonProps>( forwardRef((props, ref) => ( <> -